Compare commits

...

39 commits

Author SHA1 Message Date
John Maguire
85349fa366 Bump Nebula again 2025-05-07 14:24:32 -04:00
John Maguire
d54007fcff Rollback Xcode to 16.2 2025-05-07 11:34:50 -04:00
John Maguire
f0b7e693f6 Apply Ryan's dead tunnels patch 2025-05-07 11:01:36 -04:00
Ian VanSchooten
a8303a166d
Update github runners to use macos 15 / xcode 16 (#276)
According to https://github.com/actions/runner-images#available-images, currently `macos-latest` is pointing to macos 14 images, which come bundled with xcode 15.  Apple now requires xcode 16, which we'll get by updating to `macos-15`.  

This also bumps the version github uses to `16.3` (by default it will stay at 16.0), and adds a note to the README that ideally we should all be using the same version of xcode that is used to build the app that is sent to Apple.

We could get fancier with this, but I don't think it's really necessary right now.  See https://www.polpiella.dev/managing-xcode-installs-using-fastlane for other approaches.
2025-05-07 10:29:48 -04:00
Caleb Jasik
9a3d288a16
Update to flutter stable 3.29.2 (#271) 2025-03-27 10:47:59 -05:00
Ian VanSchooten
2dee5305db
Pin all workflow actions to SHA (#270) 2025-03-17 14:28:01 -04:00
Caleb Jasik
fd991b9a02
Fix some flutter analyze warnings (#268)
* Remove unneccesary Container instances

* Add types to uninitialized variables

* Use the parameter initializing shorthand instead of doing it in the constructor
2025-03-05 08:00:44 -06:00
Caleb Jasik
2b844d27dd
Add Flutter lint (#253)
* Enable `flutter_lints` linting

* Fix unmarked deps, we aren't on web so we don't need a URL strategy

* ` dart fix --apply --code=use_super_parameters`

* `dart fix --apply --code=use_key_in_widget_constructors`

* `dart fix --apply --code=use_function_type_syntax_for_parameters`

* Ignore code-generated `lib/services/theme.dart` file

* `dart fix --apply --code=unnecessary_this`

* `dart fix --apply --code=unnecessary_null_in_if_null_operators`

* `dart fix --apply --code=unnecessary_new`

* `dart fix --apply --code=sort_child_properties_last`

* `dart fix --apply --code=sized_box_for_whitespace`

* `dart fix --apply --code=prefer_typing_uninitialized_variables`

* `dart fix --apply --code=prefer_is_empty`

* `dart fix --apply --code=prefer_interpolation_to_compose_strings`

* `dart fix --apply --code=prefer_final_fields`

* `dart fix --apply --code=prefer_const_constructors_in_immutables`

* `dart fix --apply --code=prefer_collection_literals`

* `dart fix --apply --code=no_leading_underscores_for_local_identifiers`

* `dart fix --apply --code=curly_braces_in_flow_control_structures`

* `dart fix --apply --code=avoid_function_literals_in_foreach_calls`

* `dart fix --apply --code=annotate_overrides`

* Add CI for dart linting

* `dart format lib/`

* Re-enable the `usePathUrlStrategy` call, with proper deps

https://docs.flutter.dev/ui/navigation/url-strategies#configuring-the-url-strategy
2025-03-04 11:29:23 -06:00
Ian VanSchooten
bcfcadec8e
Use material 3 page transitions [android] (#267)
The latest version of flutter (3.29, which we're already using) finally added a decent page transition matching up with the native material 3 transition. (main-api.flutter.dev/flutter/material/FadeForwardsPageTransitionsBuilder-class.html)

To test, fire up the app in android, and navigate around.
2025-02-25 11:42:58 -05:00
Caleb Jasik
330c8348fb
Enable Swift 6 feature flags (#258)
* Specify Swift Language Version 5

* Enable `GlobalConcurrency` swift feature flag

<945d93776c/proposals/0412-strict-concurrency-for-global-variables.md>

* Enable `InferSendableFromCaptures` swift feature flag 
<945d93776c/proposals/0418-inferring-sendable-for-methods.md (L4)>

* Enable `IsolatedDefaultValues` swift feature

<945d93776c/proposals/0411-isolated-default-values.md>

* Enable `ImplicitOpenExistentials` swift feature flag

<https://github.com/swiftlang/swift-evolution/blob/main/proposals/0352-implicit-open-existentials.md>

* Enable `DeprecateApplicationMain` swift feature flag

<945d93776c/proposals/0383-deprecate-uiapplicationmain-and-nsapplicationmain.md>

* Enable `ForwardTrailingClosures` swift feature flag

<945d93776c/proposals/0286-forward-scan-trailing-closures.md>

* Enable `ConciseMagicFile` swift feature flag

<945d93776c/proposals/0274-magic-file.md>

* Enable `ImportOBJCForwardDeclarations` swift feature flag

<945d93776c/proposals/0384-importing-forward-declared-objc-interfaces-and-protocols.md>

* Enable `DisableOutwardActorInference` swift feature flag

<https://github.com/swiftlang/swift-evolution/blob/main/proposals/0401-remove-property-wrapper-isolation.md>

* Enable `ExistentialAny` swift feature flag

<945d93776c/proposals/0335-existential-any.md (L4)>

* Annotate existentials with `any` prefix keyword

<https://github.com/swiftlang/swift-evolution/blob/main/proposals/0335-existential-any.md>

* Enable `RegionBasedIsolation` swift feature flag

This adds two warnings to the build complaining about a sending closure.

<945d93776c/proposals/0414-region-based-isolation.md>
2025-02-25 10:35:12 -06:00
Ian VanSchooten
69b0a4dafa
Add safety to unbindService (#266) 2025-02-25 10:11:47 -05:00
Caleb Jasik
f6016f5da8
Git blame ignore PR 263 (#264) 2025-02-21 09:55:35 -06:00
Caleb Jasik
4621cbc000
Enable CI checking for Swift code formatting and format all swift code (#263) 2025-02-21 09:34:17 -06:00
Caleb Jasik
4d34083572
Use swift-format for Swift file formatting (#261) 2025-02-20 16:09:05 -06:00
Caleb Jasik
e128658599
Add formatting PR to .git-blame-ignore-revs (#255) 2025-02-13 15:54:20 -06:00
Caleb Jasik
ed348ab126
Flutter formatting changes (#252)
* `flutter fmt lib/`

* Re-enable formatting in CI
2025-02-13 15:37:44 -06:00
Caleb Jasik
b2ebe0289a
Upgrade to Flutter 3.29.0 (#251)
* Upgrade to Flutter 3.29.0

* Update cocoapods lock file

* Update .gitignore

* Add analysis_options.yaml to set line length default

* Bump required Dart to 3.7.0 as well

Restrict the dart version to max 3.6.0

* Disable formatting to allow merging with formatting that needs to happen
2025-02-13 14:48:44 -06:00
Caleb Jasik
f2c4b07154
Don't pop an error when there is no logs file (#245) 2025-02-05 13:05:50 -06:00
Caleb Jasik
d3e5291944
Fix padding for placeholder in ConfigTextItem (#243) 2025-02-05 10:03:47 -06:00
Caleb Jasik
961312a20a
Adjust vertical padding for config items (#244) 2025-01-31 14:17:09 -06:00
Caleb Jasik
f69f7cc9a3
Clean up SiteTunnelsScreen & Fix padding for the Checkbox config item (#239)
* Clean up SiteTunnelsScreen

* Adding padding to the checkmark in ConfigCheckboxItem
2025-01-29 16:08:08 -06:00
Caleb Jasik
af1c582984
Update the bottom app bar for the logs screen (#236) 2025-01-29 10:12:24 -06:00
Caleb Jasik
f3d22a83cf
Add padding to the ConfigItem children to prevent text from touching the edge (#237) 2025-01-29 10:11:48 -06:00
Caleb Jasik
126ed2f4b0
Add text wrap toggle to logs screen (#235)
* Add text wrap toggle to logs screen

* Use CupertinoButton.tinted on iOS instead of IconButton
2025-01-27 10:14:29 -06:00
Caleb Jasik
5afc1ef692
Add licenses page (#227)
* Add licenses page

* Use PlatformListTile

* Use platform-specific icons

* More style fixes

* Switch back to using SimplePage

* Make title widget const

* Remove unused imports
2025-01-24 13:37:28 -06:00
Ian VanSchooten
21d8265f42
Clean up enrollment screen (#234)
## Android

Blank form:
|Before|After|
|--|--|
|<img width="395" alt="image" src="https://github.com/user-attachments/assets/5c8a8e26-d030-4b14-b91a-9cbf5e1b2e50" />|<img width="393" alt="Android Studio 2025-01-24 11 34 32" src="https://github.com/user-attachments/assets/408c8003-9bc0-4e09-af58-44f5728dcb59" />|

Text entered:
|Before|After|
|--|--|
|<img width="397" alt="Arc 2025-01-24 11 47 19" src="https://github.com/user-attachments/assets/d5e8bc70-1786-488c-aa1c-c45ddf9a92de" />|<img width="393" alt="image" src="https://github.com/user-attachments/assets/5765e9c3-cf50-4913-b4d1-78780918f35d" />|

Submitted empty:
|Before|After|
|--|--|
|<img width="395" alt="image" src="https://github.com/user-attachments/assets/37b8b9c8-760f-479e-b39f-3502367567ec" />|<img width="397" alt="Android Studio 2025-01-24 11 34 47" src="https://github.com/user-attachments/assets/c381725e-f449-43de-961f-768205b60028" />|

Submitted invalid:
|Before|After|
|--|--|
|<img width="388" alt="image" src="https://github.com/user-attachments/assets/3a3859e1-eff7-4111-9dd0-15edbd3bb998" />|<img width="389" alt="image" src="https://github.com/user-attachments/assets/1ab811df-e3ff-4a1f-9d3d-a2869cbc1877" />|


Unchanged:
<img width="388" alt="image" src="https://github.com/user-attachments/assets/f2719677-a73f-435d-b3c6-a0a4fd64759b" />


## iOS


Blank form:
|Before|After|
|--|--|
|<img width="454" alt="image" src="https://github.com/user-attachments/assets/9afed9d1-c63b-4fcf-80a8-c2a9d2c18e76" />|<img width="454" alt="image" src="https://github.com/user-attachments/assets/c185b7f1-a2b3-4e9b-a49f-fb9790214c3a" />|

Text entered:
|Before|After|
|--|--|
|<img width="452" alt="image" src="https://github.com/user-attachments/assets/d3be265d-9076-4a19-865b-413dfad79ed4" />|<img width="466" alt="image" src="https://github.com/user-attachments/assets/c23e99eb-1001-4f80-b88f-62ffa8c8f7dd" />|


Submitted empty:
(note it says invalid before)
|Before|After|
|--|--|
|<img width="463" alt="image" src="https://github.com/user-attachments/assets/e26c0642-e6cc-4e97-885d-b0309e58ace6" />|<img width="455" alt="image" src="https://github.com/user-attachments/assets/95b8f975-41b5-48a0-b75a-9cb6a9acc50e" />|

Submitted invalid:
|Before|After|
|--|--|
|<img width="484" alt="image" src="https://github.com/user-attachments/assets/1daf3c2e-5fbb-477c-b0c7-66689170d97a" />|<img width="463" alt="image" src="https://github.com/user-attachments/assets/8bef248f-a607-4373-9e9e-6ff08f402b4a" />|
2025-01-24 14:22:40 -05:00
Caleb Jasik
382e2bbbf7
Add vertical padding to ConfigPageItem in case children wrap (#233) 2025-01-24 10:48:00 -06:00
Caleb Jasik
b41054920a
Migrate from colorScheme.background to colorScheme.surface (#229) 2025-01-24 09:51:21 -06:00
Ian VanSchooten
ad45cc1d78
Fix screen titles on iOS (#230)
TODO:
- [x] Address Android, which this probably breaks.

Previously the back button was taking up all the room in the title bar, this fixes it so that we can see titles again.  It also truncates site names so they stay to one line.

|Before|After|
|---|---|
|![image](https://github.com/user-attachments/assets/3e07a50d-fb40-40da-87f8-4d623019b26d)|![Simulator 2025-01-23 16 32 34](https://github.com/user-attachments/assets/ea668973-e67d-4fc5-8731-578e5f3fdd27)|


|Before|After|
|---|---|
|![image](https://github.com/user-attachments/assets/d95e1a9d-f431-42aa-a9f2-357b20c37abb)|![Simulator 2025-01-23 16 11 15](https://github.com/user-attachments/assets/ff3f664b-1983-4514-a492-cf585153e294)|

|Before|After|
|---|---|
|![image](https://github.com/user-attachments/assets/0ea3aa0d-340a-44db-8a0a-e0c8032c2450)|![image](https://github.com/user-attachments/assets/fb7e26c5-5c67-4dd7-808c-d471ca1e913e)|

|Before|After|
|---|---|
|![image](https://github.com/user-attachments/assets/bffec7e3-561d-4a43-ab8a-3bd1cc95003c)|![Simulator 2025-01-23 16 13 23](https://github.com/user-attachments/assets/288c1f7f-4d79-4b59-b693-0cbcdd2024db)|


A few other "After" screenshots:

|Logs|DN enrollment|
|---|---|
|![Simulator 2025-01-23 16 30 48](https://github.com/user-attachments/assets/4698939e-c4ad-4929-bd0b-1b72fc21c439)|![image](https://github.com/user-attachments/assets/4c738c41-af3c-4465-9907-76fce34ecdd9)|
2025-01-24 07:51:37 -05:00
Caleb Jasik
ae412cc407
Fix SimplePage background color variable (#232) 2025-01-23 16:55:28 -06:00
Caleb Jasik
91e6a4f6a2
Use platform-specific canvas color for SimplePage + platform-specific icons (#231)
* Use platform-specific canvas color for SimplePage

* Use platform-specific icon for MainScreen appbar
2025-01-23 16:25:59 -06:00
Caleb Jasik
fc120053f2
Upgrade Gradle and Android Gradle Plugin (#225) 2025-01-23 14:57:07 -06:00
Ian VanSchooten
5a641be96f
Switch to Material 3 for Android (#224)
This exploration updates the Android app to use Google's Material 3 design, and changes up the colors a bit in the process.  
I used a source color of #5D23DD in https://material-foundation.github.io/material-theme-builder/, which generates a full set of material colors that I imported into `lib/services/theme.dart`.  It should be easy to tweak any of the colors that we want, vs previously it was somewhat more difficult because they were being generated "behind the scenes".

This doesn't necessarily need to be done now, but it does align better with more modern Android design patterns and Flutter has said at some point they will stop supporting Material 2.  

Note: this should not have any impact on iOS, since we use the default `CupertinoThemeData` there, without any custom theming.

Here are some before/after comparisons of interesting screens:

Adding a new site:
|Dark Before|Dark After|Light Before|Light After|
|---|---|---|---
|<img width="390" alt="Android Studio 2025-01-22 10 43 55" src="https://github.com/user-attachments/assets/c7954b7f-a8eb-48a5-bd47-237877f078fd" />|<img width="405" alt="Android Studio 2025-01-22 11 16 48" src="https://github.com/user-attachments/assets/b69c1e6f-3464-44af-9f9c-f6377b5ce1d1" />|<img width="389" alt="image" src="https://github.com/user-attachments/assets/40f1e40f-a1f5-42e4-909c-c07b3f82df2b" />|<img width="393" alt="image" src="https://github.com/user-attachments/assets/09f8f8ce-668e-4e69-a4a9-233fb970655f" />|


Confirmation dialog:
|Before|After|
|---|---|
|<img width="264" alt="image" src="https://github.com/user-attachments/assets/7cae1abc-621d-4c59-bf36-0ce3be0af88c" />|<img width="278" alt="Android Studio 2025-01-22 11 43 19" src="https://github.com/user-attachments/assets/d461a97f-d252-494f-8b30-49d33a5a9cda" />|

Settings page:
|Dark Before|Dark After|Light Before|Light After|
|---|---|---|---|
|<img width="394" alt="Android Studio 2025-01-22 11 46 01" src="https://github.com/user-attachments/assets/edfc6c40-ee72-4a7c-b9b7-7c3025f0cdfe" />|<img width="393" alt="Android Studio 2025-01-22 11 46 35" src="https://github.com/user-attachments/assets/d1445041-bbc0-4994-98d5-3eb149afceea" />|<img width="391" alt="Android Studio 2025-01-22 11 46 09" src="https://github.com/user-attachments/assets/295e5cc4-74e2-422a-b478-a3e0bd577b60" />|<img width="391" alt="Android Studio 2025-01-22 11 46 28" src="https://github.com/user-attachments/assets/098339de-f2df-4269-84cf-e3ae2c09a51d" />|

Site with errors:
|Dark Before|Dark After|Light Before|Light After|
|---|---|---|---|
|<img width="395" alt="Android Studio 2025-01-22 11 48 50" src="https://github.com/user-attachments/assets/4f8dd05d-ccc7-44eb-96e0-ffec63975085" />|<img width="390" alt="Android Studio 2025-01-22 11 48 06" src="https://github.com/user-attachments/assets/751f35e2-b801-4ddf-9655-15f0097e05ca" />|<img width="395" alt="image" src="https://github.com/user-attachments/assets/15ac20c9-4d40-4e51-aed6-fd69a001588b" />|<img width="388" alt="Android Studio 2025-01-22 11 48 20" src="https://github.com/user-attachments/assets/780bf849-9add-4f91-bac8-3cdd5fd89337" />|

Main page / site list:
|Light Before|Light After|Light Scrolled Before|Light Scrolled After|
|---|---|---|---|
|<img width="387" alt="Android Studio 2025-01-22 11 53 07" src="https://github.com/user-attachments/assets/ca426470-00c2-4dc3-bd22-4a336c4ec8cf" />|<img width="403" alt="Android Studio 2025-01-22 11 53 41" src="https://github.com/user-attachments/assets/abf755e3-df10-453b-a439-0aa3b21b7ef9" />|<img width="389" alt="Android Studio 2025-01-22 11 53 22" src="https://github.com/user-attachments/assets/a6cdfabf-5288-49fe-ac29-e73678d34ccb" />|<img width="396" alt="Android Studio 2025-01-22 11 53 51" src="https://github.com/user-attachments/assets/11eda1f4-cf2f-4848-9690-6093223a9e7e" />|

Certificate:
|Dark Before|Dark After|Light Before|Light After|
|---|---|---|---|
|<img width="388" alt="Android Studio 2025-01-22 11 57 25" src="https://github.com/user-attachments/assets/5a1db1ba-a560-4aa6-8649-9b41d9ba25b6" />|<img width="390" alt="Android Studio 2025-01-22 11 56 44" src="https://github.com/user-attachments/assets/5e1b9200-ea13-42e5-a55d-51a1fda4522f" />|<img width="397" alt="Android Studio 2025-01-22 11 57 01" src="https://github.com/user-attachments/assets/65c52218-3f90-40c7-bb21-e499c2e0b08c" />|<img width="392" alt="Android Studio 2025-01-22 11 56 21" src="https://github.com/user-attachments/assets/554847cd-e825-4691-ac21-1549ab5e7d21" />|
2025-01-23 10:49:32 -05:00
Caleb Jasik
f290a71b94
Remove unused imports (#223) 2025-01-22 13:50:50 -06:00
Ian VanSchooten
87c16ea95c
Fix iOS 16 support (#222)
In older versions of iOS, it's not possible to call `NETunnelProviderManager.loadAllFromPreferences()` from inside the network extension process.  We were seeing `NETunnelProviderManager objects cannot be instantiated from NEProvider processes` errors in iOS 16.  It's unclear exactly when the change happened to allow it, but as far as we can tell it was in iOS 17. 

To Test:
1. On a real device running iOS 16, ensure that enrolling as a Managed Nebula host works correctly.
2. Start the site.
3. Update the host in the admin panel and wait at least 15 minutes for a `checkForUpdate` from the mobile client.  You should get a `Host renewed` audit log for the host.  
4. Verify that there's a log for "Reloading Nebula" in the mobile host, and that it has an up-to-date config.
2025-01-17 12:31:13 -05:00
Ian VanSchooten
6ed64b7349
Do not allow starting site if there are errors (#220) 2025-01-17 12:30:34 -05:00
Ian VanSchooten
301dc6c394
Minor updates (#217)
These are a few more minor updated dependencies, mostly in go.mod. I also see that the pubspec now has an update to the flutter version, which should have happened previously along with the flutter upgrade, but it didn't for whatever reason.
2025-01-17 12:30:20 -05:00
Ian VanSchooten
a28b922494
Add PrimaryButton component (#221) 2025-01-16 10:59:21 -05:00
Ian VanSchooten
991837676a
Add DangerButton component (#219)
This pulls out a component that we can use for "dangerous" operations like deleting, and styles it in one place.

It also starts to move us slowly towards Material 3, with the rounded corners on these buttons in Android.  

Android:

|Before Light|Before Dark|After Light|After Dark|
|---|---|---|---|
|<img width="425" alt="Android Studio 2025-01-15 14 16 36" src="https://github.com/user-attachments/assets/4823e551-6a40-48dd-9bc1-3004699b90ea" />|<img width="417" alt="Android Studio 2025-01-15 14 16 47" src="https://github.com/user-attachments/assets/df5461fd-586e-47bb-99b9-0212e63f0454" />|<img width="413" alt="Android Studio 2025-01-15 14 15 59" src="https://github.com/user-attachments/assets/d88a6225-b71a-4886-8387-e35811a3a6ec" />|<img width="418" alt="Android Studio 2025-01-15 14 16 15" src="https://github.com/user-attachments/assets/d4f23b1c-7003-4a00-b865-4a123d8fe3e9" />|


iOS:

|Before Light|Before Dark|After Light|After Dark|
|---|---|---|---|
|<img width="437" alt="Simulator 2025-01-15 15 56 26" src="https://github.com/user-attachments/assets/87c4eed3-6d07-4858-8ad8-d8c011538154" />|<img width="445" alt="Simulator 2025-01-15 15 56 36" src="https://github.com/user-attachments/assets/9dc5b174-7bc7-48ec-a3c0-61633168c31a" />|<img width="439" alt="Simulator 2025-01-15 16 05 23" src="https://github.com/user-attachments/assets/31dc9ab6-8a3c-49c7-892d-627f16e2a8cd" />|<img width="444" alt="Simulator 2025-01-15 16 05 37" src="https://github.com/user-attachments/assets/979280d6-e1f4-4d57-a86a-10bb4def729a" />|
2025-01-16 08:16:23 -05:00
93 changed files with 5171 additions and 3919 deletions

View file

@ -1,2 +1,8 @@
# Big flutter format run
9934f226e3e79c3567ce07dbab9e9f6443e7afc5
# Another big flutter format run
ed348ab126160e64ba09899c946383ca9e54768c
# Start formatting with swift-format
4621cbc0006b3c64c8948d920f69b0dc3f503565

42
.github/workflows/fluttercheck.yml vendored Normal file
View file

@ -0,0 +1,42 @@
name: Flutter check
on:
push:
branches:
- main
pull_request:
paths:
- '.github/workflows/fluttercheck.yml'
- '**.dart'
jobs:
flutterfmt:
name: Run flutter format
runs-on: ubuntu-latest
steps:
- name: Install flutter
uses: subosito/flutter-action@f2c4f6686ca8e8d6e6d0f28410eeef506ed66aff #v2.18.0
with:
flutter-version: '3.29.2'
- name: Check out code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #4.2.2
with:
show-progress: false
- name: Check formating
run: dart format -l120 lib/ --set-exit-if-changed --suppress-analytics --output none
flutterlint:
name: Run flutter lint
runs-on: ubuntu-latest
steps:
- name: Install flutter
uses: subosito/flutter-action@f2c4f6686ca8e8d6e6d0f28410eeef506ed66aff #v2.18.0
with:
flutter-version: '3.29.2'
- name: Check out code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #4.2.2
with:
show-progress: false
- name: Check linting
run: dart fix --dry-run

View file

@ -1,29 +0,0 @@
name: Flutter format
on:
push:
branches:
- main
pull_request:
paths:
- '.github/workflows/flutterfmt.yml'
- '.github/workflows/flutterfmt.sh'
- '**.dart'
jobs:
gofmt:
name: Run flutter format
runs-on: ubuntu-latest
steps:
- name: Install flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.1'
- name: Check out code
uses: actions/checkout@v4
with:
show-progress: false
- name: Check formating
run: dart format -l120 lib/ --set-exit-if-changed --suppress-analytics --output none

View file

@ -15,14 +15,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #4.2.2
with:
show-progress: false
- name: Set up Go 1.22
uses: actions/setup-go@v5
uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 #5.3.0
with:
go-version: "1.22"
go-version: '1.22'
cache-dependency-path: nebula/go.sum
- name: Install goimports

View file

@ -8,30 +8,30 @@ on:
jobs:
build:
name: Build ios and android package
runs-on: macos-latest
runs-on: macos-15
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #4.2.2
with:
show-progress: false
fetch-depth: 25 # For sentry releases
- name: Set up Go 1.22
uses: actions/setup-go@v5
uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 #5.3.0
with:
go-version: "1.22"
go-version: '1.22'
cache-dependency-path: nebula/go.sum
- uses: actions/setup-java@v4
- uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 #v4.7.0
with:
distribution: 'zulu'
java-version: '17'
- name: Install flutter
uses: subosito/flutter-action@v2
uses: subosito/flutter-action@f2c4f6686ca8e8d6e6d0f28410eeef506ed66aff #v2.18.0
with:
flutter-version: '3.24.1'
flutter-version: '3.29.2'
- name: Setup bundletool for APK generation
uses: amyu/setup-bundletool@f7a6fdd8e04bb23d2fdf3c2f60c9257a6298a40a
@ -60,8 +60,7 @@ jobs:
- name: Place Github token for fastlane match
env:
TOKEN: ${{ secrets.MACHINE_USER_PAT }}
run:
echo "MATCH_GIT_BASIC_AUTHORIZATION=$(echo -n "defined-machine:${TOKEN}" | base64)" >> $GITHUB_ENV
run: echo "MATCH_GIT_BASIC_AUTHORIZATION=$(echo -n "defined-machine:${TOKEN}" | base64)" >> $GITHUB_ENV
- name: Get build name and number, install dependencies
env:
@ -102,7 +101,7 @@ jobs:
fi
- name: Collect iOS artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 #4.6.1
with:
name: MobileNebula.ipa
path: ios/MobileNebula.ipa
@ -140,7 +139,7 @@ jobs:
unzip -p build/app/outputs/apk/release/MobileNebula.apks universal.apk > build/app/outputs/apk/release/MobileNebula.apk
- name: Collect Android artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 #4.6.1
with:
name: MobileNebula.aab
path: build/app/outputs/bundle/release/app-release.aab

View file

@ -10,28 +10,28 @@ on:
jobs:
build-android:
name: Android
runs-on: macos-latest
runs-on: macos-15
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #4.2.2
with:
show-progress: false
- name: Set up Go 1.22
uses: actions/setup-go@v5
uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 #5.3.0
with:
go-version: "1.22"
go-version: '1.22'
cache-dependency-path: nebula/go.sum
- uses: actions/setup-java@v4
- uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 #v4.7.0
with:
distribution: 'zulu'
java-version: '17'
- name: Install flutter
uses: subosito/flutter-action@v2
uses: subosito/flutter-action@f2c4f6686ca8e8d6e6d0f28410eeef506ed66aff #v2.18.0
with:
flutter-version: '3.24.1'
flutter-version: '3.29.2'
- name: install dependencies
env:
@ -77,7 +77,7 @@ jobs:
unzip -p build/app/outputs/apk/debug/app-debug.apks universal.apk > build/app/outputs/apk/debug/app-debug.apk
- name: Collect debug apk
if: github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 #4.6.1
with:
name: MobileNebulaDebug.apk
path: build/app/outputs/apk/debug/app-debug.apk
@ -85,24 +85,24 @@ jobs:
build-ios:
name: iOS
runs-on: macos-latest
runs-on: macos-15
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #4.2.2
with:
show-progress: false
- name: Set up Go 1.22
uses: actions/setup-go@v5
uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 #5.3.0
with:
go-version: "1.22"
go-version: '1.22'
cache-dependency-path: nebula/go.sum
- name: Install flutter
uses: subosito/flutter-action@v2
uses: subosito/flutter-action@f2c4f6686ca8e8d6e6d0f28410eeef506ed66aff #v2.18.0
with:
flutter-version: '3.24.1'
flutter-version: '3.29.2'
- name: install dependencies
run: |
@ -110,7 +110,7 @@ jobs:
gomobile init
flutter pub get
touch env.sh
- name: Build iOS
run: |
cd ios

21
.github/workflows/swiftfmt.yml vendored Normal file
View file

@ -0,0 +1,21 @@
name: Swift format
on:
push:
branches:
- main
pull_request:
paths:
- ".github/workflows/swiftfmt.yml"
- "**.swift"
jobs:
swiftfmt:
name: Run swift format
runs-on: macos-15
steps:
- name: Check out code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #4.2.2
with:
show-progress: false
- name: Check formating
run: ./swift-format.sh check

2
.gitignore vendored
View file

@ -8,6 +8,7 @@
.buildlog/
.history
.svn/
android/app/.cxx
# IntelliJ related
*.iml
@ -45,6 +46,7 @@ lib/generated_plugin_registrant.dart
/env.sh
/lib/gen.versions.dart
/lib/.gen.versions.dart
/lib/oss_licenses.dart
/ios/Flutter/.last_build_id
/local.properties
/.gradle/

1
.swiftformatignore Normal file
View file

@ -0,0 +1 @@
ios/Pods/**

View file

@ -6,9 +6,9 @@
Install all of the following things:
- [`xcode`](https://apps.apple.com/us/app/xcode/)
- [`xcode`](https://apps.apple.com/us/app/xcode/) - use the version specified by `xcode_select` in `/ios/fastlane/Fastfile`
- [`android-studio`](https://developer.android.com/studio)
- [`flutter` 3.27.0](https://docs.flutter.dev/get-started/install)
- [`flutter` 3.29.2](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)
@ -22,7 +22,7 @@ Run `flutter doctor` and fix everything it complains before proceeding
- 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 `26.1.10909125` version.
- 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 `27.0.12077973` 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`
@ -41,6 +41,9 @@ dart format lib/ test/ -l 120
In Android Studio, set the line length using Preferences -> Editor -> Code Style -> Dart -> Line length, set it to 120. Enable auto-format with Preferences -> Languages & Frameworks -> Flutter -> Format code on save.
`./swift-format.sh` can be used to format Swift code in the repo.
Once `swift-format` supports ignoring directories (<https://github.com/swiftlang/swift-format/issues/870>), we can move to a method of running it more like what <https://calebhearth.com/swift-format-github-action> describes.
# Release
@ -56,4 +59,4 @@ Upload the android bundle to the google play store https://play.google.com/apps/
## iOS
In xcode, Release -> Archive then follow the directions to upload to the app store. If you have issues, https://flutter.dev/docs/deployment/ios#create-a-build-archive
In xcode, Release -> Archive then follow the directions to upload to the app store. If you have issues, https://flutter.dev/docs/deployment/ios#create-a-build-archive

35
analysis_options.yaml Normal file
View file

@ -0,0 +1,35 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/tools/linter-rules.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/tools/analysis
formatter:
page_width: 120
analyzer:
exclude:
# This is a generated file, let's ignore it.
- lib/services/theme.dart

View file

@ -28,8 +28,8 @@ android {
compileSdkVersion 34
// default ndk version for AGP 8.5: https://developer.android.com/build/releases/past-releases/agp-8-5-0-release-notes
ndkVersion "26.1.10909125"
// default ndk version for AGP 8.7: https://developer.android.com/build/releases/past-releases/agp-8-7-0-release-notes
ndkVersion "27.0.12077973"
sourceSets {
main.java.srcDirs += 'src/main/kotlin'

View file

@ -36,6 +36,10 @@ class MainActivity: FlutterActivity() {
private var apiClient: APIClient? = null
private var sites: Sites? = null
// Don't attempt to unbind from the service unless the client has received some
// information about the service's state.
private var isServiceBound = false
// 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
@ -440,6 +444,7 @@ class MainActivity: FlutterActivity() {
private val connection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
outMessenger = Messenger(service)
isServiceBound = true
// We want to monitor the service for as long as we are connected to it.
try {
@ -461,6 +466,7 @@ class MainActivity: FlutterActivity() {
override fun onServiceDisconnected(arg0: ComponentName) {
outMessenger = null
isServiceBound = false
if (activeSiteId != null) {
//TODO: this indicates the service died, notify that it is disconnected
}
@ -510,7 +516,14 @@ class MainActivity: FlutterActivity() {
msg.replyTo = inMessenger
outMessenger!!.send(msg)
// Unbind
unbindService(connection)
if (isServiceBound) {
try {
unbindService(connection)
isServiceBound = false
} catch (e: IllegalArgumentException) {
Log.e(TAG, e.toString())
}
}
}
outMessenger = null
}

View file

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip

View file

@ -19,7 +19,7 @@ pluginManagement {
plugins {
id "org.gradle.toolchains.foojay-resolver-convention" version "0.8.0"
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.6.1' apply false
id "com.android.application" version '8.8.0' apply false
id "org.jetbrains.kotlin.android" version "2.0.20" apply false
}

View file

@ -50,3 +50,6 @@ cd ..
# Try and avoid issues with building by moving into place after we are complete
#TODO: this might be a parallel build of deps issue in kotlin, might need to solve there
mv lib/.gen.versions.dart lib/gen.versions.dart
# Generate licenses library
flutter pub run flutter_oss_licenses:generate.dart

View file

@ -3,67 +3,68 @@ import Foundation
let groupName = "group.net.defined.mobileNebula"
class KeyChain {
class func save(key: String, data: Data, managed: Bool) -> Bool {
var query: [String: Any] = [
kSecClass as String : kSecClassGenericPassword as String,
kSecAttrAccount as String : key,
kSecValueData as String : data,
kSecAttrAccessGroup as String: groupName,
]
if (managed) {
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
}
class func save(key: String, data: Data, managed: Bool) -> Bool {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword as String,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessGroup as String: groupName,
]
// Attempt to delete an existing key to allow for an overwrite
_ = self.delete(key: key)
return SecItemAdd(query as CFDictionary, nil) == 0
if managed {
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
}
class func load(key: String) -> Data? {
let query: [String: Any] = [
kSecClass as String : kSecClassGenericPassword,
kSecAttrAccount as String : key,
kSecReturnData as String : kCFBooleanTrue!,
kSecMatchLimit as String : kSecMatchLimitOne,
kSecAttrAccessGroup as String: groupName,
]
// Attempt to delete an existing key to allow for an overwrite
_ = self.delete(key: key)
return SecItemAdd(query as CFDictionary, nil) == 0
}
var dataTypeRef: AnyObject? = nil
class func load(key: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: kCFBooleanTrue!,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecAttrAccessGroup as String: groupName,
]
let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
var dataTypeRef: AnyObject? = nil
if status == noErr {
return dataTypeRef as! Data?
} else {
return nil
}
let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
if status == noErr {
return dataTypeRef as! Data?
} else {
return nil
}
class func delete(key: String) -> Bool {
let query: [String: Any] = [
kSecClass as String : kSecClassGenericPassword as String,
kSecAttrAccount as String : key,
kSecAttrAccessGroup as String: groupName,
]
}
return SecItemDelete(query as CFDictionary) == 0
}
class func delete(key: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword as String,
kSecAttrAccount as String: key,
kSecAttrAccessGroup as String: groupName,
]
return SecItemDelete(query as CFDictionary) == 0
}
}
extension Data {
init<T>(from value: T) {
var value = value
var data = Data()
withUnsafePointer(to: &value, { (ptr: UnsafePointer<T>) -> Void in
data = Data(buffer: UnsafeBufferPointer(start: ptr, count: 1))
})
self.init(data)
}
init<T>(from value: T) {
var value = value
var data = Data()
withUnsafePointer(
to: &value,
{ (ptr: UnsafePointer<T>) -> Void in
data = Data(buffer: UnsafeBufferPointer(start: ptr, count: 1))
})
self.init(data)
}
func to<T>(type: T.Type) -> T {
return self.withUnsafeBytes { $0.load(as: T.self) }
}
func to<T>(type: T.Type) -> T {
return self.withUnsafeBytes { $0.load(as: T.self) }
}
}

View file

@ -1,303 +1,323 @@
import NetworkExtension
import MobileNebula
import os.log
import NetworkExtension
import SwiftyJSON
import os.log
enum VPNStartError: Error {
case noManagers
case couldNotFindManager
case noTunFileDescriptor
case noProviderConfig
case noManagers
case couldNotFindManager
case noTunFileDescriptor
case noProviderConfig
}
enum AppMessageError: Error {
case unknownIPCType(command: String)
case unknownIPCType(command: String)
}
extension AppMessageError: LocalizedError {
public var description: String? {
switch self {
case .unknownIPCType(let command):
return NSLocalizedString("Unknown IPC message type \(String(command))", comment: "")
}
public var description: String? {
switch self {
case .unknownIPCType(let command):
return NSLocalizedString("Unknown IPC message type \(String(command))", comment: "")
}
}
}
class PacketTunnelProvider: NEPacketTunnelProvider {
private var networkMonitor: NWPathMonitor?
private var site: Site?
private let log = Logger(subsystem: "net.defined.mobileNebula", category: "PacketTunnelProvider")
private var nebula: MobileNebulaNebula?
private var dnUpdater = DNUpdater()
private var didSleep = false
private var cachedRouteDescription: String?
override func startTunnel(options: [String : NSObject]? = nil) async throws {
// There is currently no way to get initialization errors back to the UI via completionHandler here
// `expectStart` is sent only via the UI which means we should wait for the real start command which has another completion handler the UI can intercept
if options?["expectStart"] != nil {
// startTunnel must complete before IPC will work
return
}
// VPN is being booted out of band of the UI. Use the system completion handler as there will be nothing to route initialization errors to but we still need to report
// success/fail by the presence of an error or nil
try await start()
private var networkMonitor: NWPathMonitor?
private var site: Site?
private let log = Logger(subsystem: "net.defined.mobileNebula", category: "PacketTunnelProvider")
private var nebula: MobileNebulaNebula?
private var dnUpdater = DNUpdater()
private var didSleep = false
private var cachedRouteDescription: String?
override func startTunnel(options: [String: NSObject]? = nil) async throws {
// There is currently no way to get initialization errors back to the UI via completionHandler here
// `expectStart` is sent only via the UI which means we should wait for the real start command which has another completion handler the UI can intercept
if options?["expectStart"] != nil {
// startTunnel must complete before IPC will work
return
}
private func start() async throws {
var manager: NETunnelProviderManager?
var config: Data
var key: String
// VPN is being booted out of band of the UI. Use the system completion handler as there will be nothing to route initialization errors to but we still need to report
// success/fail by the presence of an error or nil
try await start()
}
private func start() async throws {
var manager: NETunnelProviderManager?
var config: Data
var key: String
do {
// Cannot use NETunnelProviderManager.loadAllFromPreferences() in earlier versions of iOS
// TODO: Remove else once we drop support for iOS 16
if ProcessInfo().isOperatingSystemAtLeast(
OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0))
{
manager = try await self.findManager()
guard let foundManager = manager else {
throw VPNStartError.couldNotFindManager
}
do {
self.site = try Site(manager: foundManager)
config = try self.site!.getConfig()
} catch {
//TODO: need a way to notify the app
self.log.error("Failed to render config from vpn object")
throw error
throw VPNStartError.couldNotFindManager
}
self.site = try Site(manager: foundManager)
} else {
// This does not save the manager with the site, which means we cannot update the
// vpn profile name when updates happen (rare).
self.site = try Site(proto: self.protocolConfiguration as! NETunnelProviderProtocol)
}
config = try self.site!.getConfig()
} catch {
//TODO: need a way to notify the app
self.log.error("Failed to render config from vpn object")
throw error
}
let _site = self.site!
key = try _site.getKey()
guard let fileDescriptor = self.tunnelFileDescriptor else {
throw VPNStartError.noTunFileDescriptor
}
let tunFD = Int(fileDescriptor)
let _site = self.site!
key = try _site.getKey()
// This is set to 127.0.0.1 because it has to be something..
let tunnelNetworkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1")
guard let fileDescriptor = self.tunnelFileDescriptor else {
throw VPNStartError.noTunFileDescriptor
}
let tunFD = Int(fileDescriptor)
// Make sure our ip is routed to the tun device
var err: NSError?
let ipNet = MobileNebulaParseCIDR(_site.cert!.cert.details.ips[0], &err)
if (err != nil) {
throw err!
}
tunnelNetworkSettings.ipv4Settings = NEIPv4Settings(addresses: [ipNet!.ip], subnetMasks: [ipNet!.maskCIDR])
var routes: [NEIPv4Route] = [NEIPv4Route(destinationAddress: ipNet!.network, subnetMask: ipNet!.maskCIDR)]
// This is set to 127.0.0.1 because it has to be something..
let tunnelNetworkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1")
// Add our unsafe routes
try _site.unsafeRoutes.forEach { unsafeRoute in
let ipNet = MobileNebulaParseCIDR(unsafeRoute.route, &err)
if (err != nil) {
throw err!
}
routes.append(NEIPv4Route(destinationAddress: ipNet!.network, subnetMask: ipNet!.maskCIDR))
}
// Make sure our ip is routed to the tun device
var err: NSError?
let ipNet = MobileNebulaParseCIDR(_site.cert!.cert.details.ips[0], &err)
if err != nil {
throw err!
}
tunnelNetworkSettings.ipv4Settings = NEIPv4Settings(
addresses: [ipNet!.ip], subnetMasks: [ipNet!.maskCIDR])
var routes: [NEIPv4Route] = [
NEIPv4Route(destinationAddress: ipNet!.network, subnetMask: ipNet!.maskCIDR)
]
tunnelNetworkSettings.ipv4Settings!.includedRoutes = routes
tunnelNetworkSettings.mtu = _site.mtu as NSNumber
// Add our unsafe routes
try _site.unsafeRoutes.forEach { unsafeRoute in
let ipNet = MobileNebulaParseCIDR(unsafeRoute.route, &err)
if err != nil {
throw err!
}
routes.append(NEIPv4Route(destinationAddress: ipNet!.network, subnetMask: ipNet!.maskCIDR))
}
try await self.setTunnelNetworkSettings(tunnelNetworkSettings)
var nebulaErr: NSError?
self.nebula = MobileNebulaNewNebula(String(data: config, encoding: .utf8), key, self.site!.logFile, tunFD, &nebulaErr)
self.startNetworkMonitor()
tunnelNetworkSettings.ipv4Settings!.includedRoutes = routes
tunnelNetworkSettings.mtu = _site.mtu as NSNumber
if nebulaErr != nil {
self.log.error("We had an error starting up: \(nebulaErr, privacy: .public)")
throw nebulaErr!
try await self.setTunnelNetworkSettings(tunnelNetworkSettings)
var nebulaErr: NSError?
self.nebula = MobileNebulaNewNebula(
String(data: config, encoding: .utf8), key, self.site!.logFile, tunFD, &nebulaErr)
self.startNetworkMonitor()
if nebulaErr != nil {
self.log.error("We had an error starting up: \(nebulaErr, privacy: .public)")
throw nebulaErr!
}
self.nebula!.start()
self.dnUpdater.updateSingleLoop(site: self.site!, onUpdate: self.handleDNUpdate)
}
private func handleDNUpdate(newSite: Site) {
do {
self.site = newSite
try self.nebula?.reload(
String(data: newSite.getConfig(), encoding: .utf8), key: newSite.getKey())
} catch {
log.error(
"Got an error while updating nebula \(error.localizedDescription, privacy: .public)")
}
}
//TODO: Sleep/wake get called aggressively and do nothing to help us here, we should locate why that is and make these work appropriately
// override func sleep(completionHandler: @escaping () -> Void) {
// nebula!.sleep()
// completionHandler()
// }
private func findManager() async throws -> NETunnelProviderManager {
let targetProtoConfig = self.protocolConfiguration as? NETunnelProviderProtocol
guard let targetProviderConfig = targetProtoConfig?.providerConfiguration else {
throw VPNStartError.noProviderConfig
}
let targetID = targetProviderConfig["id"] as? String
// Load vpn configs from system, and find the manager matching the one being started
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
for manager in managers {
let mgrProtoConfig = manager.protocolConfiguration as? NETunnelProviderProtocol
guard let mgrProviderConfig = mgrProtoConfig?.providerConfiguration else {
throw VPNStartError.noProviderConfig
}
let id = mgrProviderConfig["id"] as? String
if id == targetID {
return manager
}
}
// If we didn't find anything, throw an error
throw VPNStartError.noManagers
}
private func startNetworkMonitor() {
networkMonitor = NWPathMonitor()
networkMonitor!.pathUpdateHandler = self.pathUpdate
networkMonitor!.start(queue: DispatchQueue(label: "NetworkMonitor"))
}
private func stopNetworkMonitor() {
self.networkMonitor?.cancel()
networkMonitor = nil
}
override func stopTunnel(
with reason: NEProviderStopReason, completionHandler: @escaping () -> Void
) {
nebula?.stop()
stopNetworkMonitor()
completionHandler()
}
private func pathUpdate(path: Network.NWPath) {
let routeDescription = collectAddresses(endpoints: path.gateways)
if routeDescription != cachedRouteDescription {
// Don't bother to rebind if we don't have any gateways
if routeDescription != "" {
nebula?.rebind(
"network change to: \(routeDescription); from: \(cachedRouteDescription ?? "none")")
}
cachedRouteDescription = routeDescription
}
}
private func collectAddresses(endpoints: [Network.NWEndpoint]) -> String {
var str: [String] = []
endpoints.forEach { endpoint in
switch endpoint {
case let .hostPort(.ipv6(host), port):
str.append("[\(host)]:\(port)")
case let .hostPort(.ipv4(host), port):
str.append("\(host):\(port)")
default:
return
}
}
return str.sorted().joined(separator: ", ")
}
override func handleAppMessage(_ data: Data) async -> Data? {
guard let call = try? JSONDecoder().decode(IPCRequest.self, from: data) else {
log.error("Failed to decode IPCRequest from network extension")
return nil
}
var error: (any Error)?
var data: JSON?
// start command has special treatment due to needing to call two completers
if call.command == "start" {
do {
try await self.start()
// No response data, this is expected on a clean start
return try? JSONEncoder().encode(IPCResponse.init(type: .success, message: nil))
} catch {
defer {
self.cancelTunnelWithError(error)
}
self.nebula!.start()
self.dnUpdater.updateSingleLoop(site: self.site!, onUpdate: self.handleDNUpdate)
return try? JSONEncoder().encode(
IPCResponse.init(type: .error, message: JSON(error.localizedDescription)))
}
}
private func handleDNUpdate(newSite: Site) {
do {
self.site = newSite
try self.nebula?.reload(String(data: newSite.getConfig(), encoding: .utf8), key: newSite.getKey())
} catch {
log.error("Got an error while updating nebula \(error.localizedDescription, privacy: .public)")
if nebula == nil {
// Respond with an empty success message in the event a command comes in before we've truly started
log.warning("Received command but do not have a nebula instance")
return try? JSONEncoder().encode(IPCResponse.init(type: .success, message: nil))
}
//TODO: try catch over all this
switch call.command {
case "listHostmap": (data, error) = listHostmap(pending: false)
case "listPendingHostmap": (data, error) = listHostmap(pending: true)
case "getHostInfo": (data, error) = getHostInfo(args: call.arguments!)
case "setRemoteForTunnel": (data, error) = setRemoteForTunnel(args: call.arguments!)
case "closeTunnel": (data, error) = closeTunnel(args: call.arguments!)
default:
error = AppMessageError.unknownIPCType(command: call.command)
}
if error != nil {
return try? JSONEncoder().encode(
IPCResponse.init(
type: .error, message: JSON(error?.localizedDescription ?? "Unknown error")))
} else {
return try? JSONEncoder().encode(IPCResponse.init(type: .success, message: data))
}
}
private func listHostmap(pending: Bool) -> (JSON?, (any Error)?) {
var err: NSError?
let res = nebula!.listHostmap(pending, error: &err)
return (JSON(res), err)
}
private func getHostInfo(args: JSON) -> (JSON?, (any Error)?) {
var err: NSError?
let res = nebula!.getHostInfo(
byVpnIp: args["vpnIp"].string, pending: args["pending"].boolValue, error: &err)
return (JSON(res), err)
}
private func setRemoteForTunnel(args: JSON) -> (JSON?, (any Error)?) {
var err: NSError?
let res = nebula!.setRemoteForTunnel(
args["vpnIp"].string, addr: args["addr"].string, error: &err)
return (JSON(res), err)
}
private func closeTunnel(args: JSON) -> (JSON?, (any Error)?) {
let res = nebula!.closeTunnel(args["vpnIp"].string)
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)
}
}
//TODO: Sleep/wake get called aggressively and do nothing to help us here, we should locate why that is and make these work appropriately
// override func sleep(completionHandler: @escaping () -> Void) {
// nebula!.sleep()
// completionHandler()
// }
private func findManager() async throws -> NETunnelProviderManager {
let targetProtoConfig = self.protocolConfiguration as? NETunnelProviderProtocol
guard let targetProviderConfig = targetProtoConfig?.providerConfiguration else {
throw VPNStartError.noProviderConfig
}
if ret != 0 || addr.sc_family != AF_SYSTEM {
continue
}
if ctlInfo.ctl_id == 0 {
ret = ioctl(fd, CTLIOCGINFO, &ctlInfo)
if ret != 0 {
continue
}
let targetID = targetProviderConfig["id"] as? String
// Load vpn configs from system, and find the manager matching the one being started
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
for manager in managers {
let mgrProtoConfig = manager.protocolConfiguration as? NETunnelProviderProtocol
guard let mgrProviderConfig = mgrProtoConfig?.providerConfiguration else {
throw VPNStartError.noProviderConfig
}
let id = mgrProviderConfig["id"] as? String
if (id == targetID) {
return manager
}
}
// If we didn't find anything, throw an error
throw VPNStartError.noManagers
}
private func startNetworkMonitor() {
networkMonitor = NWPathMonitor()
networkMonitor!.pathUpdateHandler = self.pathUpdate
networkMonitor!.start(queue: DispatchQueue(label: "NetworkMonitor"))
}
private func stopNetworkMonitor() {
self.networkMonitor?.cancel()
networkMonitor = nil
}
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
nebula?.stop()
stopNetworkMonitor()
completionHandler()
}
private func pathUpdate(path: Network.NWPath) {
let routeDescription = collectAddresses(endpoints: path.gateways)
if routeDescription != cachedRouteDescription {
// Don't bother to rebind if we don't have any gateways
if routeDescription != "" {
nebula?.rebind("network change to: \(routeDescription); from: \(cachedRouteDescription ?? "none")")
}
cachedRouteDescription = routeDescription
}
}
private func collectAddresses(endpoints: [Network.NWEndpoint]) -> String {
var str: [String] = []
endpoints.forEach{ endpoint in
switch endpoint {
case let .hostPort(.ipv6(host), port):
str.append("[\(host)]:\(port)")
case let .hostPort(.ipv4(host), port):
str.append("\(host):\(port)")
default:
return
}
}
return str.sorted().joined(separator: ", ")
}
override func handleAppMessage(_ data: Data) async -> Data? {
guard let call = try? JSONDecoder().decode(IPCRequest.self, from: data) else {
log.error("Failed to decode IPCRequest from network extension")
return nil
}
var error: Error?
var data: JSON?
// start command has special treatment due to needing to call two completers
if call.command == "start" {
do {
try await self.start()
// No response data, this is expected on a clean start
return try? JSONEncoder().encode(IPCResponse.init(type: .success, message: nil))
} catch {
defer {
self.cancelTunnelWithError(error)
}
return try? JSONEncoder().encode(IPCResponse.init(type: .error, message: JSON(error.localizedDescription)))
}
}
if nebula == nil {
// Respond with an empty success message in the event a command comes in before we've truly started
log.warning("Received command but do not have a nebula instance")
return try? JSONEncoder().encode(IPCResponse.init(type: .success, message: nil))
}
//TODO: try catch over all this
switch call.command {
case "listHostmap": (data, error) = listHostmap(pending: false)
case "listPendingHostmap": (data, error) = listHostmap(pending: true)
case "getHostInfo": (data, error) = getHostInfo(args: call.arguments!)
case "setRemoteForTunnel": (data, error) = setRemoteForTunnel(args: call.arguments!)
case "closeTunnel": (data, error) = closeTunnel(args: call.arguments!)
default:
error = AppMessageError.unknownIPCType(command: call.command)
}
if (error != nil) {
return try? JSONEncoder().encode(IPCResponse.init(type: .error, message: JSON(error?.localizedDescription ?? "Unknown error")))
} else {
return try? JSONEncoder().encode(IPCResponse.init(type: .success, message: data))
}
}
private func listHostmap(pending: Bool) -> (JSON?, Error?) {
var err: NSError?
let res = nebula!.listHostmap(pending, error: &err)
return (JSON(res), err)
}
private func getHostInfo(args: JSON) -> (JSON?, Error?) {
var err: NSError?
let res = nebula!.getHostInfo(byVpnIp: args["vpnIp"].string, pending: args["pending"].boolValue, error: &err)
return (JSON(res), err)
}
private func setRemoteForTunnel(args: JSON) -> (JSON?, Error?) {
var err: NSError?
let res = nebula!.setRemoteForTunnel(args["vpnIp"].string, addr: args["addr"].string, error: &err)
return (JSON(res), err)
}
private func closeTunnel(args: JSON) -> (JSON?, Error?) {
let res = nebula!.closeTunnel(args["vpnIp"].string)
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
}
if addr.sc_id == ctlInfo.ctl_id {
return fd
}
}
return nil
}
}

View file

@ -1,543 +1,550 @@
import NetworkExtension
import MobileNebula
import NetworkExtension
import SwiftyJSON
import os.log
let log = Logger(subsystem: "net.defined.mobileNebula", category: "Site")
enum SiteError: Error {
case nonConforming(site: [String : Any]?)
case noCertificate
case keyLoad
case keySave
case unmanagedGetCredentials
case dnCredentialLoad
case dnCredentialSave
case nonConforming(site: [String: Any]?)
case noCertificate
case keyLoad
case keySave
case unmanagedGetCredentials
case dnCredentialLoad
case dnCredentialSave
// Throw in all other cases
case unexpected(code: Int)
// Throw in all other cases
case unexpected(code: Int)
}
extension SiteError: CustomStringConvertible {
public var description: String {
switch self {
case .nonConforming(let site):
return String("Non-conforming site \(String(describing: site))")
case .noCertificate:
return "No certificate found"
case .keyLoad:
return "failed to get key from keychain"
case .keySave:
return "failed to store key material in keychain"
case .unmanagedGetCredentials:
return "Cannot get dn credentials for unmanaged site"
case .dnCredentialLoad:
return "failed to find dn credentials in keychain"
case .dnCredentialSave:
return "failed to store dn credentials in keychain"
case .unexpected(_):
return "An unexpected error occurred."
}
public var description: String {
switch self {
case .nonConforming(let site):
return String("Non-conforming site \(String(describing: site))")
case .noCertificate:
return "No certificate found"
case .keyLoad:
return "failed to get key from keychain"
case .keySave:
return "failed to store key material in keychain"
case .unmanagedGetCredentials:
return "Cannot get dn credentials for unmanaged site"
case .dnCredentialLoad:
return "failed to find dn credentials in keychain"
case .dnCredentialSave:
return "failed to store dn credentials in keychain"
case .unexpected(_):
return "An unexpected error occurred."
}
}
}
enum IPCResponseType: String, Codable {
case error = "error"
case success = "success"
case error = "error"
case success = "success"
}
class IPCResponse: Codable {
var type: IPCResponseType
//TODO: change message to data?
var message: JSON?
var type: IPCResponseType
//TODO: change message to data?
var message: JSON?
init(type: IPCResponseType, message: JSON?) {
self.type = type
self.message = message
}
init(type: IPCResponseType, message: JSON?) {
self.type = type
self.message = message
}
}
class IPCRequest: Codable {
var command: String
var arguments: JSON?
var command: String
var arguments: JSON?
init(command: String, arguments: JSON?) {
self.command = command
self.arguments = arguments
}
init(command: String, arguments: JSON?) {
self.command = command
self.arguments = arguments
}
init(command: String) {
self.command = command
}
init(command: String) {
self.command = command
}
}
struct CertificateInfo: Codable {
var cert: Certificate
var rawCert: String
var validity: CertificateValidity
var cert: Certificate
var rawCert: String
var validity: CertificateValidity
enum CodingKeys: String, CodingKey {
case cert = "Cert"
case rawCert = "RawCert"
case validity = "Validity"
}
enum CodingKeys: String, CodingKey {
case cert = "Cert"
case rawCert = "RawCert"
case validity = "Validity"
}
}
struct Certificate: Codable {
var fingerprint: String
var signature: String
var details: CertificateDetails
var fingerprint: String
var signature: String
var details: CertificateDetails
/// An empty initializer to make error reporting easier
init() {
fingerprint = ""
signature = ""
details = CertificateDetails()
}
/// An empty initializer to make error reporting easier
init() {
fingerprint = ""
signature = ""
details = CertificateDetails()
}
}
struct CertificateDetails: Codable {
var name: String
var notBefore: String
var notAfter: String
var publicKey: String
var groups: [String]
var ips: [String]
var subnets: [String]
var isCa: Bool
var issuer: String
var name: String
var notBefore: String
var notAfter: String
var publicKey: String
var groups: [String]
var ips: [String]
var subnets: [String]
var isCa: Bool
var issuer: String
/// An empty initializer to make error reporting easier
init() {
name = ""
notBefore = ""
notAfter = ""
publicKey = ""
groups = []
ips = ["ERROR"]
subnets = []
isCa = false
issuer = ""
}
/// An empty initializer to make error reporting easier
init() {
name = ""
notBefore = ""
notAfter = ""
publicKey = ""
groups = []
ips = ["ERROR"]
subnets = []
isCa = false
issuer = ""
}
}
struct CertificateValidity: Codable {
var valid: Bool
var reason: String
var valid: Bool
var reason: String
enum CodingKeys: String, CodingKey {
case valid = "Valid"
case reason = "Reason"
}
enum CodingKeys: String, CodingKey {
case valid = "Valid"
case reason = "Reason"
}
}
let statusMap: Dictionary<NEVPNStatus, Bool> = [
NEVPNStatus.invalid: false,
NEVPNStatus.disconnected: false,
NEVPNStatus.connecting: false,
NEVPNStatus.connected: true,
NEVPNStatus.reasserting: true,
NEVPNStatus.disconnecting: true,
let statusMap: [NEVPNStatus: Bool] = [
NEVPNStatus.invalid: false,
NEVPNStatus.disconnected: false,
NEVPNStatus.connecting: false,
NEVPNStatus.connected: true,
NEVPNStatus.reasserting: true,
NEVPNStatus.disconnecting: true,
]
let statusString: Dictionary<NEVPNStatus, String> = [
NEVPNStatus.invalid: "Invalid configuration",
NEVPNStatus.disconnected: "Disconnected",
NEVPNStatus.connecting: "Connecting...",
NEVPNStatus.connected: "Connected",
NEVPNStatus.reasserting: "Reasserting...",
NEVPNStatus.disconnecting: "Disconnecting...",
let statusString: [NEVPNStatus: String] = [
NEVPNStatus.invalid: "Invalid configuration",
NEVPNStatus.disconnected: "Disconnected",
NEVPNStatus.connecting: "Connecting...",
NEVPNStatus.connected: "Connected",
NEVPNStatus.reasserting: "Reasserting...",
NEVPNStatus.disconnecting: "Disconnecting...",
]
// Represents a site that was pulled out of the system configuration
class Site: Codable {
// Stored in manager
var name: String
var id: String
// Stored in manager
var name: String
var id: String
// Stored in proto
var staticHostmap: Dictionary<String, StaticHosts>
var unsafeRoutes: [UnsafeRoute]
var cert: CertificateInfo?
var ca: [CertificateInfo]
var lhDuration: Int
var port: Int
var mtu: Int
var cipher: String
var sortKey: Int
var logVerbosity: String
var connected: Bool? //TODO: active is a better name
var status: String?
var logFile: String?
var managed: Bool
// The following fields are present if managed = true
var lastManagedUpdate: String?
var rawConfig: String?
// Stored in proto
var staticHostmap: [String: StaticHosts]
var unsafeRoutes: [UnsafeRoute]
var cert: CertificateInfo?
var ca: [CertificateInfo]
var lhDuration: Int
var port: Int
var mtu: Int
var cipher: String
var sortKey: Int
var logVerbosity: String
var connected: Bool? //TODO: active is a better name
var status: 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
/// 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
var errors: [String]
// A list of error encountered when trying to rehydrate a site from config
var errors: [String]
var manager: NETunnelProviderManager?
var manager: NETunnelProviderManager?
var incomingSite: IncomingSite?
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 {
//TODO: Throw an error and have Sites delete the site, notify the user instead of using !
let proto = manager.protocolConfiguration as! NETunnelProviderProtocol
try self.init(proto: proto)
self.manager = manager
self.connected = statusMap[manager.connection.status]
self.status = statusString[manager.connection.status]
/// Creates a new site from a vpn manager instance. Mainly used by the UI. A manager is required to be able to edit the system profile
convenience init(manager: NETunnelProviderManager) throws {
//TODO: Throw an error and have Sites delete the site, notify the user instead of using !
let proto = manager.protocolConfiguration as! NETunnelProviderProtocol
try self.init(proto: proto)
self.manager = manager
self.connected = statusMap[manager.connection.status]
self.status = statusString[manager.connection.status]
}
convenience init(proto: NETunnelProviderProtocol) throws {
let dict = proto.providerConfiguration
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
}
convenience init(proto: NETunnelProviderProtocol) throws {
let dict = proto.providerConfiguration
let id = dict?["id"] as? String ?? nil
if id == nil {
throw SiteError.nonConforming(site: dict)
}
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
try self.init(path: SiteList.getSiteConfigFile(id: id!, createDir: false))
}
/// Creates a new site from a path on the filesystem. Mainly ussed by the VPN process or when in simulator where we lack a NEVPNManager
convenience init(path: URL) throws {
let config = try Data(contentsOf: path)
let decoder = JSONDecoder()
let incoming = try decoder.decode(IncomingSite.self, from: config)
self.init(incoming: incoming)
}
init(incoming: IncomingSite) {
var err: NSError?
incomingSite = incoming
errors = []
name = incoming.name
id = incoming.id
staticHostmap = incoming.staticHostmap
unsafeRoutes = incoming.unsafeRoutes ?? []
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 {
let rawCert = incoming.cert
let rawDetails = MobileNebulaParseCerts(rawCert, &err)
if err != nil {
throw err!
}
var certs: [CertificateInfo]
certs = try JSONDecoder().decode([CertificateInfo].self, from: rawDetails.data(using: .utf8)!)
if certs.count == 0 {
throw SiteError.noCertificate
}
cert = certs[0]
if !cert!.validity.valid {
errors.append("Certificate is invalid: \(cert!.validity.reason)")
}
} catch {
errors.append("Error while loading certificate: \(error.localizedDescription)")
}
do {
let rawCa = incoming.ca
let rawCaDetails = MobileNebulaParseCerts(rawCa, &err)
if err != nil {
throw err!
}
ca = try JSONDecoder().decode([CertificateInfo].self, from: rawCaDetails.data(using: .utf8)!)
var hasErrors = false
ca.forEach { cert in
if !cert.validity.valid {
hasErrors = true
}
}
let id = dict?["id"] as? String ?? nil
if id == nil {
throw SiteError.nonConforming(site: dict)
}
if hasErrors && !managed {
errors.append("There are issues with 1 or more ca certificates")
}
try self.init(path: SiteList.getSiteConfigFile(id: id!, createDir: false))
} catch {
ca = []
errors.append("Error while loading certificate authorities: \(error.localizedDescription)")
}
/// Creates a new site from a path on the filesystem. Mainly ussed by the VPN process or when in simulator where we lack a NEVPNManager
convenience init(path: URL) throws {
let config = try Data(contentsOf: path)
let decoder = JSONDecoder()
let incoming = try decoder.decode(IncomingSite.self, from: config)
self.init(incoming: incoming)
do {
logFile = try SiteList.getSiteLogFile(id: self.id, createDir: true).path
} catch {
logFile = nil
errors.append("Unable to create the site directory: \(error.localizedDescription)")
}
init(incoming: IncomingSite) {
if managed && (try? getDNCredentials())?.invalid != false {
errors.append("Unable to fetch managed updates - please re-enroll the device")
}
if errors.isEmpty {
do {
let encoder = JSONEncoder()
let rawConfig = try encoder.encode(incoming)
let key = try getKey()
let strConfig = String(data: rawConfig, encoding: .utf8)
var err: NSError?
incomingSite = incoming
errors = []
name = incoming.name
id = incoming.id
staticHostmap = incoming.staticHostmap
unsafeRoutes = incoming.unsafeRoutes ?? []
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 {
let rawCert = incoming.cert
let rawDetails = MobileNebulaParseCerts(rawCert, &err)
if (err != nil) {
throw err!
}
var certs: [CertificateInfo]
certs = try JSONDecoder().decode([CertificateInfo].self, from: rawDetails.data(using: .utf8)!)
if (certs.count == 0) {
throw SiteError.noCertificate
}
cert = certs[0]
if (!cert!.validity.valid) {
errors.append("Certificate is invalid: \(cert!.validity.reason)")
}
} catch {
errors.append("Error while loading certificate: \(error.localizedDescription)")
MobileNebulaTestConfig(strConfig, key, &err)
if err != nil {
throw err!
}
} catch {
errors.append("Config test error: \(error.localizedDescription)")
}
}
}
do {
let rawCa = incoming.ca
let rawCaDetails = MobileNebulaParseCerts(rawCa, &err)
if (err != nil) {
throw err!
}
ca = try JSONDecoder().decode([CertificateInfo].self, from: rawCaDetails.data(using: .utf8)!)
var hasErrors = false
ca.forEach { cert in
if (!cert.validity.valid) {
hasErrors = true
}
}
if (hasErrors && !managed) {
errors.append("There are issues with 1 or more ca certificates")
}
} catch {
ca = []
errors.append("Error while loading certificate authorities: \(error.localizedDescription)")
}
do {
logFile = try SiteList.getSiteLogFile(id: self.id, createDir: true).path
} catch {
logFile = nil
errors.append("Unable to create the site directory: \(error.localizedDescription)")
}
if (managed && (try? getDNCredentials())?.invalid != false) {
errors.append("Unable to fetch managed updates - please re-enroll the device")
}
if (errors.isEmpty) {
do {
let encoder = JSONEncoder()
let rawConfig = try encoder.encode(incoming)
let key = try getKey()
let strConfig = String(data: rawConfig, encoding: .utf8)
var err: NSError?
MobileNebulaTestConfig(strConfig, key, &err)
if (err != nil) {
throw err!
}
} catch {
errors.append("Config test error: \(error.localizedDescription)")
}
}
// Gets the private key from the keystore, we don't always need it in memory
func getKey() throws -> String {
guard let keyData = KeyChain.load(key: "\(id).key") else {
throw SiteError.keyLoad
}
// Gets the private key from the keystore, we don't always need it in memory
func getKey() throws -> String {
guard let keyData = KeyChain.load(key: "\(id).key") else {
throw SiteError.keyLoad
}
//TODO: make sure this is valid on return!
return String(decoding: keyData, as: UTF8.self)
}
//TODO: make sure this is valid on return!
return String(decoding: keyData, as: UTF8.self)
func getDNCredentials() throws -> DNCredentials {
if !managed {
throw SiteError.unmanagedGetCredentials
}
func getDNCredentials() throws -> DNCredentials {
if (!managed) {
throw SiteError.unmanagedGetCredentials
}
let rawDNCredentials = KeyChain.load(key: "\(id).dnCredentials")
if rawDNCredentials == nil {
throw SiteError.dnCredentialLoad
}
let decoder = JSONDecoder()
return try decoder.decode(DNCredentials.self, from: rawDNCredentials!)
let rawDNCredentials = KeyChain.load(key: "\(id).dnCredentials")
if rawDNCredentials == nil {
throw SiteError.dnCredentialLoad
}
func invalidateDNCredentials() throws {
let creds = try getDNCredentials()
creds.invalid = true
let decoder = JSONDecoder()
return try decoder.decode(DNCredentials.self, from: rawDNCredentials!)
}
if (!(try creds.save(siteID: self.id))) {
throw SiteError.dnCredentialLoad
}
func invalidateDNCredentials() throws {
let creds = try getDNCredentials()
creds.invalid = true
if !(try creds.save(siteID: self.id)) {
throw SiteError.dnCredentialLoad
}
}
func validateDNCredentials() throws {
let creds = try getDNCredentials()
creds.invalid = false
func validateDNCredentials() throws {
let creds = try getDNCredentials()
creds.invalid = false
if (!(try creds.save(siteID: self.id))) {
throw SiteError.dnCredentialSave
}
if !(try creds.save(siteID: self.id)) {
throw SiteError.dnCredentialSave
}
}
func getConfig() throws -> Data {
return try self.incomingSite!.getConfig()
}
func getConfig() throws -> Data {
return try self.incomingSite!.getConfig()
}
// Limits what we export to the UI
private enum CodingKeys: String, CodingKey {
case name
case id
case staticHostmap
case cert
case ca
case lhDuration
case port
case cipher
case sortKey
case connected
case status
case logFile
case unsafeRoutes
case logVerbosity
case errors
case mtu
case managed
case lastManagedUpdate
case rawConfig
}
// Limits what we export to the UI
private enum CodingKeys: String, CodingKey {
case name
case id
case staticHostmap
case cert
case ca
case lhDuration
case port
case cipher
case sortKey
case connected
case status
case logFile
case unsafeRoutes
case logVerbosity
case errors
case mtu
case managed
case lastManagedUpdate
case rawConfig
}
}
class StaticHosts: Codable {
var lighthouse: Bool
var destinations: [String]
var lighthouse: Bool
var destinations: [String]
}
class UnsafeRoute: Codable {
var route: String
var via: String
var mtu: Int?
var route: String
var via: String
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 }
}
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?
private var _invalid: Bool?
func save(siteID: String) throws -> Bool {
let encoder = JSONEncoder()
let rawDNCredentials = try encoder.encode(self)
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)
}
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"
}
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
struct IncomingSite: Codable {
var name: String
var id: String
var staticHostmap: Dictionary<String, StaticHosts>
var unsafeRoutes: [UnsafeRoute]?
var cert: String?
var ca: String?
var lhDuration: Int
var port: Int
var mtu: Int?
var cipher: String
var sortKey: Int?
var logVerbosity: String?
var key: String?
var managed: Bool?
// The following fields are present if managed = true
var dnCredentials: DNCredentials?
var lastManagedUpdate: String?
var rawConfig: String?
var name: String
var id: String
var staticHostmap: [String: StaticHosts]
var unsafeRoutes: [UnsafeRoute]?
var cert: String?
var ca: String?
var lhDuration: Int
var port: Int
var mtu: Int?
var cipher: String
var sortKey: Int?
var logVerbosity: String?
var key: String?
var managed: Bool?
// The following fields are present if managed = true
var dnCredentials: DNCredentials?
var lastManagedUpdate: String?
var rawConfig: String?
func getConfig() throws -> Data {
let encoder = JSONEncoder()
var config = self
func getConfig() throws -> Data {
let encoder = JSONEncoder()
var config = self
config.key = nil
config.dnCredentials = nil
config.key = nil
config.dnCredentials = nil
return try encoder.encode(config)
return try encoder.encode(config)
}
func save(
manager: NETunnelProviderManager?, saveToManager: Bool = true,
callback: @escaping ((any Error)?) -> Void
) {
let configPath: URL
do {
configPath = try SiteList.getSiteConfigFile(id: self.id, createDir: true)
} catch {
callback(error)
return
}
func save(manager: NETunnelProviderManager?, saveToManager: Bool = true, callback: @escaping (Error?) -> ()) {
let configPath: URL
do {
configPath = try SiteList.getSiteConfigFile(id: self.id, createDir: true)
} catch {
callback(error)
return
log.notice("Saving to \(configPath, privacy: .public)")
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(SiteError.keySave)
}
}
log.notice("Saving to \(configPath, privacy: .public)")
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(SiteError.keySave)
}
}
do {
if ((try self.dnCredentials?.save(siteID: self.id)) == false) {
return callback(SiteError.dnCredentialSave)
}
} catch {
return callback(error)
}
try self.getConfig().write(to: configPath)
} catch {
return callback(error)
do {
if (try self.dnCredentials?.save(siteID: self.id)) == false {
return callback(SiteError.dnCredentialSave)
}
} catch {
return callback(error)
}
try self.getConfig().write(to: configPath)
#if targetEnvironment(simulator)
// We are on a simulator and there is no NEVPNManager for us to interact with
} catch {
return callback(error)
}
#if targetEnvironment(simulator)
// We are on a simulator and there is no NEVPNManager for us to interact with
callback(nil)
#else
if saveToManager {
self.saveToManager(manager: manager, callback: callback)
} else {
callback(nil)
#else
if saveToManager {
self.saveToManager(manager: manager, callback: callback)
} else {
callback(nil)
}
#endif
}
}
#endif
}
private func saveToManager(manager: NETunnelProviderManager?, callback: @escaping (Error?) -> ()) {
if (manager != nil) {
// We need to refresh our settings to properly update config
manager?.loadFromPreferences { error in
if (error != nil) {
return callback(error)
}
return self.finishSaveToManager(manager: manager!, callback: callback)
}
return
private func saveToManager(
manager: NETunnelProviderManager?, callback: @escaping ((any Error)?) -> Void
) {
if manager != nil {
// We need to refresh our settings to properly update config
manager?.loadFromPreferences { error in
if error != nil {
return callback(error)
}
return finishSaveToManager(manager: NETunnelProviderManager(), callback: callback)
return self.finishSaveToManager(manager: manager!, callback: callback)
}
return
}
private func finishSaveToManager(manager: NETunnelProviderManager, callback: @escaping (Error?) -> ()) {
// Stuff our details in the protocol
let proto = manager.protocolConfiguration as? NETunnelProviderProtocol ?? NETunnelProviderProtocol()
proto.providerBundleIdentifier = "net.defined.mobileNebula.NebulaNetworkExtension";
// WARN: If we stop setting providerConfiguration["id"] here, we'll need to use something else to match
// managers in PacketTunnelProvider.findManager
proto.providerConfiguration = ["id": self.id]
proto.serverAddress = "Nebula"
return finishSaveToManager(manager: NETunnelProviderManager(), callback: callback)
}
// Finish up the manager, this is what stores everything at the system level
manager.protocolConfiguration = proto
//TODO: cert name? manager.protocolConfiguration?.username
private func finishSaveToManager(
manager: NETunnelProviderManager, callback: @escaping ((any Error)?) -> Void
) {
// Stuff our details in the protocol
let proto =
manager.protocolConfiguration as? NETunnelProviderProtocol ?? NETunnelProviderProtocol()
proto.providerBundleIdentifier = "net.defined.mobileNebula.NebulaNetworkExtension"
// WARN: If we stop setting providerConfiguration["id"] here, we'll need to use something else to match
// managers in PacketTunnelProvider.findManager
proto.providerConfiguration = ["id": self.id]
proto.serverAddress = "Nebula"
//TODO: This is what is shown on the vpn page. We should add more identifying details in
manager.localizedDescription = self.name
manager.isEnabled = true
// Finish up the manager, this is what stores everything at the system level
manager.protocolConfiguration = proto
//TODO: cert name? manager.protocolConfiguration?.username
manager.saveToPreferences{ error in
return callback(error)
}
//TODO: This is what is shown on the vpn page. We should add more identifying details in
manager.localizedDescription = self.name
manager.isEnabled = true
manager.saveToPreferences { error in
return callback(error)
}
}
}

View file

@ -1,140 +1,148 @@
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)
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]?, (any Error)?) -> Void) {
#if targetEnvironment(simulator)
SiteList.loadAllFromFS { sites, err in
if sites != nil {
self.sites = sites!
}
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)
completion(sites, err)
}
#else
SiteList.loadAllFromNETPM { sites, err in
if sites != nil {
self.sites = sites!
}
return sitesDir
completion(sites, err)
}
#endif
}
private static func loadAllFromFS(completion: @escaping ([String: Site]?, (any Error)?) -> Void) {
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
}
/// 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
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)")
}
}
/// 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]()
completion(sites, nil)
}
private static func loadAllFromNETPM(
completion: @escaping ([String: Site]?, (any Error)?) -> Void
) {
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 {
siteDirs = try fileManager.contentsOfDirectory(at: getSitesDir(), includingPropertiesForKeys: nil)
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 {
completion(nil, error)
return
//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
}
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)")
}
}
}
dispatchGroup.notify(queue: .main) {
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
}
}
func getSites() -> [String: Site] {
return sites
}
}

View file

@ -56,17 +56,17 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/url_launcher_ios/ios"
SPEC CHECKSUMS:
file_picker: c79185e70b9b45728cde2a8d8da454e0cb43f287
file_picker: 8272ff2f2365937598e2407f4f2ff55c723f084a
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
mobile_scanner: 77265f3dc8d580810e91849d4a0811a90467ed5e
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
Sentry: 38ed8bf38eab5812787274bf591e528074c19e02
sentry_flutter: 7d1f1df30f3768c411603ed449519bbb90a7d87b
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
sentry_flutter: a72ca0eb6e78335db7c4ddcddd1b9f6c8ed5b764
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
SwiftyJSON: f5b1bf1cd8dd53cd25887ac0eabcfd92301c6a5a
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
PODFILE CHECKSUM: b44d9de9944d89118a4ff4bfffe1c2dab91de156
COCOAPODS: 1.15.2
COCOAPODS: 1.16.2

View file

@ -563,6 +563,18 @@
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES;
SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES;
SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES;
SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES;
SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES;
SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES;
SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES;
SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES;
SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES;
SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES;
SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
@ -772,6 +784,18 @@
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES;
SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES;
SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES;
SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES;
SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES;
SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES;
SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES;
SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES;
SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES;
SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES;
SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
@ -825,6 +849,18 @@
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES;
SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES;
SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES;
SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES;
SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES;
SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES;
SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES;
SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES;
SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES;
SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES;
SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};

View file

@ -1,54 +1,57 @@
import MobileNebula
enum APIClientError: Error {
case invalidCredentials
case invalidCredentials
}
class APIClient {
let apiClient: MobileNebulaAPIClient
let json = JSONDecoder()
init() {
let packageInfo = PackageInfo()
apiClient = MobileNebulaNewAPIClient("MobileNebula/\(packageInfo.getVersion()) (iOS \(packageInfo.getSystemVersion()))")!
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
}
func enroll(code: String) throws -> IncomingSite {
let res = try apiClient.enroll(code)
return try decodeIncomingSite(jsonSite: res.site)
if res.fetchedUpdate {
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
}
return nil
}
private func decodeIncomingSite(jsonSite: String) throws -> IncomingSite {
do {
return try json.decode(IncomingSite.self, from: jsonSite.data(using: .utf8)!)
} catch {
print("decodeIncomingSite: \(error)")
throw error
}
}
}

View file

@ -1,297 +1,334 @@
import UIKit
import Flutter
import MobileNebula
import NetworkExtension
import SwiftyJSON
import UIKit
enum ChannelName {
static let vpn = "net.defined.mobileNebula/NebulaVpnService"
static let vpn = "net.defined.mobileNebula/NebulaVpnService"
}
func MissingArgumentError(message: String, details: Any?) -> FlutterError {
return FlutterError(code: "missing_argument", message: message, details: details)
return FlutterError(code: "missing_argument", message: message, details: details)
}
@main
@objc class AppDelegate: FlutterAppDelegate {
private let dnUpdater = DNUpdater()
private let apiClient = APIClient()
private var sites: Sites?
private var ui: FlutterMethodChannel?
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
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 {
fatalError("rootViewController is not type FlutterViewController")
}
sites = Sites(messenger: controller.binaryMessenger)
ui = FlutterMethodChannel(name: ChannelName.vpn, binaryMessenger: controller.binaryMessenger)
ui!.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
switch call.method {
case "nebula.parseCerts": return self.nebulaParseCerts(call: call, result: result)
case "nebula.generateKeyPair": return self.nebulaGenerateKeyPair(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 "deleteSite": return self.deleteSite(call: call, result: result)
case "saveSite": return self.saveSite(call: call, result: result)
case "startSite": return self.startSite(call: call, result: result)
case "stopSite": return self.stopSite(call: call, result: result)
case "active.listHostmap": self.vpnRequest(command: "listHostmap", arguments: call.arguments, result: result)
case "active.listPendingHostmap": self.vpnRequest(command: "listPendingHostmap", arguments: call.arguments, result: result)
case "active.getHostInfo": self.vpnRequest(command: "getHostInfo", 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)
default:
result(FlutterMethodNotImplemented)
}
})
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func nebulaParseCerts(call: FlutterMethodCall, result: FlutterResult) {
guard let args = call.arguments as? Dictionary<String, String> else { return result(NoArgumentsError()) }
guard let certs = args["certs"] else { return result(MissingArgumentError(message: "certs is a required argument")) }
var err: NSError?
let json = MobileNebulaParseCerts(certs, &err)
if (err != nil) {
return result(CallFailedError(message: "Error while parsing certificate(s)", details: err!.localizedDescription))
}
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) {
var err: NSError?
let kp = MobileNebulaGenerateKeyPair(&err)
if (err != nil) {
return result(CallFailedError(message: "Error while generating key pairs", details: err!.localizedDescription))
}
return result(kp)
}
func nebulaRenderConfig(call: FlutterMethodCall, result: FlutterResult) {
guard let config = call.arguments as? String else { return result(NoArgumentsError()) }
var err: NSError?
let yaml = MobileNebulaRenderConfig(config, "<hidden>", &err)
if (err != nil) {
return result(CallFailedError(message: "Error while rendering config", details: err!.localizedDescription))
}
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) {
self.sites?.loadSites { (sites, err) -> () in
if (err != nil) {
return result(CallFailedError(message: "Failed to load site list", details: err!.localizedDescription))
}
private let dnUpdater = DNUpdater()
private let apiClient = APIClient()
private var sites: Sites?
private var ui: FlutterMethodChannel?
let encoder = JSONEncoder()
let data = try! encoder.encode(sites)
let ret = String(data: data, encoding: .utf8)
result(ret)
}
}
func deleteSite(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let id = call.arguments as? String else { return result(NoArgumentsError()) }
//TODO: stop the site if its running currently
self.sites?.deleteSite(id: id) { error in
if (error != nil) {
result(CallFailedError(message: "Failed to delete site", details: error!.localizedDescription))
}
result(nil)
}
}
func saveSite(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let json = call.arguments as? String else { return result(NoArgumentsError()) }
guard let data = json.data(using: .utf8) else { return result(NoArgumentsError()) }
guard let site = try? JSONDecoder().decode(IncomingSite.self, from: data) else {
return result(NoArgumentsError())
}
let oldSite = self.sites?.getSite(id: site.id)
site.save(manager: oldSite?.manager) { error in
if (error != nil) {
return result(CallFailedError(message: "Failed to save site", details: error!.localizedDescription))
}
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
self.sites?.loadSites { _, _ in
result(nil)
}
}
}
func startSite(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? Dictionary<String, String> else { return result(NoArgumentsError()) }
guard let id = args["id"] else { return result(MissingArgumentError(message: "id is a required argument")) }
#if targetEnvironment(simulator)
let updater = self.sites?.getUpdater(id: id)
updater?.update(connected: true)
#else
let container = self.sites?.getContainer(id: id)
let manager = container?.site.manager
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)
}
manager?.loadFromPreferences{ error in
// Signal to the main screen to reload
self.ui?.invokeMethod("refreshSites", arguments: nil)
}
guard let controller = window?.rootViewController as? FlutterViewController else {
fatalError("rootViewController is not type FlutterViewController")
}
sites = Sites(messenger: controller.binaryMessenger)
ui = FlutterMethodChannel(name: ChannelName.vpn, binaryMessenger: controller.binaryMessenger)
ui!.setMethodCallHandler({ (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
switch call.method {
case "nebula.parseCerts": return self.nebulaParseCerts(call: call, result: result)
case "nebula.generateKeyPair": return self.nebulaGenerateKeyPair(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 "deleteSite": return self.deleteSite(call: call, result: result)
case "saveSite": return self.saveSite(call: call, result: result)
case "startSite": return self.startSite(call: call, result: result)
case "stopSite": return self.stopSite(call: call, result: result)
case "active.listHostmap":
self.vpnRequest(command: "listHostmap", arguments: call.arguments, result: result)
case "active.listPendingHostmap":
self.vpnRequest(command: "listPendingHostmap", arguments: call.arguments, result: result)
case "active.getHostInfo":
self.vpnRequest(command: "getHostInfo", 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)
default:
result(FlutterMethodNotImplemented)
}
})
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func nebulaParseCerts(call: FlutterMethodCall, result: FlutterResult) {
guard let args = call.arguments as? [String: String] else { return result(NoArgumentsError()) }
guard let certs = args["certs"] else {
return result(MissingArgumentError(message: "certs is a required argument"))
}
var err: NSError?
let json = MobileNebulaParseCerts(certs, &err)
if err != nil {
return result(
CallFailedError(
message: "Error while parsing certificate(s)", details: err!.localizedDescription))
}
return result(json)
}
func nebulaVerifyCertAndKey(call: FlutterMethodCall, result: FlutterResult) {
guard let args = call.arguments as? [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) {
var err: NSError?
let kp = MobileNebulaGenerateKeyPair(&err)
if err != nil {
return result(
CallFailedError(
message: "Error while generating key pairs", details: err!.localizedDescription))
}
return result(kp)
}
func nebulaRenderConfig(call: FlutterMethodCall, result: FlutterResult) {
guard let config = call.arguments as? String else { return result(NoArgumentsError()) }
var err: NSError?
let yaml = MobileNebulaRenderConfig(config, "<hidden>", &err)
if err != nil {
return result(
CallFailedError(message: "Error while rendering config", details: err!.localizedDescription)
)
}
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) {
self.sites?.loadSites { (sites, err) -> Void in
if err != nil {
return result(
CallFailedError(message: "Failed to load site list", details: err!.localizedDescription))
}
let encoder = JSONEncoder()
let data = try! encoder.encode(sites)
let ret = String(data: data, encoding: .utf8)
result(ret)
}
}
func deleteSite(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let id = call.arguments as? String else { return result(NoArgumentsError()) }
//TODO: stop the site if its running currently
self.sites?.deleteSite(id: id) { error in
if error != nil {
result(
CallFailedError(message: "Failed to delete site", details: error!.localizedDescription))
}
result(nil)
}
}
func saveSite(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let json = call.arguments as? String else { return result(NoArgumentsError()) }
guard let data = json.data(using: .utf8) else { return result(NoArgumentsError()) }
guard let site = try? JSONDecoder().decode(IncomingSite.self, from: data) else {
return result(NoArgumentsError())
}
let oldSite = self.sites?.getSite(id: site.id)
site.save(manager: oldSite?.manager) { error in
if error != nil {
return result(
CallFailedError(message: "Failed to save site", details: error!.localizedDescription))
}
self.sites?.loadSites { _, _ in
result(nil)
}
}
}
func startSite(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: String] else { return result(NoArgumentsError()) }
guard let id = args["id"] else {
return result(MissingArgumentError(message: "id is a required argument"))
}
#if targetEnvironment(simulator)
let updater = self.sites?.getUpdater(id: id)
updater?.update(connected: true)
#else
let container = self.sites?.getContainer(id: id)
let manager = container?.site.manager
manager?.loadFromPreferences { error in
//TODO: Handle load error
// This is silly but we need to enable the site each time to avoid situations where folks have multiple sites
manager?.isEnabled = true
manager?.saveToPreferences { error in
//TODO: Handle load error
manager?.loadFromPreferences { error in
//TODO: Handle load error
// This is silly but we need to enable the site each time to avoid situations where folks have multiple sites
manager?.isEnabled = true
manager?.saveToPreferences{ error in
//TODO: Handle load error
manager?.loadFromPreferences{ error in
//TODO: Handle load error
do {
container?.updater.startFunc = {() -> Void in
return self.vpnRequest(command: "start", arguments: args, result: result)
}
try manager?.connection.startVPNTunnel(options: ["expectStart": NSNumber(1)])
} catch {
return result(CallFailedError(message: "Could not start site", details: error.localizedDescription))
}
}
}
}
#endif
}
func stopSite(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? Dictionary<String, String> else { return result(NoArgumentsError()) }
guard let id = args["id"] else { return result(MissingArgumentError(message: "id is a required argument")) }
#if targetEnvironment(simulator)
let updater = self.sites?.getUpdater(id: id)
updater?.update(connected: false)
#else
let manager = self.sites?.getSite(id: id)?.manager
manager?.loadFromPreferences{ error in
//TODO: Handle load error
manager?.connection.stopVPNTunnel()
return result(nil)
}
#endif
}
func vpnRequest(command: String, arguments: Any?, result: @escaping FlutterResult) {
guard let args = arguments as? Dictionary<String, Any> else { return result(NoArgumentsError()) }
guard let id = args["id"] as? String else { return result(MissingArgumentError(message: "id is a required argument")) }
let container = sites?.getContainer(id: id)
if container == nil {
// No site for this id
return result(nil)
}
if !(container!.site.connected ?? false) {
// Site isn't connected, no point in sending a command
return result(nil)
}
if let session = container!.site.manager?.connection as? NETunnelProviderSession {
do {
try session.sendProviderMessage(try JSONEncoder().encode(IPCRequest(command: command, arguments: JSON(args)))) { data in
if data == nil {
return result(nil)
}
//print(String(decoding: data!, as: UTF8.self))
guard let res = try? JSONDecoder().decode(IPCResponse.self, from: data!) else {
return result(CallFailedError(message: "Failed to decode response"))
}
if res.type == .success {
return result(res.message?.object)
}
return result(CallFailedError(message: res.message?.debugDescription ?? "Failed to convert error"))
}
container?.updater.startFunc = { () -> Void in
return self.vpnRequest(command: "start", arguments: args, result: result)
}
try manager?.connection.startVPNTunnel(options: ["expectStart": NSNumber(1)])
} catch {
return result(CallFailedError(message: error.localizedDescription))
return result(
CallFailedError(
message: "Could not start site", details: error.localizedDescription))
}
} else {
//TODO: we have a site without a manager, things have gone weird. How to handle since this shouldn't happen?
result(nil)
}
}
}
#endif
}
func stopSite(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: String] else { return result(NoArgumentsError()) }
guard let id = args["id"] else {
return result(MissingArgumentError(message: "id is a required argument"))
}
#if targetEnvironment(simulator)
let updater = self.sites?.getUpdater(id: id)
updater?.update(connected: false)
#else
let manager = self.sites?.getSite(id: id)?.manager
manager?.loadFromPreferences { error in
//TODO: Handle load error
manager?.connection.stopVPNTunnel()
return result(nil)
}
#endif
}
func vpnRequest(command: String, arguments: Any?, result: @escaping FlutterResult) {
guard let args = arguments as? [String: Any] else { return result(NoArgumentsError()) }
guard let id = args["id"] as? String else {
return result(MissingArgumentError(message: "id is a required argument"))
}
let container = sites?.getContainer(id: id)
if container == nil {
// No site for this id
return result(nil)
}
if !(container!.site.connected ?? false) {
// Site isn't connected, no point in sending a command
return result(nil)
}
if let session = container!.site.manager?.connection as? NETunnelProviderSession {
do {
try session.sendProviderMessage(
try JSONEncoder().encode(IPCRequest(command: command, arguments: JSON(args)))
) { data in
if data == nil {
return result(nil)
}
//print(String(decoding: data!, as: UTF8.self))
guard let res = try? JSONDecoder().decode(IPCResponse.self, from: data!) else {
return result(CallFailedError(message: "Failed to decode response"))
}
if res.type == .success {
return result(res.message?.object)
}
return result(
CallFailedError(message: res.message?.debugDescription ?? "Failed to convert error"))
}
} catch {
return result(CallFailedError(message: error.localizedDescription))
}
} else {
//TODO: we have a site without a manager, things have gone weird. How to handle since this shouldn't happen?
result(nil)
}
}
}
func MissingArgumentError(message: String, details: Error? = nil) -> FlutterError {
return FlutterError(code: "missingArgument", message: message, details: details)
func MissingArgumentError(message: String, details: (any Error)? = nil) -> FlutterError {
return FlutterError(code: "missingArgument", message: message, details: details)
}
func NoArgumentsError(message: String? = "no arguments were provided or could not be deserialized", details: Error? = nil) -> FlutterError {
return FlutterError(code: "noArguments", message: message, details: details)
func NoArgumentsError(
message: String? = "no arguments were provided or could not be deserialized",
details: (any Error)? = nil
) -> FlutterError {
return FlutterError(code: "noArguments", message: message, details: details)
}
func CallFailedError(message: String, details: String? = "") -> FlutterError {
return FlutterError(code: "callFailed", message: message, details: details)
return FlutterError(code: "callFailed", message: message, details: details)
}

View file

@ -2,138 +2,146 @@ import Foundation
import os.log
class DNUpdater {
private let apiClient = APIClient()
private let timer = RepeatingTimer(timeInterval: 15 * 60) // 15 * 60 is 15 minutes
private let log = Logger(subsystem: "net.defined.mobileNebula", category: "DNUpdater")
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
}
private let apiClient = APIClient()
private let timer = RepeatingTimer(timeInterval: 15 * 60) // 15 * 60 is 15 minutes
private let log = Logger(subsystem: "net.defined.mobileNebula", category: "DNUpdater")
self.updateSite(site: site, onUpdate: onUpdate)
}
}
func updateAll(onUpdate: @escaping (Site) -> Void) {
_ = SiteList { (sites, _) -> Void 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)
}
func updateAllLoop(onUpdate: @escaping (Site) -> Void) {
timer.eventHandler = {
self.updateAll(onUpdate: onUpdate)
}
timer.resume()
}
func updateSingleLoop(site: Site, onUpdate: @escaping (Site) -> Void) {
timer.eventHandler = {
self.updateSite(site: site, onUpdate: onUpdate)
}
timer.resume()
}
func updateSite(site: Site, onUpdate: @escaping (Site) -> Void) {
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()
log.notice("Invalidated credentials in site: \(site.name, privacy: .public)")
}
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()
log.notice("Invalidated credentials in site: \(site.name, privacy: .public)")
}
return
}
newSite?.save(manager: site.manager) { error in
if (error != nil) {
self.log.error("failed to save update: \(error!.localizedDescription, privacy: .public)")
}
// reload nebula even if we couldn't save the vpn profile
onUpdate(Site(incoming: newSite!))
}
if (credentials.invalid) {
try site.validateDNCredentials()
log.notice("Revalidated credentials in site \(site.name, privacy: .public)")
}
} catch {
log.error("Error while updating \(site.name, privacy: .public): \(error.localizedDescription, privacy: .public)")
return
}
let siteManager = site.manager
let shouldSaveToManager =
siteManager != nil
|| ProcessInfo().isOperatingSystemAtLeast(
OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0))
newSite?.save(manager: site.manager, saveToManager: shouldSaveToManager) { error in
if error != nil {
self.log.error("failed to save update: \(error!.localizedDescription, privacy: .public)")
}
// reload nebula even if we couldn't save the vpn profile
onUpdate(Site(incoming: newSite!))
}
if credentials.invalid {
try site.validateDNCredentials()
log.notice("Revalidated credentials in site \(site.name, privacy: .public)")
}
} catch {
log.error(
"Error while updating \(site.name, privacy: .public): \(error.localizedDescription, privacy: .public)"
)
}
}
}
// From https://medium.com/over-engineering/a-background-repeating-timer-in-swift-412cecfd2ef9
class RepeatingTimer {
let timeInterval: TimeInterval
let timeInterval: TimeInterval
init(timeInterval: TimeInterval) {
self.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
}()
private lazy var timer: any 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)?
var eventHandler: (() -> Void)?
private enum State {
case suspended
case resumed
}
private enum State {
case suspended
case resumed
}
private var state: State = .suspended
private var state: State = .suspended
deinit {
timer.setEventHandler {}
timer.cancel()
/*
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
}
resume()
eventHandler = nil
}
func resume() {
if state == .resumed {
return
}
state = .resumed
timer.resume()
func resume() {
if state == .resumed {
return
}
state = .resumed
timer.resume()
}
func suspend() {
if state == .suspended {
return
}
state = .suspended
timer.suspend()
func suspend() {
if state == .suspended {
return
}
state = .suspended
timer.suspend()
}
}

View file

@ -1,26 +1,24 @@
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 getVersion() -> String {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String
if buildNumber == nil {
return version
}
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)"
}
return "\(version)-\(buildNumber!)"
}
func getName() -> String {
return Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? Bundle.main
.infoDictionary?["CFBundleName"] as? String ?? "Nebula"
}
func getSystemVersion() -> String {
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
return "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
}
}

View file

@ -1,185 +1,192 @@
import NetworkExtension
import MobileNebula
import NetworkExtension
class SiteContainer {
var site: Site
var updater: SiteUpdater
init(site: Site, updater: SiteUpdater) {
self.site = site
self.updater = updater
}
var site: Site
var updater: SiteUpdater
init(site: Site, updater: SiteUpdater) {
self.site = site
self.updater = updater
}
}
class Sites {
private var containers = [String: SiteContainer]()
private var messenger: FlutterBinaryMessenger?
init(messenger: FlutterBinaryMessenger?) {
self.messenger = messenger
private var containers = [String: SiteContainer]()
private var messenger: (any FlutterBinaryMessenger)?
init(messenger: (any FlutterBinaryMessenger)?) {
self.messenger = messenger
}
func loadSites(completion: @escaping ([String: Site]?, (any Error)?) -> Void) {
_ = SiteList { (sites, err) in
if err != nil {
return completion(nil, err)
}
sites?.values.forEach { site in
var updater = self.containers[site.id]?.updater
if updater != nil {
updater!.setSite(site: site)
} else {
updater = SiteUpdater(messenger: self.messenger!, site: site)
}
self.containers[site.id] = SiteContainer(site: site, updater: updater!)
}
let justSites = self.containers.mapValues {
return $0.site
}
completion(justSites, nil)
}
}
func deleteSite(id: String, callback: @escaping ((any Error)?) -> Void) {
if let site = self.containers.removeValue(forKey: id) {
_ = KeyChain.delete(key: "\(site.site.id).dnCredentials")
_ = KeyChain.delete(key: "\(site.site.id).key")
do {
let fileManager = FileManager.default
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)
return
#endif
}
func loadSites(completion: @escaping ([String: Site]?, Error?) -> ()) {
_ = SiteList { (sites, err) in
if (err != nil) {
return completion(nil, err)
}
sites?.values.forEach{ site in
var updater = self.containers[site.id]?.updater
if (updater != nil) {
updater!.setSite(site: site)
} else {
updater = SiteUpdater(messenger: self.messenger!, site: site)
}
self.containers[site.id] = SiteContainer(site: site, updater: updater!)
}
let justSites = self.containers.mapValues {
return $0.site
}
completion(justSites, nil)
}
}
func deleteSite(id: String, callback: @escaping (Error?) -> ()) {
if let site = self.containers.removeValue(forKey: id) {
_ = KeyChain.delete(key: "\(site.site.id).dnCredentials")
_ = KeyChain.delete(key: "\(site.site.id).key")
do {
let fileManager = FileManager.default
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)
return
#endif
}
// Nothing to remove
callback(nil)
}
func getSite(id: String) -> Site? {
return self.containers[id]?.site
}
func getUpdater(id: String) -> SiteUpdater? {
return self.containers[id]?.updater
}
func getContainer(id: String) -> SiteContainer? {
return self.containers[id]
}
// Nothing to remove
callback(nil)
}
func getSite(id: String) -> Site? {
return self.containers[id]?.site
}
func getUpdater(id: String) -> SiteUpdater? {
return self.containers[id]?.updater
}
func getContainer(id: String) -> SiteContainer? {
return self.containers[id]
}
}
class SiteUpdater: NSObject, FlutterStreamHandler {
private var eventSink: FlutterEventSink?;
private var eventChannel: FlutterEventChannel;
private var site: Site
private var notification: Any?
public var startFunc: (() -> Void)?
private var configFd: Int32? = nil
private var configObserver: DispatchSourceFileSystemObject? = nil
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)
self.site = site
super.init()
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
private var eventSink: FlutterEventSink?
private var eventChannel: FlutterEventChannel
private var site: Site
private var notification: Any?
public var startFunc: (() -> Void)?
private var configFd: Int32? = nil
private var configObserver: (any DispatchSourceFileSystemObject)? = nil
init(messenger: any 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
}
/// onListen is called when flutter code attaches an event listener
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
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
let oldConnected = self.site.connected
self.site.status = statusString[self.site.manager!.connection.status]
self.site.connected = statusMap[self.site.manager!.connection.status]
// Check to see if we just moved to connected and if we have a start function to call when that happens
if self.site.connected! && oldConnected != self.site.connected && self.startFunc != nil {
self.startFunc!()
self.startFunc = nil
}
self.update(connected: self.site.connected!)
}
#endif
return nil
eventChannel = FlutterEventChannel(
name: "net.defined.nebula/\(site.id)", binaryMessenger: messenger)
self.site = site
super.init()
eventChannel.setStreamHandler(self)
self.configObserver?.setEventHandler(handler: self.configUpdated)
self.configObserver?.setCancelHandler {
if self.configFd != nil {
close(self.configFd!)
}
self.configObserver = nil
}
/// onCancel is called when the flutter listener stops listening
func onCancel(withArguments arguments: Any?) -> FlutterError? {
if (self.notification != nil) {
NotificationCenter.default.removeObserver(self.notification!)
self.configObserver?.resume()
}
func setSite(site: Site) {
self.site = site
}
/// onListen is called when flutter code attaches an event listener
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
-> FlutterError?
{
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
let oldConnected = self.site.connected
self.site.status = statusString[self.site.manager!.connection.status]
self.site.connected = statusMap[self.site.manager!.connection.status]
// Check to see if we just moved to connected and if we have a start function to call when that happens
if self.site.connected! && oldConnected != self.site.connected && self.startFunc != nil {
self.startFunc!()
self.startFunc = nil
}
return nil
self.update(connected: self.site.connected!)
}
#endif
return nil
}
/// onCancel is called when the flutter listener stops listening
func onCancel(withArguments arguments: Any?) -> FlutterError? {
if self.notification != nil {
NotificationCenter.default.removeObserver(self.notification!)
}
/// update is a way to send information to the flutter listener and generally should not be used directly
func update(connected: Bool, replaceSite: Site? = nil) {
if (replaceSite != nil) {
site = replaceSite!
}
site.connected = connected
site.status = connected ? "Connected" : "Disconnected"
let encoder = JSONEncoder()
let data = try! encoder.encode(site)
self.eventSink?(String(data: data, encoding: .utf8))
return nil
}
/// update is a way to send information to the flutter listener and generally should not be used directly
func update(connected: Bool, replaceSite: Site? = nil) {
if replaceSite != nil {
site = replaceSite!
}
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)
site.connected = connected
site.status = connected ? "Connected" : "Disconnected"
let encoder = JSONEncoder()
let data = try! encoder.encode(site)
self.eventSink?(String(data: data, encoding: .utf8))
}
private func configUpdated() {
if self.site.connected != true {
return
}
guard let newSite = try? Site(manager: self.site.manager!) else {
return
}
self.update(connected: newSite.connected ?? false, replaceSite: newSite)
}
}

View file

@ -18,6 +18,11 @@ default_platform(:ios)
platform :ios do
desc "Push a new beta build to TestFlight"
before_all do
xcode_select("/Applications/Xcode_16.2.0.app")
end
lane :build do
# Do some things like setting up a temporary keystore to host secrets in CI
setup_ci

View file

@ -2,13 +2,14 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:mobile_nebula/components/SpecialTextField.dart';
import 'package:mobile_nebula/models/CIDR.dart';
import '../services/utils.dart';
import 'IPField.dart';
//TODO: Support initialValue
class CIDRField extends StatefulWidget {
const CIDRField({
Key? key,
super.key,
this.ipHelp = "ip address",
this.autoFocus = false,
this.focusNode,
@ -17,7 +18,7 @@ class CIDRField extends StatefulWidget {
this.textInputAction,
this.ipController,
this.bitsController,
}) : super(key: key);
});
final String ipHelp;
final bool autoFocus;
@ -52,31 +53,33 @@ class _CIDRFieldState extends State<CIDRField> {
Widget build(BuildContext context) {
var textStyle = CupertinoTheme.of(context).textTheme.textStyle;
return Container(
child: Row(children: <Widget>[
Expanded(
return Row(
children: <Widget>[
Expanded(
child: Padding(
padding: EdgeInsets.fromLTRB(6, 6, 2, 6),
child: IPField(
help: widget.ipHelp,
ipOnly: true,
textPadding: EdgeInsets.all(0),
textInputAction: TextInputAction.next,
textAlign: TextAlign.end,
focusNode: widget.focusNode,
nextFocusNode: bitsFocus,
onChanged: (val) {
if (widget.onChanged == null) {
return;
}
padding: EdgeInsets.fromLTRB(6, 6, 2, 6),
child: IPField(
help: widget.ipHelp,
ipOnly: true,
textPadding: EdgeInsets.all(0),
textInputAction: TextInputAction.next,
textAlign: TextAlign.end,
focusNode: widget.focusNode,
nextFocusNode: bitsFocus,
onChanged: (val) {
if (widget.onChanged == null) {
return;
}
cidr.ip = val;
widget.onChanged!(cidr);
},
controller: widget.ipController,
))),
Text("/"),
Container(
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(
@ -96,8 +99,10 @@ class _CIDRFieldState extends State<CIDRField> {
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
textInputAction: widget.textInputAction ?? TextInputAction.done,
placeholder: 'bits',
))
]));
),
),
],
);
}
@override

View file

@ -6,63 +6,66 @@ import 'package:mobile_nebula/validators/ipValidator.dart';
class CIDRFormField extends FormField<CIDR> {
//TODO: onSaved, validator, auto-validate, enabled?
CIDRFormField({
Key? key,
super.key,
autoFocus = false,
enableIPV6 = false,
focusNode,
nextFocusNode,
ValueChanged<CIDR>? onChanged,
FormFieldSetter<CIDR>? onSaved,
super.onSaved,
textInputAction,
CIDR? initialValue,
super.initialValue,
this.ipController,
this.bitsController,
}) : super(
key: key,
initialValue: initialValue,
onSaved: onSaved,
validator: (cidr) {
if (cidr == null) {
return "Please fill out this field";
}
validator: (cidr) {
if (cidr == null) {
return "Please fill out this field";
}
if (!ipValidator(cidr.ip, enableIPV6)) {
return 'Please enter a valid ip address';
}
if (!ipValidator(cidr.ip, enableIPV6)) {
return 'Please enter a valid ip address';
}
if (cidr.bits > 32 || cidr.bits < 0) {
return "Please enter a valid number of bits";
}
if (cidr.bits > 32 || cidr.bits < 0) {
return "Please enter a valid number of bits";
}
return null;
},
builder: (FormFieldState<CIDR> field) {
final _CIDRFormField state = field as _CIDRFormField;
return null;
},
builder: (FormFieldState<CIDR> field) {
final _CIDRFormField state = field as _CIDRFormField;
void onChangedHandler(CIDR value) {
if (onChanged != null) {
onChanged(value);
}
field.didChange(value);
}
void onChangedHandler(CIDR value) {
if (onChanged != null) {
onChanged(value);
}
field.didChange(value);
}
return Column(crossAxisAlignment: CrossAxisAlignment.end, children: <Widget>[
CIDRField(
autoFocus: autoFocus,
focusNode: focusNode,
nextFocusNode: nextFocusNode,
onChanged: onChangedHandler,
textInputAction: textInputAction,
ipController: state._effectiveIPController,
bitsController: state._effectiveBitsController,
),
field.hasError
? Text(field.errorText ?? "Unknown error",
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13),
textAlign: TextAlign.end)
: Container(height: 0)
]);
});
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
CIDRField(
autoFocus: autoFocus,
focusNode: focusNode,
nextFocusNode: nextFocusNode,
onChanged: onChangedHandler,
textInputAction: textInputAction,
ipController: state._effectiveIPController,
bitsController: state._effectiveBitsController,
),
field.hasError
? Text(
field.errorText ?? "Unknown error",
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13),
textAlign: TextAlign.end,
)
: Container(height: 0),
],
);
},
);
final TextEditingController? ipController;
final TextEditingController? bitsController;

View file

@ -0,0 +1,36 @@
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
class DangerButton extends StatelessWidget {
const DangerButton({super.key, required this.child, this.onPressed});
final Widget child;
final GestureTapCallback? onPressed;
@override
Widget build(BuildContext context) {
if (Platform.isAndroid) {
return FilledButton(
onPressed: onPressed,
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
child: child,
);
} else {
// Workaround for https://github.com/flutter/flutter/issues/161590
final themeData = CupertinoTheme.of(context);
return CupertinoTheme(
data: themeData.copyWith(primaryColor: CupertinoColors.white),
child: CupertinoButton(
onPressed: onPressed,
color: CupertinoColors.systemRed.resolveFrom(context),
child: child,
),
);
}
}
}

View file

@ -5,15 +5,15 @@ import 'package:mobile_nebula/services/utils.dart';
/// SimplePage with a form and built in validation and confirmation to discard changes if any are made
class FormPage extends StatefulWidget {
const FormPage(
{Key? key,
required this.title,
required this.child,
required this.onSave,
required this.changed,
this.hideSave = false,
this.scrollController})
: super(key: key);
const FormPage({
super.key,
required this.title,
required this.child,
required this.onSave,
required this.changed,
this.hideSave = false,
this.scrollController,
});
final String title;
final Function onSave;
@ -39,42 +39,61 @@ class _FormPageState extends State<FormPage> {
changed = widget.changed || changed;
return PopScope<Object?>(
canPop: !changed,
onPopInvokedWithResult: (bool didPop, Object? result) async {
if (didPop) {
return;
}
final NavigatorState navigator = Navigator.of(context);
canPop: !changed,
onPopInvokedWithResult: (bool didPop, Object? result) async {
if (didPop) {
return;
}
final NavigatorState navigator = Navigator.of(context);
Utils.confirmDelete(context, 'Discard changes?', () {
Utils.confirmDelete(
context,
'Discard changes?',
() {
navigator.pop();
}, deleteLabel: 'Yes', cancelLabel: 'No');
},
child: SimplePage(
leadingAction: _buildLeader(context),
trailingActions: _buildTrailer(context),
scrollController: widget.scrollController,
title: Text(widget.title),
child: Form(
key: _formKey,
onChanged: () => setState(() {
changed = true;
}),
child: widget.child),
));
},
deleteLabel: 'Yes',
cancelLabel: 'No',
);
},
child: SimplePage(
leadingAction: _buildLeader(context),
trailingActions: _buildTrailer(context),
scrollController: widget.scrollController,
title: Text(widget.title),
child: Form(
key: _formKey,
onChanged:
() => setState(() {
changed = true;
}),
child: widget.child,
),
),
);
}
Widget _buildLeader(BuildContext context) {
return Utils.leadingBackWidget(context, label: changed ? 'Cancel' : 'Back', onPressed: () {
if (changed) {
Utils.confirmDelete(context, 'Discard changes?', () {
changed = false;
return Utils.leadingBackWidget(
context,
label: changed ? 'Cancel' : 'Back',
onPressed: () {
if (changed) {
Utils.confirmDelete(
context,
'Discard changes?',
() {
changed = false;
Navigator.pop(context);
},
deleteLabel: 'Yes',
cancelLabel: 'No',
);
} else {
Navigator.pop(context);
}, deleteLabel: 'Yes', cancelLabel: 'No');
} else {
Navigator.pop(context);
}
});
}
},
);
}
List<Widget> _buildTrailer(BuildContext context) {
@ -83,21 +102,18 @@ class _FormPageState extends State<FormPage> {
}
return [
Utils.trailingSaveWidget(
context,
() {
if (_formKey.currentState == null) {
return;
}
Utils.trailingSaveWidget(context, () {
if (_formKey.currentState == null) {
return;
}
if (!_formKey.currentState!.validate()) {
return;
}
if (!_formKey.currentState!.validate()) {
return;
}
_formKey.currentState!.save();
widget.onSave();
},
)
_formKey.currentState!.save();
widget.onSave();
}),
];
}
}

View file

@ -2,13 +2,14 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:mobile_nebula/components/SpecialTextField.dart';
import 'package:mobile_nebula/models/IPAndPort.dart';
import '../services/utils.dart';
import 'IPField.dart';
//TODO: Support initialValue
class IPAndPortField extends StatefulWidget {
const IPAndPortField({
Key? key,
super.key,
this.ipOnly = false,
this.ipHelp = "ip address",
this.autoFocus = false,
@ -20,7 +21,7 @@ class IPAndPortField extends StatefulWidget {
this.ipTextAlign,
this.ipController,
this.portController,
}) : super(key: key);
});
final String ipHelp;
final bool ipOnly;
@ -58,27 +59,29 @@ class _IPAndPortFieldState extends State<IPAndPortField> {
Widget build(BuildContext context) {
var textStyle = CupertinoTheme.of(context).textTheme.textStyle;
return Container(
child: Row(children: <Widget>[
Expanded(
return Row(
children: <Widget>[
Expanded(
child: Padding(
padding: EdgeInsets.fromLTRB(6, 6, 2, 6),
child: IPField(
help: widget.ipHelp,
ipOnly: widget.ipOnly,
nextFocusNode: _portFocus,
textPadding: EdgeInsets.all(0),
textInputAction: TextInputAction.next,
focusNode: widget.focusNode,
onChanged: (val) {
_ipAndPort.ip = val;
widget.onChanged(_ipAndPort);
},
textAlign: widget.ipTextAlign,
controller: widget.ipController,
))),
Text(":"),
Container(
padding: EdgeInsets.fromLTRB(6, 6, 2, 6),
child: IPField(
help: widget.ipHelp,
ipOnly: widget.ipOnly,
nextFocusNode: _portFocus,
textPadding: EdgeInsets.all(0),
textInputAction: TextInputAction.next,
focusNode: widget.focusNode,
onChanged: (val) {
_ipAndPort.ip = val;
widget.onChanged(_ipAndPort);
},
textAlign: widget.ipTextAlign,
controller: widget.ipController,
),
),
),
Text(":"),
Container(
width: Utils.textSize("00000", textStyle).width + 12,
padding: EdgeInsets.fromLTRB(2, 6, 6, 6),
child: SpecialTextField(
@ -94,8 +97,10 @@ class _IPAndPortFieldState extends State<IPAndPortField> {
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
textInputAction: TextInputAction.done,
placeholder: 'port',
))
]));
),
),
],
);
}
@override

View file

@ -8,7 +8,7 @@ import 'IPAndPortField.dart';
class IPAndPortFormField extends FormField<IPAndPort> {
//TODO: onSaved, validator, auto-validate, enabled?
IPAndPortFormField({
Key? key,
super.key,
ipOnly = false,
enableIPV6 = false,
ipHelp = "ip address",
@ -16,62 +16,64 @@ class IPAndPortFormField extends FormField<IPAndPort> {
focusNode,
nextFocusNode,
ValueChanged<IPAndPort>? onChanged,
FormFieldSetter<IPAndPort>? onSaved,
super.onSaved,
textInputAction,
IPAndPort? initialValue,
super.initialValue,
noBorder,
ipTextAlign = TextAlign.center,
this.ipController,
this.portController,
}) : super(
key: key,
initialValue: initialValue,
onSaved: onSaved,
validator: (ipAndPort) {
if (ipAndPort == null) {
return "Please fill out this field";
}
validator: (ipAndPort) {
if (ipAndPort == null) {
return "Please fill out this field";
}
if (!ipValidator(ipAndPort.ip, enableIPV6) && (!ipOnly && !dnsValidator(ipAndPort.ip))) {
return ipOnly ? 'Please enter a valid ip address' : 'Please enter a valid ip address or dns name';
}
if (!ipValidator(ipAndPort.ip, enableIPV6) && (!ipOnly && !dnsValidator(ipAndPort.ip))) {
return ipOnly ? 'Please enter a valid ip address' : 'Please enter a valid ip address or dns name';
}
if (ipAndPort.port == null || ipAndPort.port! > 65535 || ipAndPort.port! < 0) {
return "Please enter a valid port";
}
if (ipAndPort.port == null || ipAndPort.port! > 65535 || ipAndPort.port! < 0) {
return "Please enter a valid port";
}
return null;
},
builder: (FormFieldState<IPAndPort> field) {
final _IPAndPortFormField state = field as _IPAndPortFormField;
return null;
},
builder: (FormFieldState<IPAndPort> field) {
final _IPAndPortFormField state = field as _IPAndPortFormField;
void onChangedHandler(IPAndPort value) {
if (onChanged != null) {
onChanged(value);
}
field.didChange(value);
}
void onChangedHandler(IPAndPort value) {
if (onChanged != null) {
onChanged(value);
}
field.didChange(value);
}
return Column(children: <Widget>[
IPAndPortField(
ipOnly: ipOnly,
ipHelp: ipHelp,
autoFocus: autoFocus,
focusNode: focusNode,
nextFocusNode: nextFocusNode,
onChanged: onChangedHandler,
textInputAction: textInputAction,
ipController: state._effectiveIPController,
portController: state._effectivePortController,
noBorder: noBorder,
ipTextAlign: ipTextAlign,
),
field.hasError
? Text(field.errorText!,
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13))
: Container(height: 0)
]);
});
return Column(
children: <Widget>[
IPAndPortField(
ipOnly: ipOnly,
ipHelp: ipHelp,
autoFocus: autoFocus,
focusNode: focusNode,
nextFocusNode: nextFocusNode,
onChanged: onChangedHandler,
textInputAction: textInputAction,
ipController: state._effectiveIPController,
portController: state._effectivePortController,
noBorder: noBorder,
ipTextAlign: ipTextAlign,
),
field.hasError
? Text(
field.errorText!,
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13),
)
: Container(height: 0),
],
);
},
);
final TextEditingController? ipController;
final TextEditingController? portController;
@ -109,8 +111,7 @@ class _IPAndPortFormField extends FormFieldState<IPAndPort> {
@override
void didUpdateWidget(IPAndPortFormField oldWidget) {
super.didUpdateWidget(oldWidget);
var update =
IPAndPort(ip: widget.ipController?.text, port: int.tryParse(widget.portController?.text ?? "") ?? null);
var update = IPAndPort(ip: widget.ipController?.text, port: int.tryParse(widget.portController?.text ?? ""));
bool shouldUpdate = false;
if (widget.ipController != oldWidget.ipController) {

View file

@ -16,19 +16,19 @@ class IPField extends StatelessWidget {
final controller;
final textAlign;
const IPField(
{Key? key,
this.ipOnly = false,
this.help = "ip address",
this.autoFocus = false,
this.focusNode,
this.nextFocusNode,
this.onChanged,
this.textPadding = const EdgeInsets.all(6.0),
this.textInputAction,
this.controller,
this.textAlign = TextAlign.center})
: super(key: key);
const IPField({
super.key,
this.ipOnly = false,
this.help = "ip address",
this.autoFocus = false,
this.focusNode,
this.nextFocusNode,
this.onChanged,
this.textPadding = const EdgeInsets.all(6.0),
this.textInputAction,
this.controller,
this.textAlign = TextAlign.center,
});
@override
Widget build(BuildContext context) {
@ -36,21 +36,22 @@ class IPField extends StatelessWidget {
final double? ipWidth = ipOnly ? Utils.textSize("000000000000000", textStyle).width + 12 : null;
return SizedBox(
width: ipWidth,
child: SpecialTextField(
keyboardType: ipOnly ? TextInputType.numberWithOptions(decimal: true, signed: true) : null,
textAlign: textAlign,
autofocus: autoFocus,
focusNode: focusNode,
nextFocusNode: nextFocusNode,
controller: controller,
onChanged: onChanged,
maxLength: ipOnly ? 15 : null,
maxLengthEnforcement: ipOnly ? MaxLengthEnforcement.enforced : MaxLengthEnforcement.none,
inputFormatters: ipOnly ? [IPTextInputFormatter()] : [FilteringTextInputFormatter.allow(RegExp(r'[^\s]+'))],
textInputAction: this.textInputAction,
placeholder: help,
));
width: ipWidth,
child: SpecialTextField(
keyboardType: ipOnly ? TextInputType.numberWithOptions(decimal: true, signed: true) : null,
textAlign: textAlign,
autofocus: autoFocus,
focusNode: focusNode,
nextFocusNode: nextFocusNode,
controller: controller,
onChanged: onChanged,
maxLength: ipOnly ? 15 : null,
maxLengthEnforcement: ipOnly ? MaxLengthEnforcement.enforced : MaxLengthEnforcement.none,
inputFormatters: ipOnly ? [IPTextInputFormatter()] : [FilteringTextInputFormatter.allow(RegExp(r'[^\s]+'))],
textInputAction: textInputAction,
placeholder: help,
),
);
}
}
@ -59,22 +60,19 @@ class IPTextInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
return _selectionAwareTextManipulation(
newValue,
(String substring) {
return whitelistedPattern
.allMatches(substring)
.map<String>((Match match) => match.group(0)!)
.join()
.replaceAll(RegExp(r','), '.');
},
);
return _selectionAwareTextManipulation(newValue, (String substring) {
return whitelistedPattern
.allMatches(substring)
.map<String>((Match match) => match.group(0)!)
.join()
.replaceAll(RegExp(r','), '.');
});
}
}
TextEditingValue _selectionAwareTextManipulation(
TextEditingValue value,
String substringManipulation(String substring),
String Function(String substring) substringManipulation,
) {
final int selectionStartIndex = value.selection.start;
final int selectionEndIndex = value.selection.end;

View file

@ -9,7 +9,7 @@ import 'IPField.dart';
class IPFormField extends FormField<String> {
//TODO: validator, auto-validate, enabled?
IPFormField({
Key? key,
super.key,
ipOnly = false,
enableIPV6 = false,
help = "ip address",
@ -17,7 +17,7 @@ class IPFormField extends FormField<String> {
focusNode,
nextFocusNode,
ValueChanged<String>? onChanged,
FormFieldSetter<String>? onSaved,
super.onSaved,
textPadding = const EdgeInsets.all(6.0),
textInputAction,
initialValue,
@ -25,52 +25,55 @@ class IPFormField extends FormField<String> {
crossAxisAlignment = CrossAxisAlignment.center,
textAlign = TextAlign.center,
}) : super(
key: key,
initialValue: initialValue,
onSaved: onSaved,
validator: (ip) {
if (ip == null || ip == "") {
return "Please fill out this field";
}
initialValue: initialValue,
validator: (ip) {
if (ip == null || ip == "") {
return "Please fill out this field";
}
if (!ipValidator(ip, enableIPV6) || (!ipOnly && !dnsValidator(ip))) {
print(ip);
return ipOnly ? 'Please enter a valid ip address' : 'Please enter a valid ip address or dns name';
}
if (!ipValidator(ip, enableIPV6) || (!ipOnly && !dnsValidator(ip))) {
print(ip);
return ipOnly ? 'Please enter a valid ip address' : 'Please enter a valid ip address or dns name';
}
return null;
},
builder: (FormFieldState<String> field) {
final _IPFormField state = field as _IPFormField;
return null;
},
builder: (FormFieldState<String> field) {
final _IPFormField state = field as _IPFormField;
void onChangedHandler(String value) {
if (onChanged != null) {
onChanged(value);
}
field.didChange(value);
}
void onChangedHandler(String value) {
if (onChanged != null) {
onChanged(value);
}
field.didChange(value);
}
return Column(crossAxisAlignment: crossAxisAlignment, children: <Widget>[
IPField(
ipOnly: ipOnly,
help: help,
autoFocus: autoFocus,
focusNode: focusNode,
nextFocusNode: nextFocusNode,
onChanged: onChangedHandler,
textPadding: textPadding,
textInputAction: textInputAction,
controller: state._effectiveController,
textAlign: textAlign),
field.hasError
? Text(
field.errorText!,
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13),
textAlign: textAlign,
)
: Container(height: 0)
]);
});
return Column(
crossAxisAlignment: crossAxisAlignment,
children: <Widget>[
IPField(
ipOnly: ipOnly,
help: help,
autoFocus: autoFocus,
focusNode: focusNode,
nextFocusNode: nextFocusNode,
onChanged: onChangedHandler,
textPadding: textPadding,
textInputAction: textInputAction,
controller: state._effectiveController,
textAlign: textAlign,
),
field.hasError
? Text(
field.errorText!,
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13),
textAlign: textAlign,
)
: Container(height: 0),
],
);
},
);
final TextEditingController? controller;
@ -103,8 +106,9 @@ class _IPFormField extends FormFieldState<String> {
oldWidget.controller?.removeListener(_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);
}
if (widget.controller != null) {
setValue(widget.controller!.text);
if (oldWidget.controller == null) _controller = null;

View file

@ -5,81 +5,84 @@ import 'package:mobile_nebula/components/SpecialTextField.dart';
class PlatformTextFormField extends FormField<String> {
//TODO: auto-validate, enabled?
PlatformTextFormField(
{Key? key,
widgetKey,
this.controller,
focusNode,
nextFocusNode,
TextInputType? keyboardType,
textInputAction,
List<TextInputFormatter>? inputFormatters,
textAlign,
autofocus,
maxLines = 1,
maxLength,
maxLengthEnforcement,
onChanged,
keyboardAppearance,
minLines,
expands,
suffix,
textAlignVertical,
String? initialValue,
String? placeholder,
FormFieldValidator<String>? validator,
ValueChanged<String?>? onSaved})
: super(
key: key,
initialValue: controller != null ? controller.text : (initialValue ?? ''),
onSaved: onSaved,
validator: (str) {
if (validator != null) {
return validator(str);
}
PlatformTextFormField({
super.key,
widgetKey,
this.controller,
focusNode,
nextFocusNode,
TextInputType? keyboardType,
textInputAction,
List<TextInputFormatter>? inputFormatters,
textAlign,
autofocus,
maxLines = 1,
maxLength,
maxLengthEnforcement,
onChanged,
keyboardAppearance,
minLines,
expands,
suffix,
textAlignVertical,
String? initialValue,
String? placeholder,
FormFieldValidator<String>? validator,
super.onSaved,
}) : super(
initialValue: controller != null ? controller.text : (initialValue ?? ''),
validator: (str) {
if (validator != null) {
return validator(str);
}
return null;
},
builder: (FormFieldState<String> field) {
final _PlatformTextFormFieldState state = field as _PlatformTextFormFieldState;
return null;
},
builder: (FormFieldState<String> field) {
final _PlatformTextFormFieldState state = field as _PlatformTextFormFieldState;
void onChangedHandler(String value) {
if (onChanged != null) {
onChanged(value);
}
field.didChange(value);
}
void onChangedHandler(String value) {
if (onChanged != null) {
onChanged(value);
}
field.didChange(value);
}
return Column(crossAxisAlignment: CrossAxisAlignment.end, children: <Widget>[
SpecialTextField(
key: widgetKey,
controller: state._effectiveController,
focusNode: focusNode,
nextFocusNode: nextFocusNode,
keyboardType: keyboardType,
textInputAction: textInputAction,
textAlign: textAlign,
autofocus: autofocus,
maxLines: maxLines,
maxLength: maxLength,
maxLengthEnforcement: maxLengthEnforcement,
onChanged: onChangedHandler,
keyboardAppearance: keyboardAppearance,
minLines: minLines,
expands: expands,
textAlignVertical: textAlignVertical,
placeholder: placeholder,
inputFormatters: inputFormatters,
suffix: suffix),
field.hasError
? Text(
field.errorText!,
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13),
textAlign: textAlign,
)
: Container(height: 0)
]);
});
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
SpecialTextField(
key: widgetKey,
controller: state._effectiveController,
focusNode: focusNode,
nextFocusNode: nextFocusNode,
keyboardType: keyboardType,
textInputAction: textInputAction,
textAlign: textAlign,
autofocus: autofocus,
maxLines: maxLines,
maxLength: maxLength,
maxLengthEnforcement: maxLengthEnforcement,
onChanged: onChangedHandler,
keyboardAppearance: keyboardAppearance,
minLines: minLines,
expands: expands,
textAlignVertical: textAlignVertical,
placeholder: placeholder,
inputFormatters: inputFormatters,
suffix: suffix,
),
field.hasError
? Text(
field.errorText!,
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13),
textAlign: textAlign,
)
: Container(height: 0),
],
);
},
);
final TextEditingController? controller;
@ -112,8 +115,9 @@ class _PlatformTextFormFieldState extends FormFieldState<String> {
oldWidget.controller?.removeListener(_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);
}
if (widget.controller != null) {
setValue(widget.controller!.text);
if (oldWidget.controller == null) _controller = null;

View file

@ -1,32 +1,25 @@
import 'package:flutter/cupertino.dart' as cupertino;
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:mobile_nebula/services/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
enum SimpleScrollable {
none,
vertical,
horizontal,
both,
}
enum SimpleScrollable { none, vertical, horizontal, both }
class SimplePage extends StatelessWidget {
const SimplePage(
{Key? key,
required this.title,
required this.child,
this.leadingAction,
this.trailingActions = const [],
this.scrollable = SimpleScrollable.vertical,
this.scrollbar = true,
this.scrollController,
this.bottomBar,
this.onRefresh,
this.onLoading,
this.alignment,
this.refreshController})
: super(key: key);
const SimplePage({
super.key,
required this.title,
required this.child,
this.leadingAction,
this.trailingActions = const [],
this.scrollable = SimpleScrollable.vertical,
this.scrollbar = true,
this.scrollController,
this.bottomBar,
this.onRefresh,
this.onLoading,
this.alignment,
this.refreshController,
});
final Widget title;
final Widget child;
@ -50,13 +43,14 @@ class SimplePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
Widget realChild = child;
var addScrollbar = this.scrollbar;
var addScrollbar = scrollbar;
if (scrollable == SimpleScrollable.vertical || scrollable == SimpleScrollable.both) {
realChild = SingleChildScrollView(
scrollDirection: Axis.vertical,
child: realChild,
controller: refreshController == null ? scrollController : null);
scrollDirection: Axis.vertical,
controller: refreshController == null ? scrollController : null,
child: realChild,
);
addScrollbar = true;
}
@ -67,19 +61,20 @@ class SimplePage extends StatelessWidget {
if (refreshController != null) {
realChild = RefreshConfiguration(
headerTriggerDistance: 100,
footerTriggerDistance: -100,
maxUnderScrollExtent: 100,
child: SmartRefresher(
scrollController: scrollController,
onRefresh: onRefresh,
onLoading: onLoading,
controller: refreshController!,
child: realChild,
enablePullUp: onLoading != null,
enablePullDown: onRefresh != null,
footer: ClassicFooter(loadStyle: LoadStyle.ShowWhenLoading),
));
headerTriggerDistance: 100,
footerTriggerDistance: -100,
maxUnderScrollExtent: 100,
child: SmartRefresher(
scrollController: scrollController,
onRefresh: onRefresh,
onLoading: onLoading,
controller: refreshController!,
enablePullUp: onLoading != null,
enablePullDown: onRefresh != null,
footer: ClassicFooter(loadStyle: LoadStyle.ShowWhenLoading),
child: realChild,
),
);
addScrollbar = true;
}
@ -88,26 +83,28 @@ class SimplePage extends StatelessWidget {
}
if (alignment != null) {
realChild = Align(alignment: this.alignment!, child: realChild);
realChild = Align(alignment: alignment!, child: realChild);
}
if (bottomBar != null) {
realChild = Column(children: [
Expanded(child: realChild),
bottomBar!,
]);
realChild = Column(children: [Expanded(child: realChild), bottomBar!]);
}
return PlatformScaffold(
backgroundColor: cupertino.CupertinoColors.systemGroupedBackground.resolveFrom(context),
appBar: PlatformAppBar(
title: title,
leading: leadingAction != null ? leadingAction : Utils.leadingBackWidget(context),
trailingActions: trailingActions,
cupertino: (_, __) => CupertinoNavigationBarData(
transitionBetweenRoutes: false,
),
),
body: SafeArea(child: realChild));
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: PlatformAppBar(
title: title,
leading: leadingAction,
trailingActions: trailingActions,
cupertino:
(_, __) => CupertinoNavigationBarData(
transitionBetweenRoutes: false,
// TODO: set title on route, show here instead of just "Back"
previousPageTitle: 'Back',
padding: EdgeInsetsDirectional.only(end: 8.0),
),
),
body: SafeArea(child: realChild),
);
}
}

View file

@ -6,24 +6,26 @@ import 'package:mobile_nebula/models/Site.dart';
import 'package:mobile_nebula/services/utils.dart';
class SiteItem extends StatelessWidget {
const SiteItem({Key? key, required this.site, this.onPressed}) : super(key: key);
const SiteItem({super.key, required this.site, this.onPressed});
final Site site;
final onPressed;
final void Function()? onPressed;
@override
Widget build(BuildContext context) {
final borderColor = site.errors.length > 0
? CupertinoColors.systemRed.resolveFrom(context)
: site.connected
final borderColor =
site.errors.isNotEmpty
? CupertinoColors.systemRed.resolveFrom(context)
: site.connected
? CupertinoColors.systemGreen.resolveFrom(context)
: CupertinoColors.systemGrey2.resolveFrom(context);
final border = BorderSide(color: borderColor, width: 10);
return Container(
margin: EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(border: Border(left: border)),
child: _buildContent(context));
margin: EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(border: Border(left: border)),
child: _buildContent(context),
);
}
Widget _buildContent(BuildContext context) {
@ -32,21 +34,25 @@ class SiteItem extends StatelessWidget {
Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg';
return SpecialButton(
decoration:
BoxDecoration(border: Border(top: border, bottom: border), color: Utils.configItemBackground(context)),
onPressed: onPressed,
child: Padding(
padding: EdgeInsets.fromLTRB(10, 10, 5, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
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))),
Padding(padding: EdgeInsets.only(right: 10)),
Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18)
],
)));
decoration: BoxDecoration(
border: Border(top: border, bottom: border),
color: Utils.configItemBackground(context),
),
onPressed: onPressed,
child: Padding(
padding: EdgeInsets.fromLTRB(10, 10, 5, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
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))),
Padding(padding: EdgeInsets.only(right: 10)),
Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18),
],
),
),
);
}
}

View file

@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import '../models/Site.dart';
class SiteTitle extends StatelessWidget {
const SiteTitle({super.key, required this.site});
final Site site;
@override
Widget build(BuildContext context) {
final dnIcon =
Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg';
return IntrinsicWidth(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
site.managed
? Padding(padding: EdgeInsets.only(right: 10), child: SvgPicture.asset(dnIcon, width: 12))
: Container(),
Expanded(child: Text(site.name, overflow: TextOverflow.ellipsis)),
],
),
),
);
}
}

View file

@ -5,8 +5,14 @@ import 'package:flutter/material.dart';
// This is a button that pushes the bare minimum onto you, it doesn't even respect button themes - unless you tell it to
class SpecialButton extends StatefulWidget {
const SpecialButton({Key? key, this.child, this.color, this.onPressed, this.useButtonTheme = false, this.decoration})
: super(key: key);
const SpecialButton({
super.key,
this.child,
this.color,
this.onPressed,
this.useButtonTheme = false,
this.decoration,
});
final Widget? child;
final Color? color;
@ -26,20 +32,19 @@ class _SpecialButtonState extends State<SpecialButton> with SingleTickerProvider
}
Widget _buildAndroid() {
var textStyle;
TextStyle? textStyle;
if (widget.useButtonTheme) {
textStyle = Theme.of(context).textTheme.labelLarge;
}
return Material(
textStyle: textStyle,
child: Ink(
decoration: widget.decoration,
color: widget.color,
child: InkWell(
child: widget.child,
onTap: widget.onPressed,
)));
textStyle: textStyle,
child: Ink(
decoration: widget.decoration,
color: widget.color,
child: InkWell(onTap: widget.onPressed, child: widget.child),
),
);
}
Widget _buildGeneric() {
@ -49,21 +54,22 @@ class _SpecialButtonState extends State<SpecialButton> with SingleTickerProvider
}
return Container(
decoration: widget.decoration,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onTapCancel: _handleTapCancel,
onTap: widget.onPressed,
child: Semantics(
button: true,
child: FadeTransition(
opacity: _opacityAnimation!,
child: DefaultTextStyle(style: textStyle, child: Container(child: widget.child, color: widget.color)),
),
decoration: widget.decoration,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onTapCancel: _handleTapCancel,
onTap: widget.onPressed,
child: Semantics(
button: true,
child: FadeTransition(
opacity: _opacityAnimation!,
child: DefaultTextStyle(style: textStyle, child: Container(color: widget.color, child: widget.child)),
),
));
),
),
);
}
// Eyeballed values. Feel free to tweak.
@ -77,11 +83,7 @@ class _SpecialButtonState extends State<SpecialButton> with SingleTickerProvider
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 200),
value: 0.0,
vsync: this,
);
_animationController = AnimationController(duration: const Duration(milliseconds: 200), value: 0.0, vsync: this);
_opacityAnimation = _animationController!.drive(CurveTween(curve: Curves.decelerate)).drive(_opacityTween);
_setTween();
}
@ -131,9 +133,10 @@ class _SpecialButtonState extends State<SpecialButton> with SingleTickerProvider
}
final bool wasHeldDown = _buttonHeldDown;
final TickerFuture ticker = _buttonHeldDown
? _animationController!.animateTo(1.0, duration: kFadeOutDuration)
: _animationController!.animateTo(0.0, duration: kFadeInDuration);
final TickerFuture ticker =
_buttonHeldDown
? _animationController!.animateTo(1.0, duration: kFadeOutDuration)
: _animationController!.animateTo(0.0, duration: kFadeInDuration);
ticker.then<void>((void value) {
if (mounted && wasHeldDown != _buttonHeldDown) {

View file

@ -4,31 +4,31 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
/// A normal TextField or CupertinoTextField that looks the same on all platforms
class SpecialTextField extends StatefulWidget {
const SpecialTextField(
{Key? key,
this.placeholder,
this.suffix,
this.controller,
this.focusNode,
this.nextFocusNode,
this.autocorrect,
this.minLines,
this.maxLines,
this.maxLength,
this.maxLengthEnforcement,
this.style,
this.keyboardType,
this.textInputAction,
this.textCapitalization,
this.textAlign,
this.autofocus,
this.onChanged,
this.enabled,
this.expands,
this.keyboardAppearance,
this.textAlignVertical,
this.inputFormatters})
: super(key: key);
const SpecialTextField({
super.key,
this.placeholder,
this.suffix,
this.controller,
this.focusNode,
this.nextFocusNode,
this.autocorrect,
this.minLines,
this.maxLines,
this.maxLength,
this.maxLengthEnforcement,
this.style,
this.keyboardType,
this.textInputAction,
this.textCapitalization,
this.textAlign,
this.autofocus,
this.onChanged,
this.enabled,
this.expands,
this.keyboardAppearance,
this.textAlignVertical,
this.inputFormatters,
});
final String? placeholder;
final TextEditingController? controller;
@ -64,7 +64,7 @@ class _SpecialTextFieldState extends State<SpecialTextField> {
@override
void initState() {
if (widget.inputFormatters == null || formatters.length == 0) {
if (widget.inputFormatters == null || formatters.isEmpty) {
formatters = [FilteringTextInputFormatter.allow(RegExp(r'[^\t]'))];
} else {
formatters = widget.inputFormatters!;
@ -76,42 +76,48 @@ class _SpecialTextFieldState extends State<SpecialTextField> {
@override
Widget build(BuildContext context) {
return PlatformTextField(
autocorrect: widget.autocorrect,
minLines: widget.minLines,
maxLines: widget.maxLines,
maxLength: widget.maxLength,
maxLengthEnforcement: widget.maxLengthEnforcement,
keyboardType: widget.keyboardType,
keyboardAppearance: widget.keyboardAppearance,
textInputAction: widget.textInputAction,
textCapitalization: widget.textCapitalization,
textAlign: widget.textAlign,
textAlignVertical: widget.textAlignVertical,
autofocus: widget.autofocus,
focusNode: widget.focusNode,
onChanged: widget.onChanged,
enabled: widget.enabled ?? true,
onSubmitted: (_) {
if (widget.nextFocusNode != null) {
FocusScope.of(context).requestFocus(widget.nextFocusNode);
}
},
expands: widget.expands,
inputFormatters: formatters,
material: (_, __) => MaterialTextFieldData(
autocorrect: widget.autocorrect,
minLines: widget.minLines,
maxLines: widget.maxLines,
maxLength: widget.maxLength,
maxLengthEnforcement: widget.maxLengthEnforcement,
keyboardType: widget.keyboardType,
keyboardAppearance: widget.keyboardAppearance,
textInputAction: widget.textInputAction,
textCapitalization: widget.textCapitalization,
textAlign: widget.textAlign,
textAlignVertical: widget.textAlignVertical,
autofocus: widget.autofocus,
focusNode: widget.focusNode,
onChanged: widget.onChanged,
enabled: widget.enabled ?? true,
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(
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);
suffix: widget.suffix,
),
style: widget.style,
controller: widget.controller,
);
}
}

View file

@ -0,0 +1,33 @@
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
class PrimaryButton extends StatelessWidget {
const PrimaryButton({super.key, required this.child, this.onPressed});
final Widget child;
final GestureTapCallback? onPressed;
@override
Widget build(BuildContext context) {
if (Platform.isAndroid) {
return FilledButton(
onPressed: onPressed,
style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.primary),
child: child,
);
} else {
// Workaround for https://github.com/flutter/flutter/issues/161590
final themeData = CupertinoTheme.of(context);
return CupertinoTheme(
data: themeData.copyWith(primaryColor: CupertinoColors.white),
child: CupertinoButton(
onPressed: onPressed,
color: CupertinoColors.secondaryLabel.resolveFrom(context),
child: child,
),
);
}
}
}

View file

@ -5,20 +5,21 @@ import 'package:mobile_nebula/services/utils.dart';
// A config item that detects tapping and calls back on a tap
class ConfigButtonItem extends StatelessWidget {
const ConfigButtonItem({Key? key, this.content, this.onPressed}) : super(key: key);
const ConfigButtonItem({super.key, this.content, this.onPressed});
final Widget? content;
final onPressed;
final void Function()? onPressed;
@override
Widget build(BuildContext context) {
return SpecialButton(
color: Utils.configItemBackground(context),
onPressed: onPressed,
useButtonTheme: true,
child: Container(
constraints: BoxConstraints(minHeight: Utils.minInteractiveSize, minWidth: double.infinity),
child: Center(child: content),
));
color: Utils.configItemBackground(context),
onPressed: onPressed,
useButtonTheme: true,
child: Container(
constraints: BoxConstraints(minHeight: Utils.minInteractiveSize, minWidth: double.infinity),
child: Center(child: content),
),
);
}
}

View file

@ -3,9 +3,14 @@ import 'package:mobile_nebula/components/SpecialButton.dart';
import 'package:mobile_nebula/services/utils.dart';
class ConfigCheckboxItem extends StatelessWidget {
const ConfigCheckboxItem(
{Key? key, this.label, this.content, this.labelWidth = 100, this.onChanged, this.checked = false})
: super(key: key);
const ConfigCheckboxItem({
super.key,
this.label,
this.content,
this.labelWidth = 100,
this.onChanged,
this.checked = false,
});
final Widget? label;
final Widget? content;
@ -16,18 +21,19 @@ class ConfigCheckboxItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
Widget item = Container(
padding: EdgeInsets.only(left: 15),
constraints: BoxConstraints(minHeight: Utils.minInteractiveSize, minWidth: double.infinity),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
label != null ? Container(width: labelWidth, child: label) : Container(),
Expanded(child: Container(child: content, padding: EdgeInsets.only(right: 10))),
checked
? Icon(CupertinoIcons.check_mark, color: CupertinoColors.systemBlue.resolveFrom(context), size: 34)
: Container()
],
));
padding: EdgeInsets.symmetric(horizontal: 15),
constraints: BoxConstraints(minHeight: Utils.minInteractiveSize, minWidth: double.infinity),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
label != null ? SizedBox(width: labelWidth, child: label) : Container(),
Expanded(child: Container(padding: EdgeInsets.only(right: 10), child: content)),
checked
? Icon(CupertinoIcons.check_mark, color: CupertinoColors.systemBlue.resolveFrom(context))
: Container(),
],
),
);
if (onChanged != null) {
return SpecialButton(

View file

@ -9,7 +9,7 @@ TextStyle basicTextStyle(BuildContext context) =>
const double _headerFontSize = 13.0;
class ConfigHeader extends StatelessWidget {
const ConfigHeader({Key? key, required this.label, this.color}) : super(key: key);
const ConfigHeader({super.key, required this.label, this.color});
final String label;
final Color? color;
@ -20,10 +20,9 @@ class ConfigHeader extends StatelessWidget {
padding: const EdgeInsets.only(left: 10.0, top: 30.0, bottom: 5.0, right: 10.0),
child: Text(
label,
style: basicTextStyle(context).copyWith(
color: color ?? CupertinoColors.secondaryLabel.resolveFrom(context),
fontSize: _headerFontSize,
),
style: basicTextStyle(
context,
).copyWith(color: color ?? CupertinoColors.secondaryLabel.resolveFrom(context), fontSize: _headerFontSize),
),
);
}

View file

@ -5,13 +5,13 @@ import 'package:flutter/material.dart';
import 'package:mobile_nebula/services/utils.dart';
class ConfigItem extends StatelessWidget {
const ConfigItem(
{Key? key,
this.label,
required this.content,
this.labelWidth = 100,
this.crossAxisAlignment = CrossAxisAlignment.center})
: super(key: key);
const ConfigItem({
super.key,
this.label,
required this.content,
this.labelWidth = 100,
this.crossAxisAlignment = CrossAxisAlignment.center,
});
final Widget? label;
final Widget content;
@ -20,7 +20,7 @@ class ConfigItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
var textStyle;
TextStyle textStyle;
if (Platform.isAndroid) {
textStyle = Theme.of(context).textTheme.labelLarge!.copyWith(fontWeight: FontWeight.normal);
} else {
@ -28,15 +28,16 @@ class ConfigItem extends StatelessWidget {
}
return Container(
color: Utils.configItemBackground(context),
padding: EdgeInsets.only(top: 2, bottom: 2, left: 15, right: 20),
constraints: BoxConstraints(minHeight: Utils.minInteractiveSize),
child: Row(
crossAxisAlignment: crossAxisAlignment,
children: <Widget>[
Container(width: labelWidth, child: DefaultTextStyle(style: textStyle, child: Container(child: label))),
Expanded(child: DefaultTextStyle(style: textStyle, child: Container(child: content))),
],
));
color: Utils.configItemBackground(context),
padding: EdgeInsets.symmetric(vertical: 6, horizontal: 15),
constraints: BoxConstraints(minHeight: Utils.minInteractiveSize),
child: Row(
crossAxisAlignment: crossAxisAlignment,
children: <Widget>[
SizedBox(width: labelWidth, child: DefaultTextStyle(style: textStyle, child: Container(child: label))),
Expanded(child: DefaultTextStyle(style: textStyle, child: Container(child: content))),
],
),
);
}
}

View file

@ -6,32 +6,34 @@ import 'package:mobile_nebula/components/SpecialButton.dart';
import 'package:mobile_nebula/services/utils.dart';
class ConfigPageItem extends StatelessWidget {
const ConfigPageItem(
{Key? key,
this.label,
this.content,
this.labelWidth = 100,
this.onPressed,
this.disabled = false,
this.crossAxisAlignment = CrossAxisAlignment.center})
: super(key: key);
const ConfigPageItem({
super.key,
this.label,
this.content,
this.labelWidth = 100,
this.onPressed,
this.disabled = false,
this.crossAxisAlignment = CrossAxisAlignment.center,
});
final Widget? label;
final Widget? content;
final double labelWidth;
final CrossAxisAlignment crossAxisAlignment;
final onPressed;
final void Function()? onPressed;
final bool disabled;
@override
Widget build(BuildContext context) {
var theme;
dynamic theme;
if (Platform.isAndroid) {
final origTheme = Theme.of(context);
theme = origTheme.copyWith(
textTheme: origTheme.textTheme
.copyWith(labelLarge: origTheme.textTheme.labelLarge!.copyWith(fontWeight: FontWeight.normal)));
textTheme: origTheme.textTheme.copyWith(
labelLarge: origTheme.textTheme.labelLarge!.copyWith(fontWeight: FontWeight.normal),
),
);
return Theme(data: theme, child: _buildContent(context));
} else {
final origTheme = CupertinoTheme.of(context);
@ -42,21 +44,22 @@ class ConfigPageItem extends StatelessWidget {
Widget _buildContent(BuildContext context) {
return SpecialButton(
onPressed: this.disabled ? null : onPressed,
onPressed: disabled ? null : onPressed,
color: Utils.configItemBackground(context),
child: Container(
padding: EdgeInsets.only(left: 15, right: 15),
constraints: BoxConstraints(minHeight: Utils.minInteractiveSize, minWidth: double.infinity),
child: Row(
crossAxisAlignment: crossAxisAlignment,
children: <Widget>[
label != null ? Container(width: labelWidth, child: label) : Container(),
Expanded(child: Container(child: content, padding: EdgeInsets.only(right: 10))),
this.disabled
? Container()
: Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18)
],
)),
padding: EdgeInsets.symmetric(vertical: 6, horizontal: 15),
constraints: BoxConstraints(minHeight: Utils.minInteractiveSize, minWidth: double.infinity),
child: Row(
crossAxisAlignment: crossAxisAlignment,
children: <Widget>[
label != null ? SizedBox(width: labelWidth, child: label) : Container(),
Expanded(child: Container(padding: EdgeInsets.only(right: 10), child: content)),
disabled
? Container()
: Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18),
],
),
),
);
}
}

View file

@ -4,8 +4,7 @@ import 'package:mobile_nebula/services/utils.dart';
import 'ConfigHeader.dart';
class ConfigSection extends StatelessWidget {
const ConfigSection({Key? key, this.label, required this.children, this.borderColor, this.labelColor})
: super(key: key);
const ConfigSection({super.key, this.label, required this.children, this.borderColor, this.labelColor});
final List<Widget> children;
final String? label;
@ -16,30 +15,38 @@ class ConfigSection extends StatelessWidget {
Widget build(BuildContext context) {
final border = BorderSide(color: borderColor ?? Utils.configSectionBorder(context));
List<Widget> _children = [];
List<Widget> mappedChildren = [];
final len = children.length;
for (var i = 0; i < len; i++) {
_children.add(children[i]);
mappedChildren.add(children[i]);
if (i < len - 1) {
double pad = 15;
if (children[i + 1].runtimeType.toString() == 'ConfigButtonItem') {
pad = 0;
}
_children.add(Padding(
child: Divider(height: 1, color: Utils.configSectionBorder(context)), padding: EdgeInsets.only(left: pad)));
mappedChildren.add(
Padding(
padding: EdgeInsets.only(left: pad),
child: Divider(height: 1, color: Utils.configSectionBorder(context)),
),
);
}
}
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
label != null ? ConfigHeader(label: label!, color: labelColor) : Container(height: 20),
Container(
decoration:
BoxDecoration(border: Border(top: border, bottom: border), color: Utils.configItemBackground(context)),
child: Column(
children: _children,
))
]);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
label != null ? ConfigHeader(label: label!, color: labelColor) : Container(height: 20),
Container(
decoration: BoxDecoration(
border: Border(top: border, bottom: border),
color: Utils.configItemBackground(context),
),
child: Column(children: mappedChildren),
),
],
);
}
}

View file

@ -1,13 +1,12 @@
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:mobile_nebula/components/SpecialTextField.dart';
class ConfigTextItem extends StatelessWidget {
const ConfigTextItem(
{Key? key, this.placeholder, this.controller, this.style = const TextStyle(fontFamily: 'RobotoMono')})
: super(key: key);
const ConfigTextItem({
super.key,
this.placeholder,
this.controller,
this.style = const TextStyle(fontFamily: 'RobotoMono'),
});
final String? placeholder;
final TextEditingController? controller;
@ -15,14 +14,13 @@ class ConfigTextItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: Platform.isAndroid ? EdgeInsets.all(5) : EdgeInsets.zero,
child: SpecialTextField(
autocorrect: false,
minLines: 3,
maxLines: 10,
placeholder: placeholder,
style: style,
controller: controller));
return CupertinoTextFormFieldRow(
autocorrect: false,
minLines: 3,
maxLines: 10,
placeholder: placeholder,
style: style,
controller: controller,
);
}
}

View file

@ -1,8 +1,7 @@
import 'dart:async';
import 'package:flutter/cupertino.dart' show CupertinoThemeData, DefaultCupertinoLocalizations;
import 'package:flutter/material.dart'
show BottomSheetThemeData, Colors, DefaultMaterialLocalizations, ThemeData, ThemeMode;
import 'package:flutter/material.dart' show DefaultMaterialLocalizations, TextTheme, ThemeMode;
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
@ -10,24 +9,22 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:mobile_nebula/screens/MainScreen.dart';
import 'package:mobile_nebula/screens/EnrollmentScreen.dart';
import 'package:mobile_nebula/services/settings.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'package:mobile_nebula/services/theme.dart';
import 'package:mobile_nebula/services/utils.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
Future<void> main() async {
usePathUrlStrategy();
var settings = Settings();
if (settings.trackErrors) {
await SentryFlutter.init(
(options) {
options.dsn = 'https://96106df405ade3f013187dfc8e4200e7@o920269.ingest.us.sentry.io/4508132321001472';
// Capture all traces. May need to adjust if overwhelming
options.tracesSampleRate = 1.0;
// For each trace, capture all profiles
options.profilesSampleRate = 1.0;
},
appRunner: () => runApp(Main()),
);
await SentryFlutter.init((options) {
options.dsn = 'https://96106df405ade3f013187dfc8e4200e7@o920269.ingest.us.sentry.io/4508132321001472';
// Capture all traces. May need to adjust if overwhelming
options.tracesSampleRate = 1.0;
// For each trace, capture all profiles
options.profilesSampleRate = 1.0;
}, appRunner: () => runApp(Main()));
} else {
runApp(Main());
}
@ -36,12 +33,16 @@ Future<void> main() async {
//TODO: EventChannel might be better than the stream controller we are using now
class Main extends StatelessWidget {
const Main({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) => App();
}
class App extends StatefulWidget {
const App({super.key});
@override
_AppState createState() => _AppState();
}
@ -85,68 +86,47 @@ class _AppState extends State<App> {
@override
Widget build(BuildContext context) {
final ThemeData lightTheme = ThemeData(
useMaterial3: false,
brightness: Brightness.light,
primarySwatch: Colors.blueGrey,
primaryColor: Colors.blueGrey[900],
fontFamily: 'PublicSans',
//scaffoldBackgroundColor: Colors.grey[100],
scaffoldBackgroundColor: Colors.white,
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: Colors.blueGrey[50],
),
);
final ThemeData darkTheme = ThemeData(
useMaterial3: false,
brightness: Brightness.dark,
primarySwatch: Colors.grey,
primaryColor: Colors.grey[900],
fontFamily: 'PublicSans',
scaffoldBackgroundColor: Colors.grey[800],
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: Colors.grey[850],
),
);
TextTheme textTheme = Utils.createTextTheme(context, "Public Sans", "Public Sans");
MaterialTheme theme = MaterialTheme(textTheme);
return PlatformProvider(
settings: PlatformSettingsData(iosUsesMaterialWidgets: true),
builder: (context) => PlatformApp(
debugShowCheckedModeBanner: false,
localizationsDelegates: <LocalizationsDelegate<dynamic>>[
DefaultMaterialLocalizations.delegate,
DefaultWidgetsLocalizations.delegate,
DefaultCupertinoLocalizations.delegate,
],
title: 'Nebula',
material: (_, __) {
return new MaterialAppData(
themeMode: brightness == Brightness.light ? ThemeMode.light : ThemeMode.dark,
theme: brightness == Brightness.light ? lightTheme : darkTheme,
);
},
cupertino: (_, __) => CupertinoAppData(
theme: CupertinoThemeData(brightness: brightness),
),
onGenerateRoute: (settings) {
if (settings.name == '/') {
return platformPageRoute(context: context, builder: (context) => MainScreen(this.dnEnrolled));
}
builder:
(context) => PlatformApp(
debugShowCheckedModeBanner: false,
localizationsDelegates: <LocalizationsDelegate<dynamic>>[
DefaultMaterialLocalizations.delegate,
DefaultWidgetsLocalizations.delegate,
DefaultCupertinoLocalizations.delegate,
],
title: 'Nebula',
material: (_, __) {
return MaterialAppData(
themeMode: brightness == Brightness.light ? ThemeMode.light : ThemeMode.dark,
theme: brightness == Brightness.light ? theme.light() : theme.dark(),
);
},
cupertino: (_, __) => CupertinoAppData(theme: CupertinoThemeData(brightness: brightness)),
onGenerateRoute: (settings) {
print(settings);
if (settings.name == '/') {
return platformPageRoute(context: context, builder: (context) => MainScreen(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),
);
}
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: dnEnrolled),
);
}
return null;
},
),
return null;
},
),
);
}
}

View file

@ -19,9 +19,6 @@ class CIDR {
throw 'Invalid CIDR string';
}
return CIDR(
ip: parts[0],
bits: int.parse(parts[1]),
);
return CIDR(ip: parts[0], bits: int.parse(parts[1]));
}
}

View file

@ -3,14 +3,12 @@ class CertificateInfo {
String? rawCert;
CertificateValidity? validity;
CertificateInfo.debug({this.rawCert = ""})
: this.cert = Certificate.debug(),
this.validity = CertificateValidity.debug();
CertificateInfo.debug({this.rawCert = ""}) : cert = Certificate.debug(), validity = CertificateValidity.debug();
CertificateInfo.fromJson(Map<String, dynamic> json)
: cert = Certificate.fromJson(json['Cert']),
rawCert = json['RawCert'],
validity = CertificateValidity.fromJson(json['Validity']);
: cert = Certificate.fromJson(json['Cert']),
rawCert = json['RawCert'],
validity = CertificateValidity.fromJson(json['Validity']);
CertificateInfo({required this.cert, this.rawCert, this.validity});
@ -24,15 +22,12 @@ class Certificate {
String fingerprint;
String signature;
Certificate.debug()
: this.details = CertificateDetails.debug(),
this.fingerprint = "DEBUG",
this.signature = "DEBUG";
Certificate.debug() : details = CertificateDetails.debug(), fingerprint = "DEBUG", signature = "DEBUG";
Certificate.fromJson(Map<String, dynamic> json)
: details = CertificateDetails.fromJson(json['details']),
fingerprint = json['fingerprint'],
signature = json['signature'];
: details = CertificateDetails.fromJson(json['details']),
fingerprint = json['fingerprint'],
signature = json['signature'];
}
class CertificateDetails {
@ -47,37 +42,33 @@ class CertificateDetails {
String issuer;
CertificateDetails.debug()
: this.name = "DEBUG",
notBefore = DateTime.now(),
notAfter = DateTime.now(),
publicKey = "",
groups = [],
ips = [],
subnets = [],
isCa = false,
issuer = "DEBUG";
: name = "DEBUG",
notBefore = DateTime.now(),
notAfter = DateTime.now(),
publicKey = "",
groups = [],
ips = [],
subnets = [],
isCa = false,
issuer = "DEBUG";
CertificateDetails.fromJson(Map<String, dynamic> json)
: name = json['name'],
notBefore = DateTime.parse(json['notBefore']),
notAfter = DateTime.parse(json['notAfter']),
publicKey = json['publicKey'],
groups = List<String>.from(json['groups']),
ips = List<String>.from(json['ips']),
subnets = List<String>.from(json['subnets']),
isCa = json['isCa'],
issuer = json['issuer'];
: name = json['name'],
notBefore = DateTime.parse(json['notBefore']),
notAfter = DateTime.parse(json['notAfter']),
publicKey = json['publicKey'],
groups = List<String>.from(json['groups']),
ips = List<String>.from(json['ips']),
subnets = List<String>.from(json['subnets']),
isCa = json['isCa'],
issuer = json['issuer'];
}
class CertificateValidity {
bool valid;
String reason;
CertificateValidity.debug()
: this.valid = true,
this.reason = "";
CertificateValidity.debug() : valid = true, reason = "";
CertificateValidity.fromJson(Map<String, dynamic> json)
: valid = json['Valid'],
reason = json['Reason'];
CertificateValidity.fromJson(Map<String, dynamic> json) : valid = json['Valid'], reason = json['Reason'];
}

View file

@ -52,10 +52,7 @@ class UDPAddress {
String ip;
int port;
UDPAddress({
required this.ip,
required this.port,
});
UDPAddress({required this.ip, required this.port});
@override
String toString() {

View file

@ -21,9 +21,6 @@ class IPAndPort {
//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");
return IPAndPort(
ip: uri.host,
port: uri.port,
);
return IPAndPort(ip: uri.host, port: uri.port);
}
}

View file

@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
import 'package:mobile_nebula/models/HostInfo.dart';
import 'package:mobile_nebula/models/UnsafeRoute.dart';
import 'package:uuid/uuid.dart';
import 'Certificate.dart';
import 'StaticHosts.dart';
@ -15,7 +16,7 @@ class Site {
late EventChannel _updates;
/// Signals that something about this site has changed. onError is called with an error string if there was an error
StreamController _change = StreamController.broadcast();
final StreamController _change = StreamController.broadcast();
// Identifiers
late String name;
@ -53,60 +54,49 @@ class Site {
late List<String> errors;
Site({
String name = '',
this.name = '',
String? id,
Map<String, StaticHost>? staticHostmap,
List<CertificateInfo>? ca,
CertificateInfo? certInfo,
int lhDuration = 0,
int port = 0,
String cipher = "aes",
int sortKey = 0,
int mtu = 1300,
bool connected = false,
String status = '',
String logFile = '',
String logVerbosity = 'info',
this.certInfo,
this.lhDuration = 0,
this.port = 0,
this.cipher = "aes",
this.sortKey = 0,
this.mtu = 1300,
this.connected = false,
this.status = '',
this.logFile = '',
this.logVerbosity = 'info',
List<String>? errors,
List<UnsafeRoute>? unsafeRoutes,
bool managed = false,
String? rawConfig,
DateTime? lastManagedUpdate,
this.managed = false,
this.rawConfig,
this.lastManagedUpdate,
}) {
this.name = name;
this.id = id ?? uuid.v4();
this.staticHostmap = staticHostmap ?? {};
this.ca = ca ?? [];
this.certInfo = certInfo;
this.lhDuration = lhDuration;
this.port = port;
this.cipher = cipher;
this.sortKey = sortKey;
this.mtu = mtu;
this.connected = connected;
this.status = status;
this.logFile = logFile;
this.logVerbosity = logVerbosity;
this.errors = errors ?? [];
this.unsafeRoutes = unsafeRoutes ?? [];
this.managed = managed;
this.rawConfig = rawConfig;
this.lastManagedUpdate = lastManagedUpdate;
_updates = EventChannel('net.defined.nebula/${this.id}');
_updates.receiveBroadcastStream().listen((d) {
try {
_updateFromJson(d);
_change.add(null);
} catch (err) {
//TODO: handle the error
print(err);
}
}, onError: (err) {
_updateFromJson(err.details);
var error = err as PlatformException;
_change.addError(error.message ?? 'An unexpected error occurred');
});
_updates.receiveBroadcastStream().listen(
(d) {
try {
_updateFromJson(d);
_change.add(null);
} catch (err) {
//TODO: handle the error
print(err);
}
},
onError: (err) {
_updateFromJson(err.details);
var error = err as PlatformException;
_change.addError(error.message ?? 'An unexpected error occurred');
},
);
}
factory Site.fromJson(Map<String, dynamic> json) {
@ -136,25 +126,25 @@ class Site {
_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.managed = decoded['managed'];
this.rawConfig = decoded['rawConfig'];
this.lastManagedUpdate = decoded['lastManagedUpdate'];
name = decoded["name"];
id = decoded['id']; // TODO update EventChannel
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'];
managed = decoded['managed'];
rawConfig = decoded['rawConfig'];
lastManagedUpdate = decoded['lastManagedUpdate'];
}
static _fromJson(Map<String, dynamic> json) {
@ -166,15 +156,15 @@ class Site {
List<dynamic> rawUnsafeRoutes = json['unsafeRoutes'];
List<UnsafeRoute> unsafeRoutes = [];
rawUnsafeRoutes.forEach((val) {
for (var val in rawUnsafeRoutes) {
unsafeRoutes.add(UnsafeRoute.fromJson(val));
});
}
List<dynamic> rawCA = json['ca'];
List<CertificateInfo> ca = [];
rawCA.forEach((val) {
for (var val in rawCA) {
ca.add(CertificateInfo.fromJson(val));
});
}
CertificateInfo? certInfo;
if (json['cert'] != null) {
@ -183,9 +173,9 @@ class Site {
List<dynamic> rawErrors = json["errors"];
List<String> errors = [];
rawErrors.forEach((error) {
for (var error in rawErrors) {
errors.add(error);
});
}
return {
"name": json["name"],
@ -220,9 +210,11 @@ class Site {
'id': id,
'staticHostmap': staticHostmap,
'unsafeRoutes': unsafeRoutes,
'ca': ca.map((cert) {
return cert.rawCert;
}).join('\n'),
'ca': ca
.map((cert) {
return cert.rawCert;
})
.join('\n'),
'cert': certInfo?.rawCert,
'key': key,
'lhDuration': lhDuration,
@ -290,9 +282,9 @@ class Site {
List<dynamic> f = jsonDecode(ret);
List<HostInfo> hosts = [];
f.forEach((v) {
for (var v in f) {
hosts.add(HostInfo.fromJson(v));
});
}
return hosts;
} on PlatformException catch (err) {
@ -312,9 +304,9 @@ class Site {
List<dynamic> f = jsonDecode(ret);
List<HostInfo> hosts = [];
f.forEach((v) {
for (var v in f) {
hosts.add(HostInfo.fromJson(v));
});
}
return hosts;
} on PlatformException catch (err) {
@ -326,7 +318,7 @@ class Site {
Future<Map<String, List<HostInfo>>> listAllHostmaps() async {
try {
var res = await Future.wait([this.listHostmap(), this.listPendingHostmap()]);
var res = await Future.wait([listHostmap(), listPendingHostmap()]);
return {"active": res[0], "pending": res[1]};
} on PlatformException catch (err) {
throw err.details ?? err.message ?? err.toString();
@ -341,8 +333,11 @@ class Site {
Future<HostInfo?> getHostInfo(String vpnIp, bool pending) async {
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);
if (h == null) {
return null;
@ -358,8 +353,11 @@ class Site {
Future<HostInfo?> setRemoteForTunnel(String vpnIp, String addr) async {
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);
if (h == null) {
return null;

View file

@ -10,20 +10,14 @@ class StaticHost {
var list = json['destinations'] as List<dynamic>;
var result = <IPAndPort>[];
list.forEach((item) {
for (var item in list) {
result.add(IPAndPort.fromString(item));
});
}
return StaticHost(
lighthouse: json['lighthouse'],
destinations: result,
);
return StaticHost(lighthouse: json['lighthouse'], destinations: result);
}
Map<String, dynamic> toJson() {
return {
'lighthouse': lighthouse,
'destinations': destinations,
};
return {'lighthouse': lighthouse, 'destinations': destinations};
}
}

View file

@ -5,16 +5,10 @@ class UnsafeRoute {
UnsafeRoute({this.route, this.via});
factory UnsafeRoute.fromJson(Map<String, dynamic> json) {
return UnsafeRoute(
route: json['route'],
via: json['via'],
);
return UnsafeRoute(route: json['route'], via: json['via']);
}
Map<String, dynamic> toJson() {
return {
'route': route,
'via': via,
};
return {'route': route, 'via': via};
}
}

View file

@ -5,11 +5,12 @@ import 'package:mobile_nebula/components/config/ConfigItem.dart';
import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
import 'package:mobile_nebula/components/config/ConfigSection.dart';
import 'package:mobile_nebula/gen.versions.dart';
import 'package:mobile_nebula/screens/LicensesScreen.dart';
import 'package:mobile_nebula/services/utils.dart';
import 'package:package_info_plus/package_info_plus.dart';
class AboutScreen extends StatefulWidget {
const AboutScreen({Key? key}) : super(key: key);
const AboutScreen({super.key});
@override
_AboutScreenState createState() => _AboutScreenState();
@ -36,47 +37,67 @@ class _AboutScreenState extends State<AboutScreen> {
// packageInfo is null until ready is true
if (!ready) {
return Center(
child: PlatformCircularProgressIndicator(cupertino: (_, __) {
return CupertinoProgressIndicatorData(radius: 50);
}),
child: PlatformCircularProgressIndicator(
cupertino: (_, __) {
return CupertinoProgressIndicatorData(radius: 50);
},
),
);
}
return SimplePage(
title: Text('About'),
child: Column(children: [
ConfigSection(children: <Widget>[
ConfigItem(
label: Text('App version'),
labelWidth: 150,
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>[
//TODO: wire up these other pages
// 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://www.defined.net/privacy/', context)),
// ConfigPageItem(label: Text('Licenses'), labelWidth: 300, onPressed: () => Utils.launchUrl('https://defined.net/mobile/license', context)),
]),
Padding(
child: Column(
children: [
ConfigSection(
children: <Widget>[
ConfigItem(
label: Text('App version'),
labelWidth: 150,
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>[
//TODO: wire up these other pages
// 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://www.defined.net/privacy/', context),
),
ConfigPageItem(
label: Text('Licenses'),
labelWidth: 300,
onPressed:
() => Utils.openPage(context, (context) {
return LicensesScreen();
}),
),
],
),
Padding(
padding: EdgeInsets.only(top: 20),
child: Text(
'Copyright © 2024 Defined Networking, Inc',
textAlign: TextAlign.center,
)),
]),
child: Text('Copyright © 2024 Defined Networking, Inc', textAlign: TextAlign.center),
),
],
),
);
}

View file

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
@ -6,8 +7,11 @@ import 'package:flutter/services.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:mobile_nebula/components/SimplePage.dart';
import 'package:mobile_nebula/components/buttons/PrimaryButton.dart';
import 'package:url_launcher/url_launcher.dart';
import '../components/config/ConfigSection.dart';
class EnrollmentScreen extends StatefulWidget {
final String? code;
final StreamController? stream;
@ -45,6 +49,7 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
@override
void initState() {
code = widget.code;
super.initState();
@ -90,91 +95,127 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
} else {
// No code, show the error
child = Padding(
child: Center(
child: Text(
padding: EdgeInsets.only(top: 20),
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) {
} else if (error != null) {
// Error while enrolling, display it
child = Center(
child: Column(
children: [
Padding(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 20),
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 '),
'There was an issue while attempting to enroll this device. Contact your administrator to obtain a new enrollment code.',
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: SelectableText.rich(
TextSpan(
text: 'support@defined.net',
style: bodyTextStyle.apply(color: colorScheme.primary),
recognizer: TapGestureRecognizer()
..onTap = () async {
if (await canLaunchUrl(contactUri)) {
print(await launchUrl(contactUri));
}
},
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:'),
],
),
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) {
),
),
Container(
color: Theme.of(context).colorScheme.errorContainer,
child: Padding(padding: EdgeInsets.all(16), child: SelectableText(error!)),
),
],
),
);
} else if (enrolled) {
// Enrollment complete!
child = Padding(
child: Center(
child: Text(
'Enrollment complete! 🎉',
textAlign: TextAlign.center,
)),
padding: EdgeInsets.only(top: 20));
padding: EdgeInsets.only(top: 20),
child: Center(child: Text('Enrollment complete! 🎉', textAlign: TextAlign.center)),
);
} 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);
})
]));
child: Column(
children: [
Padding(padding: EdgeInsets.only(bottom: 25), child: Text('Contacting DN for enrollment')),
PlatformCircularProgressIndicator(
cupertino: (_, __) {
return CupertinoProgressIndicatorData(radius: 50);
},
),
],
),
);
}
return SimplePage(
title: Text('Enroll with Managed Nebula', style: TextStyle(fontWeight: FontWeight.bold)),
child: Padding(child: child, padding: EdgeInsets.symmetric(horizontal: 10)),
alignment: alignment);
return SimplePage(title: Text('Enroll with Managed Nebula'), alignment: alignment, child: child);
}
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();
});
},
)
]);
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
String? validator(String? value) {
if (value == null || value.isEmpty) {
return 'Code or link is required';
}
return null;
}
Future<void> onSubmit() async {
final bool isValid = formKey.currentState?.validate() ?? false;
if (!isValid) {
return;
}
setState(() {
code = EnrollmentScreen.parseCode(enrollInput.text);
error = null;
_enroll();
});
}
final input = Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: PlatformTextFormField(
controller: enrollInput,
validator: validator,
hintText: 'from admin.defined.net',
cupertino: (_, __) => CupertinoTextFormFieldData(prefix: Text("Code or link")),
material: (_, __) => MaterialTextFormFieldData(decoration: const InputDecoration(labelText: 'Code or link')),
),
);
final form = Form(key: formKey, child: Platform.isAndroid ? input : ConfigSection(children: [input]));
return Column(
children: [
Padding(padding: EdgeInsets.symmetric(vertical: 32), child: form),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Row(children: [Expanded(child: PrimaryButton(onPressed: onSubmit, child: Text('Submit')))]),
),
],
);
}
}

View file

@ -1,6 +1,6 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:mobile_nebula/components/DangerButton.dart';
import 'package:mobile_nebula/components/SimplePage.dart';
import 'package:mobile_nebula/components/config/ConfigCheckboxItem.dart';
import 'package:mobile_nebula/components/config/ConfigItem.dart';
@ -15,14 +15,14 @@ import 'package:pull_to_refresh/pull_to_refresh.dart';
class HostInfoScreen extends StatefulWidget {
const HostInfoScreen({
Key? key,
super.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 pending;
@ -53,52 +53,66 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
final title = widget.pending ? 'Pending' : 'Active';
return SimplePage(
title: Text('$title Host Info'),
refreshController: refreshController,
onRefresh: () async {
await _getHostInfo();
refreshController.refreshCompleted();
},
leadingAction: Utils.leadingBackWidget(context, onPressed: () {
Navigator.pop(context);
}),
child: Column(
children: [_buildMain(), _buildDetails(), _buildRemotes(), !widget.pending ? _buildClose() : Container()]));
title: Text('$title Host Info'),
refreshController: refreshController,
onRefresh: () async {
await _getHostInfo();
refreshController.refreshCompleted();
},
child: Column(
children: [_buildMain(), _buildDetails(), _buildRemotes(), !widget.pending ? _buildClose() : Container()],
),
);
}
Widget _buildMain() {
return ConfigSection(children: [
ConfigItem(label: Text('VPN IP'), labelWidth: 150, content: SelectableText(hostInfo.vpnIp)),
hostInfo.cert != null
? ConfigPageItem(
return ConfigSection(
children: [
ConfigItem(label: Text('VPN IP'), labelWidth: 150, content: SelectableText(hostInfo.vpnIp)),
hostInfo.cert != null
? ConfigPageItem(
label: Text('Certificate'),
labelWidth: 150,
content: Text(hostInfo.cert!.details.name),
onPressed: () => Utils.openPage(
context,
(context) => CertificateDetailsScreen(
certInfo: CertificateInfo(cert: hostInfo.cert!),
supportsQRScanning: widget.supportsQRScanning,
)))
: Container(),
]);
onPressed:
() => Utils.openPage(
context,
(context) => CertificateDetailsScreen(
certInfo: CertificateInfo(cert: hostInfo.cert!),
supportsQRScanning: widget.supportsQRScanning,
),
),
)
: Container(),
],
);
}
Widget _buildDetails() {
return ConfigSection(children: <Widget>[
ConfigItem(
label: Text('Lighthouse'), labelWidth: 150, content: SelectableText(widget.isLighthouse ? 'Yes' : 'No')),
ConfigItem(label: Text('Local Index'), labelWidth: 150, content: SelectableText('${hostInfo.localIndex}')),
ConfigItem(label: Text('Remote Index'), labelWidth: 150, content: SelectableText('${hostInfo.remoteIndex}')),
ConfigItem(
label: Text('Message Counter'), labelWidth: 150, content: SelectableText('${hostInfo.messageCounter}')),
]);
return ConfigSection(
children: <Widget>[
ConfigItem(
label: Text('Lighthouse'),
labelWidth: 150,
content: SelectableText(widget.isLighthouse ? 'Yes' : 'No'),
),
ConfigItem(label: Text('Local Index'), labelWidth: 150, content: SelectableText('${hostInfo.localIndex}')),
ConfigItem(label: Text('Remote Index'), labelWidth: 150, content: SelectableText('${hostInfo.remoteIndex}')),
ConfigItem(
label: Text('Message Counter'),
labelWidth: 150,
content: SelectableText('${hostInfo.messageCounter}'),
),
],
);
}
Widget _buildRemotes() {
if (hostInfo.remoteAddresses.length == 0) {
if (hostInfo.remoteAddresses.isEmpty) {
return ConfigSection(
label: 'REMOTES', children: [ConfigItem(content: Text('No remote addresses yet'), labelWidth: 0)]);
label: 'REMOTES',
children: [ConfigItem(content: Text('No remote addresses yet'), labelWidth: 0)],
);
}
return widget.pending ? _buildStaticRemotes() : _buildEditRemotes();
@ -110,31 +124,33 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
final double ipWidth =
Utils.textSize("000.000.000.000:000000", CupertinoTheme.of(context).textTheme.textStyle).width;
hostInfo.remoteAddresses.forEach((remoteObj) {
for (var remoteObj in hostInfo.remoteAddresses) {
String remote = remoteObj.toString();
items.add(ConfigCheckboxItem(
key: Key(remote),
label: Text(remote), //TODO: need to do something to adjust the font size in the event we have an ipv6 address
labelWidth: ipWidth,
checked: currentRemote == remote,
onChanged: () async {
if (remote == currentRemote) {
return;
}
try {
final h = await widget.site.setRemoteForTunnel(hostInfo.vpnIp, remote);
if (h != null) {
_setHostInfo(h);
items.add(
ConfigCheckboxItem(
key: Key(remote),
label: Text(remote), //TODO: need to do something to adjust the font size in the event we have an ipv6 address
labelWidth: ipWidth,
checked: currentRemote == remote,
onChanged: () async {
if (remote == currentRemote) {
return;
}
} catch (err) {
Utils.popError(context, 'Error while changing the remote', err.toString());
}
},
));
});
return ConfigSection(label: items.length > 0 ? 'Tap to change the active address' : null, children: items);
try {
final h = await widget.site.setRemoteForTunnel(hostInfo.vpnIp, remote);
if (h != null) {
_setHostInfo(h);
}
} catch (err) {
Utils.popError(context, 'Error while changing the remote', err.toString());
}
},
),
);
}
return ConfigSection(label: items.isNotEmpty ? 'Tap to change the active address' : null, children: items);
}
Widget _buildStaticRemotes() {
@ -143,38 +159,43 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
final double ipWidth =
Utils.textSize("000.000.000.000:000000", CupertinoTheme.of(context).textTheme.textStyle).width;
hostInfo.remoteAddresses.forEach((remoteObj) {
for (var remoteObj in hostInfo.remoteAddresses) {
String remote = remoteObj.toString();
items.add(ConfigCheckboxItem(
key: Key(remote),
label: Text(remote), //TODO: need to do something to adjust the font size in the event we have an ipv6 address
labelWidth: ipWidth,
checked: currentRemote == remote,
));
});
items.add(
ConfigCheckboxItem(
key: Key(remote),
label: Text(remote), //TODO: need to do something to adjust the font size in the event we have an ipv6 address
labelWidth: ipWidth,
checked: currentRemote == remote,
),
);
}
return ConfigSection(label: items.length > 0 ? 'REMOTES' : null, children: items);
return ConfigSection(label: items.isNotEmpty ? 'REMOTES' : null, children: items);
}
Widget _buildClose() {
return Padding(
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
child: SizedBox(
width: double.infinity,
child: PlatformElevatedButton(
child: Text('Close Tunnel'),
color: CupertinoColors.systemRed.resolveFrom(context),
onPressed: () => Utils.confirmDelete(context, 'Close Tunnel?', () async {
try {
await widget.site.closeTunnel(hostInfo.vpnIp);
if (widget.onChanged != null) {
widget.onChanged!();
}
Navigator.pop(context);
} catch (err) {
Utils.popError(context, 'Error while trying to close the tunnel', err.toString());
}
}, deleteLabel: 'Close'))));
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
child: SizedBox(
width: double.infinity,
child: DangerButton(
child: Text('Close Tunnel'),
onPressed:
() => Utils.confirmDelete(context, 'Close Tunnel?', () async {
try {
await widget.site.closeTunnel(hostInfo.vpnIp);
if (widget.onChanged != null) {
widget.onChanged!();
}
Navigator.pop(context);
} catch (err) {
Utils.popError(context, 'Error while trying to close the tunnel', err.toString());
}
}, deleteLabel: 'Close'),
),
),
);
}
_getHostInfo() async {

View file

@ -0,0 +1,64 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:mobile_nebula/components/SimplePage.dart';
import 'package:mobile_nebula/services/utils.dart';
import '../../oss_licenses.dart';
String capitalize(String input) {
return input[0].toUpperCase() + input.substring(1);
}
class LicensesScreen extends StatelessWidget {
const LicensesScreen({super.key});
@override
Widget build(BuildContext context) {
return SimplePage(
title: const Text("Licences"),
scrollable: SimpleScrollable.none,
child: ListView.builder(
itemCount: allDependencies.length,
itemBuilder: (_, index) {
var dep = allDependencies[index];
return Padding(
padding: const EdgeInsets.all(8),
child: PlatformListTile(
onTap: () {
Utils.openPage(context, (_) => LicenceDetailPage(title: capitalize(dep.name), licence: dep.license!));
},
title: Text(capitalize(dep.name)),
subtitle: Text(dep.description),
trailing: Icon(context.platformIcons.forward, size: 18),
),
);
},
),
);
}
}
//detail page for the licence
class LicenceDetailPage extends StatelessWidget {
final String title, licence;
const LicenceDetailPage({super.key, required this.title, required this.licence});
@override
Widget build(BuildContext context) {
return SimplePage(
title: Text(title),
scrollable: SimpleScrollable.none,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(8)),
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(children: [Text(licence, style: const TextStyle(fontSize: 15))]),
),
),
),
);
}
}

View file

@ -60,7 +60,7 @@ MAIH7gzreMGgrH/yR6rZpIHR3DxJ3E0aHtEI
};
class MainScreen extends StatefulWidget {
const MainScreen(this.dnEnrollStream, {Key? key}) : super(key: key);
const MainScreen(this.dnEnrollStream, {super.key});
final StreamController dnEnrollStream;
@ -115,12 +115,8 @@ class _MainScreenState extends State<MainScreen> {
if (kDebugMode) {
debugSite = Row(
children: [
_debugSave(badDebugSave),
_debugSave(goodDebugSave),
_debugClearKeys(),
],
mainAxisAlignment: MainAxisAlignment.center,
children: [_debugSave(badDebugSave), _debugSave(goodDebugSave), _debugClearKeys()],
);
}
@ -141,13 +137,15 @@ class _MainScreenState extends State<MainScreen> {
leadingAction: PlatformIconButton(
padding: EdgeInsets.zero,
icon: Icon(Icons.add, size: 28.0),
onPressed: () => Utils.openPage(context, (context) {
return SiteConfigScreen(
onSave: (_) {
_loadSites();
},
supportsQRScanning: supportsQRScanning);
}),
onPressed:
() => Utils.openPage(context, (context) {
return SiteConfigScreen(
onSave: (_) {
_loadSites();
},
supportsQRScanning: supportsQRScanning,
);
}),
),
refreshController: refreshController,
onRefresh: () {
@ -157,7 +155,7 @@ class _MainScreenState extends State<MainScreen> {
trailingActions: <Widget>[
PlatformIconButton(
padding: EdgeInsets.zero,
icon: Icon(Icons.menu, size: 28.0),
icon: Icon(Icons.adaptive.more, size: 28.0),
onPressed: () => Utils.openPage(context, (_) => SettingsScreen(widget.dnEnrollStream)),
),
],
@ -169,13 +167,15 @@ class _MainScreenState extends State<MainScreen> {
Widget _buildBody() {
if (error != null) {
return Center(
child: Padding(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: error!,
),
padding: EdgeInsets.symmetric(vertical: 0, horizontal: 10)));
child: Padding(
padding: EdgeInsets.symmetric(vertical: 0, horizontal: 10),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: error!,
),
),
);
}
return _buildSites();
@ -183,29 +183,33 @@ class _MainScreenState extends State<MainScreen> {
Widget _buildNoSites() {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Center(
child: Column(
children: <Widget>[
Padding(
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),
],
),
));
padding: const EdgeInsets.all(8.0),
child: Center(
child: Column(
children: <Widget>[
Padding(
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,
),
],
),
),
);
}
Widget _buildSites() {
if (sites == null || sites!.length == 0) {
if (sites == null || sites!.isEmpty) {
return _buildNoSites();
}
List<Widget> items = [];
sites!.forEach((site) {
items.add(SiteItem(
for (var site in sites!) {
items.add(
SiteItem(
key: Key(site.id),
site: site,
onPressed: () {
@ -216,44 +220,47 @@ class _MainScreenState extends State<MainScreen> {
supportsQRScanning: supportsQRScanning,
);
});
}));
});
},
),
);
}
Widget child = ReorderableListView(
shrinkWrap: true,
scrollController: scrollController,
padding: EdgeInsets.symmetric(vertical: 5),
children: items,
onReorder: (oldI, newI) async {
if (oldI < newI) {
// removing the item at oldIndex will shorten the list by 1.
newI -= 1;
}
shrinkWrap: true,
scrollController: scrollController,
padding: EdgeInsets.symmetric(vertical: 5),
children: items,
onReorder: (oldI, newI) async {
if (oldI < newI) {
// removing the item at oldIndex will shorten the list by 1.
newI -= 1;
}
setState(() {
final Site moved = sites!.removeAt(oldI);
sites!.insert(newI, moved);
});
for (var i = 0; i < sites!.length; i++) {
if (sites![i].sortKey == i) {
continue;
}
sites![i].sortKey = i;
try {
await sites![i].save();
} catch (err) {
//TODO: display error at the end
print('ERR ${sites![i].name} - $err');
}
}
_loadSites();
setState(() {
final Site moved = sites!.removeAt(oldI);
sites!.insert(newI, moved);
});
for (var i = 0; i < sites!.length; i++) {
if (sites![i].sortKey == i) {
continue;
}
sites![i].sortKey = i;
try {
await sites![i].save();
} catch (err) {
//TODO: display error at the end
print('ERR ${sites![i].name} - $err');
}
}
_loadSites();
},
);
if (Platform.isIOS) {
child = CupertinoTheme(child: child, data: CupertinoTheme.of(context));
child = CupertinoTheme(data: CupertinoTheme.of(context), child: child);
}
// The theme here is to remove the hardcoded canvas border reordering forces on us
@ -267,16 +274,18 @@ class _MainScreenState extends State<MainScreen> {
var uuid = Uuid();
var s = Site(
name: siteConfig['name']!,
id: uuid.v4(),
staticHostmap: {
"10.1.0.1": StaticHost(
lighthouse: true,
destinations: [IPAndPort(ip: '10.1.1.53', port: 4242), IPAndPort(ip: '1::1', port: 4242)])
},
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')]);
name: siteConfig['name']!,
id: uuid.v4(),
staticHostmap: {
"10.1.0.1": StaticHost(
lighthouse: true,
destinations: [IPAndPort(ip: '10.1.1.53', port: 4242), IPAndPort(ip: '1::1', port: 4242)],
),
},
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'];
@ -309,14 +318,17 @@ class _MainScreenState extends State<MainScreen> {
var site = Site.fromJson(rawSite);
//TODO: we need to cancel change listeners when we rebuild
site.onChange().listen((_) {
setState(() {});
}, onError: (err) {
setState(() {});
if (ModalRoute.of(context)!.isCurrent) {
Utils.popError(context, "${site.name} Error", err);
}
});
site.onChange().listen(
(_) {
setState(() {});
},
onError: (err) {
setState(() {});
if (ModalRoute.of(context)!.isCurrent) {
Utils.popError(context, "${site.name} Error", err);
}
},
);
sites!.add(site);
} catch (err) {

View file

@ -27,7 +27,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
void initState() {
//TODO: we need to unregister on dispose?
settings.onChange().listen((_) {
if (this.mounted) {
if (mounted) {
setState(() {});
}
});
@ -38,10 +38,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget build(BuildContext context) {
List<Widget> colorSection = [];
colorSection.add(ConfigItem(
label: Text('Use system colors'),
labelWidth: 200,
content: Align(
colorSection.add(
ConfigItem(
label: Text('Use system colors'),
labelWidth: 200,
content: Align(
alignment: Alignment.centerRight,
child: Switch.adaptive(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
@ -49,13 +50,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
settings.useSystemColors = value;
},
value: settings.useSystemColors,
)),
));
),
),
),
);
if (!settings.useSystemColors) {
colorSection.add(ConfigItem(
label: Text('Dark mode'),
content: Align(
colorSection.add(
ConfigItem(
label: Text('Dark mode'),
content: Align(
alignment: Alignment.centerRight,
child: Switch.adaptive(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
@ -63,16 +67,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
settings.darkMode = value;
},
value: settings.darkMode,
)),
));
),
),
),
);
}
List<Widget> items = [];
items.add(ConfigSection(children: colorSection));
items.add(ConfigItem(
label: Text('Wrap log output'),
labelWidth: 200,
content: Align(
items.add(
ConfigItem(
label: Text('Wrap log output'),
labelWidth: 200,
content: Align(
alignment: Alignment.centerRight,
child: Switch.adaptive(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
@ -82,14 +89,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
settings.logWrap = value;
});
},
)),
));
),
),
),
);
items.add(ConfigSection(children: [
ConfigItem(
label: Text('Report errors automatically'),
labelWidth: 250,
content: Align(
items.add(
ConfigSection(
children: [
ConfigItem(
label: Text('Report errors automatically'),
labelWidth: 250,
content: Align(
alignment: Alignment.centerRight,
child: Switch.adaptive(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
@ -99,27 +110,35 @@ class _SettingsScreenState extends State<SettingsScreen> {
settings.trackErrors = value;
});
},
))),
]));
items.add(ConfigSection(children: [
ConfigPageItem(
label: Text('Enroll with Managed Nebula'),
labelWidth: 250,
onPressed: () =>
Utils.openPage(context, (context) => EnrollmentScreen(stream: widget.stream, allowCodeEntry: true)))
]));
items.add(ConfigSection(children: [
ConfigPageItem(
label: Text('About'),
onPressed: () => Utils.openPage(context, (context) => AboutScreen()),
)
]));
return SimplePage(
title: Text('Settings'),
child: Column(children: items),
),
),
),
],
),
);
items.add(
ConfigSection(
children: [
ConfigPageItem(
label: Text('Enroll with Managed Nebula'),
labelWidth: 250,
onPressed:
() =>
Utils.openPage(context, (context) => EnrollmentScreen(stream: widget.stream, allowCodeEntry: true)),
),
],
),
);
items.add(
ConfigSection(
children: [
ConfigPageItem(label: Text('About'), onPressed: () => Utils.openPage(context, (context) => AboutScreen())),
],
),
);
return SimplePage(title: Text('Settings'), child: Column(children: items));
}
}

View file

@ -4,7 +4,6 @@ import 'package:flutter/cupertino.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:mobile_nebula/components/config/ConfigPageItem.dart';
import 'package:mobile_nebula/components/config/ConfigItem.dart';
@ -17,16 +16,14 @@ import 'package:mobile_nebula/screens/siteConfig/SiteConfigScreen.dart';
import 'package:mobile_nebula/services/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../components/DangerButton.dart';
import '../components/SiteTitle.dart';
//TODO: If the site isn't active, don't respond to reloads on hostmaps
//TODO: ios is now the problem with connecting screwing our ability to query the hostmap (its a race)
class SiteDetailScreen extends StatefulWidget {
const SiteDetailScreen({
Key? key,
required this.site,
this.onChanged,
required this.supportsQRScanning,
}) : super(key: key);
const SiteDetailScreen({super.key, required this.site, this.onChanged, required this.supportsQRScanning});
final Site site;
final Function? onChanged;
@ -52,21 +49,24 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
_listHostmap();
}
onChange = site.onChange().listen((_) {
// TODO: Gross hack... we get site.connected = true to trigger the toggle before the VPN service has started.
// If we fetch the hostmap now we'll never get a response. Wait until Nebula is running.
if (site.status == 'Connected') {
_listHostmap();
} else {
activeHosts = null;
pendingHosts = null;
}
onChange = site.onChange().listen(
(_) {
// TODO: Gross hack... we get site.connected = true to trigger the toggle before the VPN service has started.
// If we fetch the hostmap now we'll never get a response. Wait until Nebula is running.
if (site.status == 'Connected') {
_listHostmap();
} else {
activeHosts = null;
pendingHosts = null;
}
setState(() {});
}, onError: (err) {
setState(() {});
Utils.popError(context, "Error", err);
});
setState(() {});
},
onError: (err) {
setState(() {});
Utils.popError(context, "Error", err);
},
);
super.initState();
}
@ -79,49 +79,52 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
@override
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)))
]);
final title = SiteTitle(site: widget.site);
return SimplePage(
title: title,
leadingAction: Utils.leadingBackWidget(context, onPressed: () {
title: title,
leadingAction: Utils.leadingBackWidget(
context,
onPressed: () {
if (changed && widget.onChanged != null) {
widget.onChanged!();
}
Navigator.pop(context);
}),
refreshController: refreshController,
onRefresh: () async {
if (site.connected && site.status == "Connected") {
await _listHostmap();
}
refreshController.refreshCompleted();
},
child: Column(children: [
),
refreshController: refreshController,
onRefresh: () async {
if (site.connected && site.status == "Connected") {
await _listHostmap();
}
refreshController.refreshCompleted();
},
child: Column(
children: [
_buildErrors(),
_buildConfig(),
site.connected ? _buildHosts() : Container(),
_buildSiteDetails(),
_buildDelete(),
]));
],
),
);
}
Widget _buildErrors() {
if (site.errors.length == 0) {
if (site.errors.isEmpty) {
return Container();
}
List<Widget> items = [];
site.errors.forEach((error) {
items.add(ConfigItem(
labelWidth: 0, content: Padding(padding: EdgeInsets.symmetric(vertical: 10), child: SelectableText(error))));
});
for (var error in site.errors) {
items.add(
ConfigItem(
labelWidth: 0,
content: Padding(padding: EdgeInsets.symmetric(vertical: 10), child: SelectableText(error)),
),
);
}
return ConfigSection(
label: 'ERRORS',
@ -132,40 +135,51 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
}
Widget _buildConfig() {
return ConfigSection(children: <Widget>[
ConfigItem(
void handleChange(v) async {
try {
if (v) {
await widget.site.start();
} else {
await widget.site.stop();
}
} catch (error) {
var action = v ? 'start' : 'stop';
Utils.popError(context, 'Failed to $action the site', error.toString());
}
}
return ConfigSection(
children: <Widget>[
ConfigItem(
label: Text('Status'),
content: Row(mainAxisAlignment: MainAxisAlignment.end, children: <Widget>[
Padding(
content: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
Padding(
padding: EdgeInsets.only(right: 5),
child: Text(widget.site.status,
style: TextStyle(color: CupertinoColors.secondaryLabel.resolveFrom(context)))),
Switch.adaptive(
value: widget.site.connected,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onChanged: (v) async {
try {
if (v) {
await widget.site.start();
} else {
await widget.site.stop();
}
} catch (error) {
var action = v ? 'start' : 'stop';
Utils.popError(context, 'Failed to $action the site', error.toString());
}
},
)
])),
ConfigPageItem(
label: Text('Logs'),
onPressed: () {
Utils.openPage(context, (context) {
return SiteLogsScreen(site: widget.site);
});
},
),
]);
child: Text(
widget.site.status,
style: TextStyle(color: CupertinoColors.secondaryLabel.resolveFrom(context)),
),
),
Switch.adaptive(
value: widget.site.connected,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onChanged: widget.site.errors.isNotEmpty && !widget.site.connected ? null : handleChange,
),
],
),
),
ConfigPageItem(
label: Text('Logs'),
onPressed: () {
Utils.openPage(context, (context) {
return SiteLogsScreen(site: widget.site);
});
},
),
],
);
}
Widget _buildHosts() {
@ -187,83 +201,92 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
label: "TUNNELS",
children: <Widget>[
ConfigPageItem(
onPressed: () {
if (activeHosts == null) return;
onPressed: () {
if (activeHosts == null) return;
Utils.openPage(
context,
(context) => SiteTunnelsScreen(
pending: false,
tunnels: activeHosts!,
site: site,
onChanged: (hosts) {
setState(() {
activeHosts = hosts;
});
},
supportsQRScanning: widget.supportsQRScanning,
));
},
label: Text("Active"),
content: Container(alignment: Alignment.centerRight, child: active)),
Utils.openPage(
context,
(context) => SiteTunnelsScreen(
pending: false,
tunnels: activeHosts!,
site: site,
onChanged: (hosts) {
setState(() {
activeHosts = hosts;
});
},
supportsQRScanning: widget.supportsQRScanning,
),
);
},
label: Text("Active"),
content: Container(alignment: Alignment.centerRight, child: active),
),
ConfigPageItem(
onPressed: () {
if (pendingHosts == null) return;
onPressed: () {
if (pendingHosts == null) return;
Utils.openPage(
context,
(context) => SiteTunnelsScreen(
pending: true,
tunnels: pendingHosts!,
site: site,
onChanged: (hosts) {
setState(() {
pendingHosts = hosts;
});
},
supportsQRScanning: widget.supportsQRScanning,
));
},
label: Text("Pending"),
content: Container(alignment: Alignment.centerRight, child: pending))
Utils.openPage(
context,
(context) => SiteTunnelsScreen(
pending: true,
tunnels: pendingHosts!,
site: site,
onChanged: (hosts) {
setState(() {
pendingHosts = hosts;
});
},
supportsQRScanning: widget.supportsQRScanning,
),
);
},
label: Text("Pending"),
content: Container(alignment: Alignment.centerRight, child: pending),
),
],
);
}
Widget _buildSiteDetails() {
return ConfigSection(children: <Widget>[
ConfigPageItem(
crossAxisAlignment: CrossAxisAlignment.center,
content: Text('Configuration'),
onPressed: () {
Utils.openPage(context, (context) {
return SiteConfigScreen(
site: widget.site,
onSave: (site) async {
changed = true;
setState(() {});
},
supportsQRScanning: widget.supportsQRScanning,
);
});
},
),
]);
return ConfigSection(
children: <Widget>[
ConfigPageItem(
crossAxisAlignment: CrossAxisAlignment.center,
content: Text('Configuration'),
onPressed: () {
Utils.openPage(context, (context) {
return SiteConfigScreen(
site: widget.site,
onSave: (site) async {
changed = true;
setState(() {});
},
supportsQRScanning: widget.supportsQRScanning,
);
});
},
),
],
);
}
Widget _buildDelete() {
return Padding(
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
child: SizedBox(
width: double.infinity,
child: PlatformElevatedButton(
child: Text('Delete'),
color: CupertinoColors.systemRed.resolveFrom(context),
onPressed: () => Utils.confirmDelete(context, 'Delete Site?', () async {
if (await _deleteSite()) {
Navigator.of(context).pop();
}
}))));
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
child: SizedBox(
width: double.infinity,
child: DangerButton(
child: Text('Delete'),
onPressed:
() => Utils.confirmDelete(context, 'Delete Site?', () async {
if (await _deleteSite()) {
Navigator.of(context).pop();
}
}),
),
),
);
}
_listHostmap() async {

View file

@ -3,16 +3,19 @@ import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.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/models/Site.dart';
import 'package:mobile_nebula/services/logs.dart';
import 'package:mobile_nebula/services/result.dart';
import 'package:mobile_nebula/services/settings.dart';
import 'package:mobile_nebula/services/share.dart';
import 'package:mobile_nebula/services/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../components/SiteTitle.dart';
class SiteLogsScreen extends StatefulWidget {
const SiteLogsScreen({Key? key, required this.site}) : super(key: key);
const SiteLogsScreen({super.key, required this.site});
final Site site;
@ -21,14 +24,14 @@ class SiteLogsScreen extends StatefulWidget {
}
class _SiteLogsScreenState extends State<SiteLogsScreen> {
String logs = '';
ScrollController controller = ScrollController();
RefreshController refreshController = RefreshController(initialRefresh: false);
final ScrollController controller = ScrollController();
final RefreshController refreshController = RefreshController(initialRefresh: false);
final LogsNotifier logsNotifier = LogsNotifier();
var settings = Settings();
@override
void initState() {
loadLogs();
logsNotifier.loadLogs(logFile: widget.site.logFile);
super.initState();
}
@ -40,93 +43,119 @@ class _SiteLogsScreenState extends State<SiteLogsScreen> {
@override
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)))
]);
final title = SiteTitle(site: widget.site);
return SimplePage(
title: title,
trailingActions: [Padding(padding: const EdgeInsets.only(right: 8), child: _buildTextWrapToggle())],
scrollable: SimpleScrollable.both,
scrollController: controller,
onRefresh: () async {
await loadLogs();
await logsNotifier.loadLogs(logFile: widget.site.logFile);
refreshController.refreshCompleted();
},
onLoading: () async {
await loadLogs();
await logsNotifier.loadLogs(logFile: widget.site.logFile);
refreshController.loadComplete();
},
refreshController: refreshController,
child: Container(
padding: EdgeInsets.all(5),
constraints: logBoxConstraints(context),
child: SelectableText(logs.trim(), style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14))),
bottomBar: _buildBottomBar(),
child: Container(
padding: EdgeInsets.all(5),
constraints: logBoxConstraints(context),
child: ListenableBuilder(
listenable: logsNotifier,
builder:
(context, child) => SelectableText(switch (logsNotifier.logsResult) {
Ok<String>(:var value) => value.trim(),
Error<String>(:var error) =>
error is LogsNotFoundException
? error.error()
: Utils.popError(context, "Error while reading logs.", error.toString()),
null => "",
}, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
),
),
);
}
Widget _buildTextWrapToggle() {
return Platform.isIOS
? Tooltip(
message: "Turn ${settings.logWrap ? "off" : "on"} text wrapping",
child: CupertinoButton.tinted(
// Use the default tint when enabled, match the background when not.
color: settings.logWrap ? null : CupertinoColors.systemBackground,
sizeStyle: CupertinoButtonSize.small,
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: const Icon(Icons.wrap_text),
onPressed:
() => {
setState(() {
settings.logWrap = !settings.logWrap;
}),
},
),
)
: IconButton.filledTonal(
isSelected: settings.logWrap,
tooltip: "Turn ${settings.logWrap ? "off" : "on"} text wrapping",
// The variants of wrap_text seem to be the same, but this seems most correct.
selectedIcon: const Icon(Icons.wrap_text_outlined),
icon: const Icon(Icons.wrap_text),
onPressed:
() => {
setState(() {
settings.logWrap = !settings.logWrap;
}),
},
);
}
Widget _buildBottomBar() {
var borderSide = BorderSide(
color: CupertinoColors.separator,
style: BorderStyle.solid,
width: 0.0,
);
var borderSide = BorderSide(color: CupertinoColors.separator, style: BorderStyle.solid, width: 0.0);
var padding = Platform.isAndroid ? EdgeInsets.fromLTRB(0, 20, 0, 30) : EdgeInsets.all(10);
return Container(
decoration: BoxDecoration(
border: Border(top: borderSide),
),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
Expanded(child: Builder(builder: (BuildContext context) {
return PlatformIconButton(
padding: padding,
icon: Icon(context.platformIcons.share, size: 30),
return PlatformWidgetBuilder(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
spacing: 8,
children: <Widget>[
Tooltip(
message: "Share logs",
child: PlatformIconButton(
icon: Icon(context.platformIcons.share),
onPressed: () {
Share.shareFile(context,
title: '${widget.site.name} logs',
filePath: widget.site.logFile,
filename: '${widget.site.name}.log');
Share.shareFile(
context,
title: '${widget.site.name} logs',
filePath: widget.site.logFile,
filename: '${widget.site.name}.log',
);
},
);
})),
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);
},
)),
]));
}
loadLogs() async {
var file = File(widget.site.logFile);
try {
final v = await file.readAsString();
setState(() {
logs = v;
});
} on FileSystemException {
Utils.popError(context, 'Error while reading logs', 'No log file was present');
} catch (err) {
Utils.popError(context, 'Error while reading logs', err.toString());
}
}
deleteLogs() async {
var file = File(widget.site.logFile);
await file.writeAsBytes([]);
await loadLogs();
),
),
Tooltip(
message: 'Go to latest',
child: PlatformIconButton(
icon: Icon(context.platformIcons.downArrow),
onPressed: () async {
controller.animateTo(
controller.position.maxScrollExtent,
duration: const Duration(milliseconds: 500),
curve: Curves.linearToEaseOut,
);
},
),
),
],
),
cupertino:
(context, child, platform) =>
Container(decoration: BoxDecoration(border: Border(top: borderSide)), padding: padding, child: child),
material: (context, child, platform) => BottomAppBar(child: child),
);
}
logBoxConstraints(BuildContext context) {

View file

@ -11,13 +11,13 @@ import 'package:pull_to_refresh/pull_to_refresh.dart';
class SiteTunnelsScreen extends StatefulWidget {
const SiteTunnelsScreen({
Key? key,
super.key,
required this.site,
required this.tunnels,
required this.pending,
required this.onChanged,
required this.supportsQRScanning,
}) : super(key: key);
});
final Site site;
final List<HostInfo> tunnels;
@ -53,57 +53,53 @@ class _SiteTunnelsScreenState extends State<SiteTunnelsScreen> {
Widget build(BuildContext context) {
final double ipWidth = Utils.textSize("000.000.000.000", CupertinoTheme.of(context).textTheme.textStyle).width + 32;
List<Widget> children = [];
tunnels.forEach((hostInfo) {
Widget icon;
final List<ConfigPageItem> children =
tunnels.map((hostInfo) {
final isLh = site.staticHostmap[hostInfo.vpnIp]?.lighthouse ?? false;
final icon = switch (isLh) {
true => Icon(Icons.lightbulb_outline, color: CupertinoColors.placeholderText.resolveFrom(context)),
false => Icon(Icons.computer, color: CupertinoColors.placeholderText.resolveFrom(context)),
};
final isLh = site.staticHostmap[hostInfo.vpnIp]?.lighthouse ?? false;
if (isLh) {
icon = Icon(Icons.lightbulb_outline, color: CupertinoColors.placeholderText.resolveFrom(context));
} else {
icon = Icon(Icons.computer, color: CupertinoColors.placeholderText.resolveFrom(context));
}
return (ConfigPageItem(
onPressed:
() => Utils.openPage(
context,
(context) => HostInfoScreen(
isLighthouse: isLh,
hostInfo: hostInfo,
pending: widget.pending,
site: widget.site,
onChanged: () {
_listHostmap();
},
supportsQRScanning: widget.supportsQRScanning,
),
),
label: Row(
children: <Widget>[Padding(padding: EdgeInsets.only(right: 10), child: icon), Text(hostInfo.vpnIp)],
),
labelWidth: ipWidth,
content: Container(alignment: Alignment.centerRight, child: Text(hostInfo.cert?.details.name ?? "")),
));
}).toList();
children.add(ConfigPageItem(
onPressed: () => Utils.openPage(
context,
(context) => HostInfoScreen(
isLighthouse: isLh,
hostInfo: hostInfo,
pending: widget.pending,
site: widget.site,
onChanged: () {
_listHostmap();
},
supportsQRScanning: widget.supportsQRScanning,
),
),
label: Row(children: <Widget>[Padding(child: icon, padding: EdgeInsets.only(right: 10)), Text(hostInfo.vpnIp)]),
labelWidth: ipWidth,
content: Container(alignment: Alignment.centerRight, child: Text(hostInfo.cert?.details.name ?? "")),
));
});
Widget child;
if (children.length == 0) {
child = Center(child: Padding(child: Text('No tunnels to show'), padding: EdgeInsets.only(top: 30)));
} else {
child = ConfigSection(children: children);
}
final Widget child = switch (children.length) {
0 => Center(child: Padding(padding: EdgeInsets.only(top: 30), child: Text('No tunnels to show'))),
_ => ConfigSection(children: children),
};
final title = widget.pending ? 'Pending' : 'Active';
return SimplePage(
title: Text('$title Tunnels'),
leadingAction: Utils.leadingBackWidget(context, onPressed: () {
Navigator.pop(context);
}),
refreshController: refreshController,
onRefresh: () async {
await _listHostmap();
refreshController.refreshCompleted();
},
child: child);
title: Text('$title Tunnels'),
refreshController: refreshController,
onRefresh: () async {
await _listHostmap();
refreshController.refreshCompleted();
},
child: child,
);
}
_sortTunnels() {

View file

@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/cupertino.dart';
@ -15,6 +14,7 @@ import 'package:mobile_nebula/screens/siteConfig/ScanQRScreen.dart';
import 'package:mobile_nebula/services/share.dart';
import 'package:mobile_nebula/services/utils.dart';
import '../../components/buttons/PrimaryButton.dart';
import 'CertificateDetailsScreen.dart';
class CertificateResult {
@ -26,13 +26,13 @@ class CertificateResult {
class AddCertificateScreen extends StatefulWidget {
const AddCertificateScreen({
Key? key,
super.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.
@ -87,32 +87,34 @@ class _AddCertificateScreenState extends State<AddCertificateScreen> {
List<Widget> _buildShare() {
return [
ConfigSection(
label: 'Share your public key with a nebula CA so they can sign and return a certificate',
children: [
ConfigItem(
labelWidth: 0,
content: SelectableText(pubKey, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
),
Builder(
builder: (BuildContext context) {
return ConfigButtonItem(
content: Text('Share Public Key'),
onPressed: () async {
await Share.share(context,
title: 'Please sign and return a certificate', text: pubKey, filename: 'device.pub');
},
);
},
),
])
label: 'Share your public key with a nebula CA so they can sign and return a certificate',
children: [
ConfigItem(
labelWidth: 0,
content: SelectableText(pubKey, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
),
Builder(
builder: (BuildContext context) {
return ConfigButtonItem(
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() {
Map<String, Widget> children = {
'paste': Text('Copy/Paste'),
'file': Text('File'),
};
Map<String, Widget> children = {'paste': Text('Copy/Paste'), 'file': Text('File')};
// not all devices have a camera for QR codes
if (widget.supportsQRScanning) {
@ -121,18 +123,19 @@ class _AddCertificateScreenState extends State<AddCertificateScreen> {
List<Widget> items = [
Padding(
padding: EdgeInsets.fromLTRB(10, 25, 10, 0),
child: CupertinoSlidingSegmentedControl(
groupValue: inputType,
onValueChanged: (v) {
if (v != null) {
setState(() {
inputType = v;
});
}
},
children: children,
))
padding: EdgeInsets.fromLTRB(10, 25, 10, 0),
child: CupertinoSlidingSegmentedControl(
groupValue: inputType,
onValueChanged: (v) {
if (v != null) {
setState(() {
inputType = v;
});
}
},
children: children,
),
),
];
if (inputType == 'paste') {
@ -149,24 +152,25 @@ class _AddCertificateScreenState extends State<AddCertificateScreen> {
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'))));
padding: EdgeInsets.only(top: 20, bottom: 10, left: 10, right: 10),
child: SizedBox(
width: double.infinity,
child: PrimaryButton(
child: Text('Show/Import Private Key'),
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)),
],
children: [ConfigTextItem(controller: keyController, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14))],
);
}
@ -174,17 +178,15 @@ class _AddCertificateScreenState extends State<AddCertificateScreen> {
return [
ConfigSection(
children: [
ConfigTextItem(
placeholder: 'Certificate PEM Contents',
controller: pasteController,
),
ConfigTextItem(placeholder: 'Certificate PEM Contents', controller: pasteController),
ConfigButtonItem(
content: Center(child: Text('Load Certificate')),
onPressed: () {
_addCertEntry(pasteController.text);
}),
content: Center(child: Text('Load Certificate')),
onPressed: () {
_addCertEntry(pasteController.text);
},
),
],
)
),
];
}
@ -193,21 +195,22 @@ class _AddCertificateScreenState extends State<AddCertificateScreen> {
ConfigSection(
children: [
ConfigButtonItem(
content: Center(child: Text('Choose a file')),
onPressed: () async {
try {
final content = await Utils.pickFile(context);
if (content == null) {
return;
}
_addCertEntry(content);
} catch (err) {
return Utils.popError(context, 'Failed to load certificate file', err.toString());
content: Center(child: Text('Choose a file')),
onPressed: () async {
try {
final content = await Utils.pickFile(context);
if (content == null) {
return;
}
})
_addCertEntry(content);
} catch (err) {
return Utils.popError(context, 'Failed to load certificate file', err.toString());
}
},
),
],
)
),
];
}
@ -220,10 +223,7 @@ class _AddCertificateScreenState extends State<AddCertificateScreen> {
onPressed: () async {
var result = await Navigator.push(
context,
platformPageRoute(
context: context,
builder: (context) => new ScanQRScreen(),
),
platformPageRoute(context: context, builder: (context) => ScanQRScreen()),
);
if (result != null) {
_addCertEntry(result);
@ -231,7 +231,7 @@ class _AddCertificateScreenState extends State<AddCertificateScreen> {
},
),
],
)
),
];
}
@ -245,21 +245,29 @@ class _AddCertificateScreenState extends State<AddCertificateScreen> {
var rawCerts = await platform.invokeMethod("nebula.parseCerts", <String, String>{"certs": rawCert});
List<dynamic> certs = jsonDecode(rawCerts);
if (certs.length > 0) {
if (certs.isNotEmpty) {
var tryCertInfo = CertificateInfo.fromJson(certs.first);
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',
'A certificate authority is not appropriate for a client certificate.',
);
} else if (!tryCertInfo.validity!.valid) {
return Utils.popError(context, 'Certificate was invalid', tryCertInfo.validity!.reason);
}
var certMatch = await platform
.invokeMethod("nebula.verifyCertAndKey", <String, String>{"cert": rawCert, "key": keyController.text});
var certMatch = await platform.invokeMethod("nebula.verifyCertAndKey", <String, String>{
"cert": rawCert,
"key": keyController.text,
});
if (!certMatch) {
// 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.');
return Utils.popError(
context,
'Error loading certificate content',
'The provided certificates public key is not compatible with the private key.',
);
}
if (widget.onReplace != null) {

View file

@ -40,11 +40,7 @@ class Advanced {
}
class AdvancedScreen extends StatefulWidget {
const AdvancedScreen({
Key? key,
required this.site,
required this.onSave,
}) : super(key: key);
const AdvancedScreen({super.key, required this.site, required this.onSave});
final Site site;
final ValueChanged<Advanced> onSave;
@ -73,22 +69,24 @@ class _AdvancedScreenState extends State<AdvancedScreen> {
@override
Widget build(BuildContext context) {
return FormPage(
title: 'Advanced Settings',
changed: changed,
onSave: () {
Navigator.pop(context);
widget.onSave(settings);
},
child: Column(children: [
title: 'Advanced Settings',
changed: changed,
onSave: () {
Navigator.pop(context);
widget.onSave(settings);
},
child: Column(
children: [
ConfigSection(
children: [
ConfigItem(
label: Text("Lighthouse interval"),
labelWidth: 200,
//TODO: Auto select on focus?
content: widget.site.managed
? Text(settings.lhDuration.toString() + " seconds", textAlign: TextAlign.right)
: PlatformTextFormField(
label: Text("Lighthouse interval"),
labelWidth: 200,
//TODO: Auto select on focus?
content:
widget.site.managed
? Text("${settings.lhDuration} seconds", textAlign: TextAlign.right)
: PlatformTextFormField(
initialValue: settings.lhDuration.toString(),
keyboardType: TextInputType.number,
suffix: Text("seconds"),
@ -102,14 +100,16 @@ class _AdvancedScreenState extends State<AdvancedScreen> {
}
});
},
)),
),
),
ConfigItem(
label: Text("Listen port"),
labelWidth: 150,
//TODO: Auto select on focus?
content: widget.site.managed
? Text(settings.port.toString(), textAlign: TextAlign.right)
: PlatformTextFormField(
label: Text("Listen port"),
labelWidth: 150,
//TODO: Auto select on focus?
content:
widget.site.managed
? Text(settings.port.toString(), textAlign: TextAlign.right)
: PlatformTextFormField(
initialValue: settings.port.toString(),
keyboardType: TextInputType.number,
textAlign: TextAlign.right,
@ -122,13 +122,15 @@ class _AdvancedScreenState extends State<AdvancedScreen> {
}
});
},
)),
),
),
ConfigItem(
label: Text("MTU"),
labelWidth: 150,
content: widget.site.managed
? Text(settings.mtu.toString(), textAlign: TextAlign.right)
: PlatformTextFormField(
label: Text("MTU"),
labelWidth: 150,
content:
widget.site.managed
? Text(settings.mtu.toString(), textAlign: TextAlign.right)
: PlatformTextFormField(
initialValue: settings.mtu.toString(),
keyboardType: TextInputType.number,
textAlign: TextAlign.right,
@ -141,41 +143,46 @@ class _AdvancedScreenState extends State<AdvancedScreen> {
}
});
},
)),
),
),
ConfigPageItem(
disabled: widget.site.managed,
label: Text('Cipher'),
labelWidth: 150,
content: Text(settings.cipher, textAlign: TextAlign.end),
onPressed: () {
Utils.openPage(context, (context) {
return CipherScreen(
cipher: settings.cipher,
onSave: (cipher) {
setState(() {
settings.cipher = cipher;
changed = true;
});
});
});
}),
disabled: widget.site.managed,
label: Text('Cipher'),
labelWidth: 150,
content: Text(settings.cipher, textAlign: TextAlign.end),
onPressed: () {
Utils.openPage(context, (context) {
return CipherScreen(
cipher: settings.cipher,
onSave: (cipher) {
setState(() {
settings.cipher = cipher;
changed = true;
});
},
);
});
},
),
ConfigPageItem(
disabled: widget.site.managed,
label: Text('Log verbosity'),
labelWidth: 150,
content: Text(settings.verbosity, textAlign: TextAlign.end),
onPressed: () {
Utils.openPage(context, (context) {
return LogVerbosityScreen(
verbosity: settings.verbosity,
onSave: (verbosity) {
setState(() {
settings.verbosity = verbosity;
changed = true;
});
});
});
}),
disabled: widget.site.managed,
label: Text('Log verbosity'),
labelWidth: 150,
content: Text(settings.verbosity, textAlign: TextAlign.end),
onPressed: () {
Utils.openPage(context, (context) {
return LogVerbosityScreen(
verbosity: settings.verbosity,
onSave: (verbosity) {
setState(() {
settings.verbosity = verbosity;
changed = true;
});
},
);
});
},
),
ConfigPageItem(
label: Text('Unsafe routes'),
labelWidth: 150,
@ -183,18 +190,20 @@ class _AdvancedScreenState extends State<AdvancedScreen> {
onPressed: () {
Utils.openPage(context, (context) {
return UnsafeRoutesScreen(
unsafeRoutes: settings.unsafeRoutes,
onSave: widget.site.managed
? null
: (routes) {
unsafeRoutes: settings.unsafeRoutes,
onSave:
widget.site.managed
? null
: (routes) {
setState(() {
settings.unsafeRoutes = routes;
changed = true;
});
});
},
);
});
},
)
),
],
),
ConfigSection(
@ -211,9 +220,11 @@ class _AdvancedScreenState extends State<AdvancedScreen> {
Utils.popError(context, 'Failed to render the site config', err.toString());
}
},
)
),
],
)
]));
),
],
),
);
}
}

View file

@ -18,12 +18,7 @@ import 'package:mobile_nebula/services/utils.dart';
//TODO: In addition you will want to think about re-generation while the site is still active (This means storing multiple keys in secure storage)
class CAListScreen extends StatefulWidget {
const CAListScreen({
Key? key,
required this.cas,
this.onSave,
required this.supportsQRScanning,
}) : super(key: key);
const CAListScreen({super.key, required this.cas, this.onSave, required this.supportsQRScanning});
final List<CertificateInfo> cas;
final ValueChanged<List<CertificateInfo>>? onSave;
@ -44,9 +39,9 @@ class _CAListScreenState extends State<CAListScreen> {
@override
void initState() {
widget.cas.forEach((ca) {
for (var ca in widget.cas) {
cas[ca.cert.fingerprint] = ca;
});
}
super.initState();
}
@ -56,7 +51,7 @@ class _CAListScreenState extends State<CAListScreen> {
List<Widget> items = [];
final caItems = _buildCAs();
if (caItems.length > 0) {
if (caItems.isNotEmpty) {
items.add(ConfigSection(children: caItems));
}
@ -65,41 +60,47 @@ class _CAListScreenState extends State<CAListScreen> {
}
return FormPage(
title: 'Certificate Authorities',
changed: changed,
onSave: () {
if (widget.onSave != null) {
Navigator.pop(context);
widget.onSave!(cas.values.map((ca) {
title: 'Certificate Authorities',
changed: changed,
onSave: () {
if (widget.onSave != null) {
Navigator.pop(context);
widget.onSave!(
cas.values.map((ca) {
return ca;
}).toList());
}
},
child: Column(children: items));
}).toList(),
);
}
},
child: Column(children: items),
);
}
List<Widget> _buildCAs() {
List<Widget> items = [];
cas.forEach((key, ca) {
items.add(ConfigPageItem(
content: Text(ca.cert.details.name),
onPressed: () {
Utils.openPage(context, (context) {
return CertificateDetailsScreen(
certInfo: ca,
onDelete: widget.onSave == null
? null
: () {
setState(() {
changed = true;
cas.remove(key);
});
},
supportsQRScanning: widget.supportsQRScanning,
);
});
},
));
items.add(
ConfigPageItem(
content: Text(ca.cert.details.name),
onPressed: () {
Utils.openPage(context, (context) {
return CertificateDetailsScreen(
certInfo: ca,
onDelete:
widget.onSave == null
? null
: () {
setState(() {
changed = true;
cas.remove(key);
});
},
supportsQRScanning: widget.supportsQRScanning,
);
});
},
),
);
});
return items;
@ -114,14 +115,14 @@ class _CAListScreenState extends State<CAListScreen> {
var ignored = 0;
List<dynamic> certs = jsonDecode(rawCerts);
certs.forEach((rawCert) {
for (var rawCert in certs) {
final info = CertificateInfo.fromJson(rawCert);
if (!info.cert.details.isCa) {
ignored++;
return;
continue;
}
cas[info.cert.fingerprint] = info;
});
}
if (ignored > 0) {
error = 'One or more certificates were ignored because they were not certificate authorities.';
@ -137,10 +138,7 @@ class _CAListScreenState extends State<CAListScreen> {
}
List<Widget> _addCA() {
Map<String, Widget> children = {
'paste': Text('Copy/Paste'),
'file': Text('File'),
};
Map<String, Widget> children = {'paste': Text('Copy/Paste'), 'file': Text('File')};
// not all devices have a camera for QR codes
if (widget.supportsQRScanning) {
@ -149,18 +147,19 @@ class _CAListScreenState extends State<CAListScreen> {
List<Widget> items = [
Padding(
padding: EdgeInsets.fromLTRB(10, 25, 10, 0),
child: CupertinoSlidingSegmentedControl(
groupValue: inputType,
onValueChanged: (v) {
if (v != null) {
setState(() {
inputType = v;
});
}
},
children: children,
))
padding: EdgeInsets.fromLTRB(10, 25, 10, 0),
child: CupertinoSlidingSegmentedControl(
groupValue: inputType,
onValueChanged: (v) {
if (v != null) {
setState(() {
inputType = v;
});
}
},
children: children,
),
),
];
if (inputType == 'paste') {
@ -178,25 +177,23 @@ class _CAListScreenState extends State<CAListScreen> {
return [
ConfigSection(
children: [
ConfigTextItem(
placeholder: 'CA PEM contents',
controller: pasteController,
),
ConfigTextItem(placeholder: 'CA PEM contents', controller: pasteController),
ConfigButtonItem(
content: Text('Load CA'),
onPressed: () {
_addCAEntry(pasteController.text, (err) {
print(err);
if (err != null) {
return Utils.popError(context, 'Failed to parse CA content', err);
}
content: Text('Load CA'),
onPressed: () {
_addCAEntry(pasteController.text, (err) {
print(err);
if (err != null) {
return Utils.popError(context, 'Failed to parse CA content', err);
}
pasteController.text = '';
setState(() {});
});
}),
pasteController.text = '';
setState(() {});
});
},
),
],
)
),
];
}
@ -205,27 +202,28 @@ class _CAListScreenState extends State<CAListScreen> {
ConfigSection(
children: [
ConfigButtonItem(
content: Text('Choose a file'),
onPressed: () async {
try {
final content = await Utils.pickFile(context);
if (content == null) {
return;
}
_addCAEntry(content, (err) {
if (err != null) {
Utils.popError(context, 'Error loading CA file', err);
} else {
setState(() {});
}
});
} catch (err) {
return Utils.popError(context, 'Failed to load CA file', err.toString());
content: Text('Choose a file'),
onPressed: () async {
try {
final content = await Utils.pickFile(context);
if (content == null) {
return;
}
})
_addCAEntry(content, (err) {
if (err != null) {
Utils.popError(context, 'Error loading CA file', err);
} else {
setState(() {});
}
});
} catch (err) {
return Utils.popError(context, 'Failed to load CA file', err.toString());
}
},
),
],
)
),
];
}
@ -238,10 +236,7 @@ class _CAListScreenState extends State<CAListScreen> {
onPressed: () async {
var result = await Navigator.push(
context,
platformPageRoute(
context: context,
builder: (context) => new ScanQRScreen(),
),
platformPageRoute(context: context, builder: (context) => ScanQRScreen()),
);
if (result != null) {
_addCAEntry(result, (err) {
@ -253,9 +248,9 @@ class _CAListScreenState extends State<CAListScreen> {
});
}
},
)
),
],
)
),
];
}
}

View file

@ -1,6 +1,6 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:mobile_nebula/components/DangerButton.dart';
import 'package:mobile_nebula/components/FormPage.dart';
import 'package:mobile_nebula/components/config/ConfigItem.dart';
import 'package:mobile_nebula/components/config/ConfigSection.dart';
@ -11,7 +11,7 @@ import 'package:mobile_nebula/services/utils.dart';
/// Displays the details of a CertificateInfo object. Respects incomplete objects (missing validity or rawCert)
class CertificateDetailsScreen extends StatefulWidget {
const CertificateDetailsScreen({
Key? key,
super.key,
required this.certInfo,
this.onDelete,
this.onSave,
@ -19,7 +19,7 @@ class CertificateDetailsScreen extends StatefulWidget {
this.pubKey,
this.privKey,
required this.supportsQRScanning,
}) : super(key: key);
});
final CertificateInfo certInfo;
@ -76,58 +76,63 @@ class _CertificateDetailsScreenState extends State<CertificateDetailsScreen> {
}
},
hideSave: widget.onSave == null && widget.onReplace == null,
child: Column(children: [
_buildID(),
_buildFilters(),
_buildValid(),
_buildAdvanced(),
_buildReplace(),
_buildDelete(),
]),
child: Column(
children: [_buildID(), _buildFilters(), _buildValid(), _buildAdvanced(), _buildReplace(), _buildDelete()],
),
);
}
Widget _buildID() {
return ConfigSection(children: <Widget>[
ConfigItem(label: Text('Name'), content: SelectableText(certInfo.cert.details.name)),
ConfigItem(
label: Text('Type'), content: Text(certInfo.cert.details.isCa ? 'CA certificate' : 'Client certificate')),
]);
return ConfigSection(
children: <Widget>[
ConfigItem(label: Text('Name'), content: SelectableText(certInfo.cert.details.name)),
ConfigItem(
label: Text('Type'),
content: Text(certInfo.cert.details.isCa ? 'CA certificate' : 'Client certificate'),
),
],
);
}
Widget _buildValid() {
var valid = Text('yes');
if (certInfo.validity != null && !certInfo.validity!.valid) {
valid = Text(certInfo.validity!.valid ? 'yes' : certInfo.validity!.reason,
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(context)));
valid = Text(
certInfo.validity!.valid ? 'yes' : certInfo.validity!.reason,
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(context)),
);
}
return ConfigSection(
label: 'VALIDITY',
children: <Widget>[
ConfigItem(label: Text('Valid?'), content: valid),
ConfigItem(
label: Text('Created'), content: SelectableText(certInfo.cert.details.notBefore.toLocal().toString())),
label: Text('Created'),
content: SelectableText(certInfo.cert.details.notBefore.toLocal().toString()),
),
ConfigItem(
label: Text('Expires'), content: SelectableText(certInfo.cert.details.notAfter.toLocal().toString())),
label: Text('Expires'),
content: SelectableText(certInfo.cert.details.notAfter.toLocal().toString()),
),
],
);
}
Widget _buildFilters() {
List<Widget> items = [];
if (certInfo.cert.details.groups.length > 0) {
if (certInfo.cert.details.groups.isNotEmpty) {
items.add(ConfigItem(label: Text('Groups'), content: SelectableText(certInfo.cert.details.groups.join(', '))));
}
if (certInfo.cert.details.ips.length > 0) {
if (certInfo.cert.details.ips.isNotEmpty) {
items.add(ConfigItem(label: Text('IPs'), content: SelectableText(certInfo.cert.details.ips.join(', '))));
}
if (certInfo.cert.details.subnets.length > 0) {
if (certInfo.cert.details.subnets.isNotEmpty) {
items.add(ConfigItem(label: Text('Subnets'), content: SelectableText(certInfo.cert.details.subnets.join(', '))));
}
return items.length > 0
return items.isNotEmpty
? ConfigSection(label: certInfo.cert.details.isCa ? 'FILTERS' : 'DETAILS', children: items)
: Container();
}
@ -136,20 +141,24 @@ class _CertificateDetailsScreenState extends State<CertificateDetailsScreen> {
return ConfigSection(
children: <Widget>[
ConfigItem(
label: Text('Fingerprint'),
content:
SelectableText(certInfo.cert.fingerprint, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
crossAxisAlignment: CrossAxisAlignment.start),
label: Text('Fingerprint'),
content: SelectableText(certInfo.cert.fingerprint, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
crossAxisAlignment: CrossAxisAlignment.start,
),
ConfigItem(
label: Text('Public Key'),
content: SelectableText(certInfo.cert.details.publicKey,
style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
crossAxisAlignment: CrossAxisAlignment.start),
label: Text('Public Key'),
content: SelectableText(
certInfo.cert.details.publicKey,
style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14),
),
crossAxisAlignment: CrossAxisAlignment.start,
),
certInfo.rawCert != null
? ConfigItem(
label: Text('PEM Format'),
content: SelectableText(certInfo.rawCert!, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
crossAxisAlignment: CrossAxisAlignment.start)
label: Text('PEM Format'),
content: SelectableText(certInfo.rawCert!, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
crossAxisAlignment: CrossAxisAlignment.start,
)
: Container(),
],
);
@ -161,31 +170,32 @@ class _CertificateDetailsScreenState extends State<CertificateDetailsScreen> {
}
return Padding(
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
child: SizedBox(
width: double.infinity,
child: PlatformElevatedButton(
child: Text('Replace certificate'),
color: CupertinoColors.systemRed.resolveFrom(context),
onPressed: () {
Utils.openPage(context, (context) {
return AddCertificateScreen(
onReplace: (result) {
setState(() {
changed = true;
certResult = result;
certInfo = result.certInfo;
});
// Slam the page back to the top
controller.animateTo(0,
duration: const Duration(milliseconds: 10), curve: Curves.linearToEaseOut);
},
pubKey: widget.pubKey!,
privKey: widget.privKey!,
supportsQRScanning: widget.supportsQRScanning,
);
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
child: SizedBox(
width: double.infinity,
child: DangerButton(
child: Text('Replace certificate'),
onPressed: () {
Utils.openPage(context, (context) {
return AddCertificateScreen(
onReplace: (result) {
setState(() {
changed = true;
certResult = result;
certInfo = result.certInfo;
});
})));
// Slam the page back to the top
controller.animateTo(0, duration: const Duration(milliseconds: 10), curve: Curves.linearToEaseOut);
},
pubKey: widget.pubKey!,
privKey: widget.privKey!,
supportsQRScanning: widget.supportsQRScanning,
);
});
},
),
),
);
}
Widget _buildDelete() {
@ -196,15 +206,18 @@ class _CertificateDetailsScreenState extends State<CertificateDetailsScreen> {
var title = certInfo.cert.details.isCa ? 'Delete CA?' : 'Delete cert?';
return Padding(
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
child: SizedBox(
width: double.infinity,
child: PlatformElevatedButton(
child: Text('Delete'),
color: CupertinoColors.systemRed.resolveFrom(context),
onPressed: () => Utils.confirmDelete(context, title, () async {
Navigator.pop(context);
widget.onDelete!();
}))));
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
child: SizedBox(
width: double.infinity,
child: DangerButton(
child: Text('Delete'),
onPressed:
() => Utils.confirmDelete(context, title, () async {
Navigator.pop(context);
widget.onDelete!();
}),
),
),
);
}
}

View file

@ -6,11 +6,7 @@ import 'package:mobile_nebula/components/config/ConfigCheckboxItem.dart';
import 'package:mobile_nebula/components/config/ConfigSection.dart';
class CipherScreen extends StatefulWidget {
const CipherScreen({
Key? key,
required this.cipher,
required this.onSave,
}) : super(key: key);
const CipherScreen({super.key, required this.cipher, required this.onSave});
final String cipher;
final ValueChanged<String> onSave;
@ -32,15 +28,16 @@ class _CipherScreenState extends State<CipherScreen> {
@override
Widget build(BuildContext context) {
return FormPage(
title: 'Cipher Selection',
changed: changed,
onSave: () {
Navigator.pop(context);
widget.onSave(cipher);
},
child: Column(
children: <Widget>[
ConfigSection(children: [
title: 'Cipher Selection',
changed: changed,
onSave: () {
Navigator.pop(context);
widget.onSave(cipher);
},
child: Column(
children: <Widget>[
ConfigSection(
children: [
ConfigCheckboxItem(
label: Text("aes"),
labelWidth: 150,
@ -62,9 +59,11 @@ class _CipherScreenState extends State<CipherScreen> {
cipher = "chachapoly";
});
},
)
])
],
));
),
],
),
],
),
);
}
}

View file

@ -6,11 +6,7 @@ import 'package:mobile_nebula/components/config/ConfigCheckboxItem.dart';
import 'package:mobile_nebula/components/config/ConfigSection.dart';
class LogVerbosityScreen extends StatefulWidget {
const LogVerbosityScreen({
Key? key,
required this.verbosity,
required this.onSave,
}) : super(key: key);
const LogVerbosityScreen({super.key, required this.verbosity, required this.onSave});
final String verbosity;
final ValueChanged<String> onSave;
@ -32,24 +28,27 @@ class _LogVerbosityScreenState extends State<LogVerbosityScreen> {
@override
Widget build(BuildContext context) {
return FormPage(
title: 'Log Verbosity',
changed: changed,
onSave: () {
Navigator.pop(context);
widget.onSave(verbosity);
},
child: Column(
children: <Widget>[
ConfigSection(children: [
title: 'Log Verbosity',
changed: changed,
onSave: () {
Navigator.pop(context);
widget.onSave(verbosity);
},
child: Column(
children: <Widget>[
ConfigSection(
children: [
_buildEntry('debug'),
_buildEntry('info'),
_buildEntry('warning'),
_buildEntry('error'),
_buildEntry('fatal'),
_buildEntry('panic'),
])
],
));
],
),
],
),
);
}
Widget _buildEntry(String title) {

View file

@ -7,11 +7,7 @@ class RenderedConfigScreen extends StatelessWidget {
final String config;
final String name;
RenderedConfigScreen({
Key? key,
required this.config,
required this.name,
}) : super(key: key);
const RenderedConfigScreen({super.key, required this.config, required this.name});
@override
Widget build(BuildContext context) {
@ -19,18 +15,21 @@ class RenderedConfigScreen extends StatelessWidget {
title: Text('Rendered Site Config'),
scrollable: SimpleScrollable.both,
trailingActions: <Widget>[
Builder(builder: (BuildContext context) {
return PlatformIconButton(
padding: EdgeInsets.zero,
icon: Icon(context.platformIcons.share, size: 28.0),
onPressed: () => Share.share(context, title: '$name.yaml', text: config, filename: '$name.yaml'),
);
}),
Builder(
builder: (BuildContext context) {
return PlatformIconButton(
padding: EdgeInsets.zero,
icon: Icon(context.platformIcons.share, size: 28.0),
onPressed: () => Share.share(context, title: '$name.yaml', text: config, filename: '$name.yaml'),
);
},
),
],
child: Container(
padding: EdgeInsets.all(5),
constraints: BoxConstraints(minWidth: MediaQuery.of(context).size.width),
child: SelectableText(config, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14))),
padding: EdgeInsets.all(5),
constraints: BoxConstraints(minWidth: MediaQuery.of(context).size.width),
child: SelectableText(config, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
),
);
}
}

View file

@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
class ScanQRScreen extends StatefulWidget {
const ScanQRScreen({super.key});
@override
State<ScanQRScreen> createState() => _ScanQRScreenState();
}
@ -14,29 +16,28 @@ class _ScanQRScreenState extends State<ScanQRScreen> {
@override
Widget build(BuildContext context) {
final scanWindow = Rect.fromCenter(
center: MediaQuery.sizeOf(context).center(Offset.zero),
width: 250,
height: 250,
);
final scanWindow = Rect.fromCenter(center: MediaQuery.sizeOf(context).center(Offset.zero), width: 250, height: 250);
return Scaffold(
appBar: AppBar(title: const Text('Scan QR')),
backgroundColor: Colors.black,
body: Stack(fit: StackFit.expand, children: [
appBar: AppBar(title: const Text('Scan QR')),
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
Center(
child: MobileScanner(
fit: BoxFit.contain,
controller: cameraController,
scanWindow: scanWindow,
onDetect: (BarcodeCapture barcodes) {
var barcode = barcodes.barcodes.firstOrNull;
if (barcode != null && mounted) {
cameraController.stop().then((_) {
Navigator.pop(context, barcode.rawValue);
});
}
}),
fit: BoxFit.contain,
controller: cameraController,
scanWindow: scanWindow,
onDetect: (BarcodeCapture barcodes) {
var barcode = barcodes.barcodes.firstOrNull;
if (barcode != null && mounted) {
cameraController.stop().then((_) {
Navigator.pop(context, barcode.rawValue);
});
}
},
),
),
ValueListenableBuilder(
valueListenable: cameraController,
@ -45,9 +46,7 @@ class _ScanQRScreenState extends State<ScanQRScreen> {
return const SizedBox();
}
return CustomPaint(
painter: ScannerOverlay(scanWindow: scanWindow),
);
return CustomPaint(painter: ScannerOverlay(scanWindow: scanWindow));
},
),
Align(
@ -63,15 +62,14 @@ class _ScanQRScreenState extends State<ScanQRScreen> {
),
),
),
]));
],
),
);
}
}
class ScannerOverlay extends CustomPainter {
const ScannerOverlay({
required this.scanWindow,
this.borderRadius = 12.0,
});
const ScannerOverlay({required this.scanWindow, this.borderRadius = 12.0});
final Rect scanWindow;
final double borderRadius;
@ -81,32 +79,30 @@ class ScannerOverlay extends CustomPainter {
// we need to pass the size to the custom paint widget
final backgroundPath = Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height));
final cutoutPath = Path()
..addRRect(
RRect.fromRectAndCorners(
scanWindow,
topLeft: Radius.circular(borderRadius),
topRight: Radius.circular(borderRadius),
bottomLeft: Radius.circular(borderRadius),
bottomRight: Radius.circular(borderRadius),
),
);
final cutoutPath =
Path()..addRRect(
RRect.fromRectAndCorners(
scanWindow,
topLeft: Radius.circular(borderRadius),
topRight: Radius.circular(borderRadius),
bottomLeft: Radius.circular(borderRadius),
bottomRight: Radius.circular(borderRadius),
),
);
final backgroundPaint = Paint()
..color = Colors.black.withOpacity(0.5)
..style = PaintingStyle.fill
..blendMode = BlendMode.srcOver;
final backgroundPaint =
Paint()
..color = Colors.black.withValues(alpha: 0.5)
..style = PaintingStyle.fill
..blendMode = BlendMode.srcOver;
final backgroundWithCutout = Path.combine(
PathOperation.difference,
backgroundPath,
cutoutPath,
);
final backgroundWithCutout = Path.combine(PathOperation.difference, backgroundPath, cutoutPath);
final borderPaint = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 4.0;
final borderPaint =
Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 4.0;
final borderRect = RRect.fromRectAndCorners(
scanWindow,
@ -214,14 +210,7 @@ class ToggleFlashlightButton extends StatelessWidget {
},
);
case TorchState.unavailable:
return const SizedBox.square(
dimension: 48.0,
child: Icon(
Icons.no_flash,
size: 32.0,
color: Colors.grey,
),
);
return const SizedBox.square(dimension: 48.0, child: Icon(Icons.no_flash, size: 32.0, color: Colors.grey));
}
},
);

View file

@ -23,12 +23,7 @@ import 'package:mobile_nebula/services/utils.dart';
//TODO: Enforce a name
class SiteConfigScreen extends StatefulWidget {
const SiteConfigScreen({
Key? key,
this.site,
required this.onSave,
required this.supportsQRScanning,
}) : super(key: key);
const SiteConfigScreen({super.key, this.site, required this.onSave, required this.supportsQRScanning});
final Site? site;
@ -71,42 +66,45 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
Widget build(BuildContext context) {
if (pubKey == null || privKey == null) {
return Center(
child: fpw.PlatformCircularProgressIndicator(cupertino: (_, __) {
return fpw.CupertinoProgressIndicatorData(radius: 50);
}),
child: fpw.PlatformCircularProgressIndicator(
cupertino: (_, __) {
return fpw.CupertinoProgressIndicatorData(radius: 50);
},
),
);
}
return FormPage(
title: newSite ? 'New Site' : 'Edit Site',
changed: changed,
onSave: () async {
site.name = nameController.text;
try {
await site.save();
} catch (error) {
return Utils.popError(context, 'Failed to save the site configuration', error.toString());
}
title: newSite ? 'New Site' : 'Edit Site',
changed: changed,
onSave: () async {
site.name = nameController.text;
try {
await site.save();
} catch (error) {
return Utils.popError(context, 'Failed to save the site configuration', error.toString());
}
Navigator.pop(context);
widget.onSave(site);
},
child: Column(
children: <Widget>[
_main(),
_keys(),
_hosts(),
_advanced(),
_managed(),
kDebugMode ? _debugConfig() : Container(height: 0),
],
));
Navigator.pop(context);
widget.onSave(site);
},
child: Column(
children: <Widget>[
_main(),
_keys(),
_hosts(),
_advanced(),
_managed(),
kDebugMode ? _debugConfig() : Container(height: 0),
],
),
);
}
Widget _debugConfig() {
var data = "";
try {
final encoder = new JsonEncoder.withIndent(' ');
final encoder = JsonEncoder.withIndent(' ');
data = encoder.convert(site);
} catch (err) {
data = err.toString();
@ -116,8 +114,9 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
}
Widget _main() {
return ConfigSection(children: <Widget>[
ConfigItem(
return ConfigSection(
children: <Widget>[
ConfigItem(
label: Text("Name"),
content: PlatformTextFormField(
placeholder: 'Required',
@ -128,8 +127,10 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
}
return null;
},
))
]);
),
),
],
);
}
Widget _managed() {
@ -140,15 +141,19 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
}
return site.managed
? ConfigSection(label: "MANAGED CONFIG", children: <Widget>[
? ConfigSection(
label: "MANAGED CONFIG",
children: <Widget>[
ConfigItem(
label: Text("Last Update"),
content:
Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[
Text(lastUpdate),
]),
)
])
content: Wrap(
alignment: WrapAlignment.end,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[Text(lastUpdate)],
),
),
],
)
: Container();
}
@ -156,13 +161,13 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
final certError = site.certInfo == null || site.certInfo!.validity == null || !site.certInfo!.validity!.valid;
var caError = false;
if (!site.managed) {
caError = site.ca.length == 0;
caError = site.ca.isEmpty;
if (!caError) {
site.ca.forEach((ca) {
for (var ca in site.ca) {
if (ca.validity == null || !ca.validity!.valid) {
caError = true;
}
});
}
}
}
@ -171,14 +176,19 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
children: [
ConfigPageItem(
label: Text('Certificate'),
content: Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[
certError
? Padding(
content: Wrap(
alignment: WrapAlignment.end,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
certError
? Padding(
padding: EdgeInsets.only(right: 5),
child: Icon(Icons.error, color: CupertinoColors.systemRed.resolveFrom(context), size: 20),
padding: EdgeInsets.only(right: 5))
: Container(),
certError ? Text('Needs attention') : Text(site.certInfo?.cert.details.name ?? 'Unknown certificate')
]),
)
: Container(),
certError ? Text('Needs attention') : Text(site.certInfo?.cert.details.name ?? 'Unknown certificate'),
],
),
onPressed: () {
Utils.openPage(context, (context) {
if (site.certInfo != null) {
@ -186,15 +196,16 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
certInfo: site.certInfo!,
pubKey: pubKey,
privKey: privKey,
onReplace: site.managed
? null
: (result) {
setState(() {
changed = true;
site.certInfo = result.certInfo;
site.key = result.key;
});
},
onReplace:
site.managed
? null
: (result) {
setState(() {
changed = true;
site.certInfo = result.certInfo;
site.key = result.key;
});
},
supportsQRScanning: widget.supportsQRScanning,
);
}
@ -215,32 +226,38 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
},
),
ConfigPageItem(
label: Text("CA"),
content:
Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[
label: Text("CA"),
content: Wrap(
alignment: WrapAlignment.end,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
caError
? Padding(
child: Icon(Icons.error, color: CupertinoColors.systemRed.resolveFrom(context), size: 20),
padding: EdgeInsets.only(right: 5))
padding: EdgeInsets.only(right: 5),
child: Icon(Icons.error, color: CupertinoColors.systemRed.resolveFrom(context), size: 20),
)
: Container(),
caError ? Text('Needs attention') : Text(Utils.itemCountFormat(site.ca.length))
]),
onPressed: () {
Utils.openPage(context, (context) {
return CAListScreen(
cas: site.ca,
onSave: site.managed
? null
: (ca) {
caError ? Text('Needs attention') : Text(Utils.itemCountFormat(site.ca.length)),
],
),
onPressed: () {
Utils.openPage(context, (context) {
return CAListScreen(
cas: site.ca,
onSave:
site.managed
? null
: (ca) {
setState(() {
changed = true;
site.ca = ca;
});
},
supportsQRScanning: widget.supportsQRScanning,
);
});
})
supportsQRScanning: widget.supportsQRScanning,
);
});
},
),
],
);
}
@ -251,28 +268,35 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
children: <Widget>[
ConfigPageItem(
label: Text('Hosts'),
content: Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[
site.staticHostmap.length == 0
? Padding(
content: Wrap(
alignment: WrapAlignment.end,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
site.staticHostmap.isEmpty
? Padding(
padding: EdgeInsets.only(right: 5),
child: Icon(Icons.error, color: CupertinoColors.systemRed.resolveFrom(context), size: 20),
padding: EdgeInsets.only(right: 5))
: Container(),
site.staticHostmap.length == 0
? Text('Needs attention')
: Text(Utils.itemCountFormat(site.staticHostmap.length))
]),
)
: Container(),
site.staticHostmap.isEmpty
? Text('Needs attention')
: Text(Utils.itemCountFormat(site.staticHostmap.length)),
],
),
onPressed: () {
Utils.openPage(context, (context) {
return StaticHostsScreen(
hostmap: site.staticHostmap,
onSave: site.managed
? null
: (map) {
hostmap: site.staticHostmap,
onSave:
site.managed
? null
: (map) {
setState(() {
changed = true;
site.staticHostmap = map;
});
});
},
);
});
},
),
@ -285,24 +309,26 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
label: "ADVANCED",
children: <Widget>[
ConfigPageItem(
label: Text('Advanced'),
onPressed: () {
Utils.openPage(context, (context) {
return AdvancedScreen(
site: site,
onSave: (settings) {
setState(() {
changed = true;
site.cipher = settings.cipher;
site.lhDuration = settings.lhDuration;
site.port = settings.port;
site.logVerbosity = settings.verbosity;
site.unsafeRoutes = settings.unsafeRoutes;
site.mtu = settings.mtu;
});
});
});
})
label: Text('Advanced'),
onPressed: () {
Utils.openPage(context, (context) {
return AdvancedScreen(
site: site,
onSave: (settings) {
setState(() {
changed = true;
site.cipher = settings.cipher;
site.lhDuration = settings.lhDuration;
site.port = settings.port;
site.logVerbosity = settings.verbosity;
site.unsafeRoutes = settings.unsafeRoutes;
site.mtu = settings.mtu;
});
},
);
});
},
),
],
);
}

View file

@ -1,6 +1,7 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:mobile_nebula/components/DangerButton.dart';
import 'package:mobile_nebula/components/FormPage.dart';
import 'package:mobile_nebula/components/IPAndPortFormField.dart';
import 'package:mobile_nebula/components/IPFormField.dart';
@ -20,14 +21,13 @@ class _IPAndPort {
class StaticHostmapScreen extends StatefulWidget {
StaticHostmapScreen({
Key? key,
super.key,
this.nebulaIp = '',
destinations,
this.lighthouse = false,
this.onDelete,
required this.onSave,
}) : this.destinations = destinations ?? [],
super(key: key);
}) : destinations = destinations ?? [];
final List<IPAndPort> destinations;
final String nebulaIp;
@ -50,11 +50,11 @@ class _StaticHostmapScreenState extends State<StaticHostmapScreen> {
_nebulaIp = widget.nebulaIp;
_lighthouse = widget.lighthouse;
_destinations = {};
widget.destinations.forEach((dest) {
for (var dest in widget.destinations) {
_destinations[UniqueKey()] = _IPAndPort(focusNode: FocusNode(), destination: dest);
});
}
if (_destinations.length == 0) {
if (_destinations.isEmpty) {
_addDestination();
}
@ -66,69 +66,81 @@ class _StaticHostmapScreenState extends State<StaticHostmapScreen> {
@override
Widget build(BuildContext context) {
return FormPage(
title: widget.onDelete == null
? widget.onSave == null
? 'View Static Host'
: 'New Static Host'
: 'Edit Static Host',
changed: changed,
onSave: _onSave,
child: Column(children: [
ConfigSection(label: 'Maps a nebula ip address to multiple real world addresses', children: <Widget>[
ConfigItem(
title:
widget.onDelete == null
? widget.onSave == null
? 'View Static Host'
: 'New Static Host'
: 'Edit Static Host',
changed: changed,
onSave: _onSave,
child: Column(
children: [
ConfigSection(
label: 'Maps a nebula ip address to multiple real world addresses',
children: <Widget>[
ConfigItem(
label: Text('Nebula IP'),
labelWidth: 200,
content: widget.onSave == null
? Text(_nebulaIp, textAlign: TextAlign.end)
: IPFormField(
help: "Required",
initialValue: _nebulaIp,
ipOnly: true,
textAlign: TextAlign.end,
crossAxisAlignment: CrossAxisAlignment.end,
textInputAction: TextInputAction.next,
onSaved: (v) {
if (v != null) {
_nebulaIp = v;
}
})),
ConfigItem(
label: Text('Lighthouse'),
labelWidth: 200,
content: Container(
content:
widget.onSave == null
? Text(_nebulaIp, textAlign: TextAlign.end)
: IPFormField(
help: "Required",
initialValue: _nebulaIp,
ipOnly: true,
textAlign: TextAlign.end,
crossAxisAlignment: CrossAxisAlignment.end,
textInputAction: TextInputAction.next,
onSaved: (v) {
if (v != null) {
_nebulaIp = v;
}
},
),
),
ConfigItem(
label: Text('Lighthouse'),
labelWidth: 200,
content: Container(
alignment: Alignment.centerRight,
child: Switch.adaptive(
value: _lighthouse,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onChanged: widget.onSave == null
? null
: (v) {
value: _lighthouse,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onChanged:
widget.onSave == null
? null
: (v) {
setState(() {
changed = true;
_lighthouse = v;
});
})),
),
]),
ConfigSection(
label: 'List of public ips or dns names where for this host',
children: _buildHosts(),
},
),
),
),
],
),
ConfigSection(label: 'List of public ips or dns names where for this host', children: _buildHosts()),
widget.onDelete != null
? Padding(
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
child: SizedBox(
width: double.infinity,
child: PlatformElevatedButton(
child: Text('Delete'),
color: CupertinoColors.systemRed.resolveFrom(context),
onPressed: () => Utils.confirmDelete(context, 'Delete host map?', () {
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
child: SizedBox(
width: double.infinity,
child: DangerButton(
child: Text('Delete'),
onPressed:
() => Utils.confirmDelete(context, 'Delete host map?', () {
Navigator.of(context).pop();
widget.onDelete!();
}),
)))
: Container()
]));
),
),
)
: Container(),
],
),
);
}
_onSave() {
@ -148,47 +160,61 @@ class _StaticHostmapScreenState extends State<StaticHostmapScreen> {
List<Widget> items = [];
_destinations.forEach((key, dest) {
items.add(ConfigItem(
key: key,
label: Align(
items.add(
ConfigItem(
key: key,
label: Align(
alignment: Alignment.centerLeft,
child: widget.onSave == null
? Container()
: PlatformIconButton(
padding: EdgeInsets.zero,
icon: Icon(Icons.remove_circle, color: CupertinoColors.systemRed.resolveFrom(context)),
onPressed: () => setState(() {
_removeDestination(key);
_dismissKeyboard();
}))),
labelWidth: 70,
content: Row(children: <Widget>[
Expanded(
child: widget.onSave == null
? Text(dest.destination.toString(), textAlign: TextAlign.end)
: IPAndPortFormField(
ipHelp: 'public ip or name',
ipTextAlign: TextAlign.end,
enableIPV6: true,
noBorder: true,
initialValue: dest.destination,
onSaved: (v) {
if (v != null) {
dest.destination = v;
}
},
)),
]),
));
child:
widget.onSave == null
? Container()
: PlatformIconButton(
padding: EdgeInsets.zero,
icon: Icon(Icons.remove_circle, color: CupertinoColors.systemRed.resolveFrom(context)),
onPressed:
() => setState(() {
_removeDestination(key);
_dismissKeyboard();
}),
),
),
labelWidth: 70,
content: Row(
children: <Widget>[
Expanded(
child:
widget.onSave == null
? Text(dest.destination.toString(), textAlign: TextAlign.end)
: IPAndPortFormField(
ipHelp: 'public ip or name',
ipTextAlign: TextAlign.end,
enableIPV6: true,
noBorder: true,
initialValue: dest.destination,
onSaved: (v) {
if (v != null) {
dest.destination = v;
}
},
),
),
],
),
),
);
});
if (widget.onSave != null) {
items.add(ConfigButtonItem(
items.add(
ConfigButtonItem(
content: Text('Add another'),
onPressed: () => setState(() {
onPressed:
() => setState(() {
_addDestination();
_dismissKeyboard();
})));
}),
),
);
}
return items;

View file

@ -18,20 +18,11 @@ class _Hostmap {
List<IPAndPort> destinations;
bool lighthouse;
_Hostmap({
required this.focusNode,
required this.nebulaIp,
required this.destinations,
required this.lighthouse,
});
_Hostmap({required this.focusNode, required this.nebulaIp, required this.destinations, required this.lighthouse});
}
class StaticHostsScreen extends StatefulWidget {
const StaticHostsScreen({
Key? key,
required this.hostmap,
required this.onSave,
}) : super(key: key);
const StaticHostsScreen({super.key, required this.hostmap, required this.onSave});
final Map<String, StaticHost> hostmap;
final ValueChanged<Map<String, StaticHost>>? onSave;
@ -41,14 +32,18 @@ class StaticHostsScreen extends StatefulWidget {
}
class _StaticHostsScreenState extends State<StaticHostsScreen> {
Map<Key, _Hostmap> _hostmap = {};
final Map<Key, _Hostmap> _hostmap = {};
bool changed = false;
@override
void initState() {
widget.hostmap.forEach((key, map) {
_hostmap[UniqueKey()] =
_Hostmap(focusNode: FocusNode(), nebulaIp: key, destinations: map.destinations, lighthouse: map.lighthouse);
_hostmap[UniqueKey()] = _Hostmap(
focusNode: FocusNode(),
nebulaIp: key,
destinations: map.destinations,
lighthouse: map.lighthouse,
);
});
super.initState();
@ -57,12 +52,11 @@ class _StaticHostsScreenState extends State<StaticHostsScreen> {
@override
Widget build(BuildContext context) {
return FormPage(
title: 'Static Hosts',
changed: changed,
onSave: _onSave,
child: ConfigSection(
children: _buildHosts(),
));
title: 'Static Hosts',
changed: changed,
onSave: _onSave,
child: ConfigSection(children: _buildHosts()),
);
}
_onSave() {
@ -81,59 +75,73 @@ class _StaticHostsScreenState extends State<StaticHostsScreen> {
final double ipWidth = Utils.textSize("000.000.000.000", CupertinoTheme.of(context).textTheme.textStyle).width + 32;
List<Widget> items = [];
_hostmap.forEach((key, host) {
items.add(ConfigPageItem(
label: Row(children: <Widget>[
Padding(
child: Icon(host.lighthouse ? Icons.lightbulb_outline : Icons.computer,
color: CupertinoColors.placeholderText.resolveFrom(context)),
padding: EdgeInsets.only(right: 10)),
Text(host.nebulaIp),
]),
labelWidth: ipWidth,
content: Text(host.destinations.length.toString() + ' items', textAlign: TextAlign.end),
onPressed: () {
Utils.openPage(context, (context) {
return StaticHostmapScreen(
items.add(
ConfigPageItem(
label: Row(
children: <Widget>[
Padding(
padding: EdgeInsets.only(right: 10),
child: Icon(
host.lighthouse ? Icons.lightbulb_outline : Icons.computer,
color: CupertinoColors.placeholderText.resolveFrom(context),
),
),
Text(host.nebulaIp),
],
),
labelWidth: ipWidth,
content: Text('${host.destinations.length} items', textAlign: TextAlign.end),
onPressed: () {
Utils.openPage(context, (context) {
return StaticHostmapScreen(
nebulaIp: host.nebulaIp,
destinations: host.destinations,
lighthouse: host.lighthouse,
onSave: widget.onSave == null
? null
: (map) {
setState(() {
changed = true;
host.nebulaIp = map.nebulaIp;
host.destinations = map.destinations;
host.lighthouse = map.lighthouse;
});
},
onDelete: widget.onSave == null
? null
: () {
setState(() {
changed = true;
_hostmap.remove(key);
});
});
});
},
));
onSave:
widget.onSave == null
? null
: (map) {
setState(() {
changed = true;
host.nebulaIp = map.nebulaIp;
host.destinations = map.destinations;
host.lighthouse = map.lighthouse;
});
},
onDelete:
widget.onSave == null
? null
: () {
setState(() {
changed = true;
_hostmap.remove(key);
});
},
);
});
},
),
);
});
if (widget.onSave != null) {
items.add(ConfigButtonItem(
content: Text('Add a new entry'),
onPressed: () {
Utils.openPage(context, (context) {
return StaticHostmapScreen(onSave: (map) {
setState(() {
changed = true;
_addHostmap(map);
});
items.add(
ConfigButtonItem(
content: Text('Add a new entry'),
onPressed: () {
Utils.openPage(context, (context) {
return StaticHostmapScreen(
onSave: (map) {
setState(() {
changed = true;
_addHostmap(map);
});
},
);
});
});
},
));
},
),
);
}
return items;
@ -141,7 +149,11 @@ class _StaticHostsScreenState extends State<StaticHostsScreen> {
_addHostmap(Hostmap map) {
_hostmap[UniqueKey()] = (_Hostmap(
focusNode: FocusNode(), nebulaIp: map.nebulaIp, destinations: map.destinations, lighthouse: map.lighthouse));
focusNode: FocusNode(),
nebulaIp: map.nebulaIp,
destinations: map.destinations,
lighthouse: map.lighthouse,
));
}
@override

View file

@ -1,6 +1,6 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:mobile_nebula/components/CIDRFormField.dart';
import 'package:mobile_nebula/components/DangerButton.dart';
import 'package:mobile_nebula/components/FormPage.dart';
import 'package:mobile_nebula/components/IPFormField.dart';
import 'package:mobile_nebula/components/config/ConfigItem.dart';
@ -10,12 +10,7 @@ import 'package:mobile_nebula/models/UnsafeRoute.dart';
import 'package:mobile_nebula/services/utils.dart';
class UnsafeRouteScreen extends StatefulWidget {
const UnsafeRouteScreen({
Key? key,
required this.route,
required this.onSave,
this.onDelete,
}) : super(key: key);
const UnsafeRouteScreen({super.key, required this.route, required this.onSave, this.onDelete});
final UnsafeRoute route;
final ValueChanged<UnsafeRoute> onSave;
@ -44,68 +39,79 @@ class _UnsafeRouteScreenState extends State<UnsafeRouteScreen> {
var routeCIDR = route.route == null ? CIDR() : CIDR.fromString(route.route!);
return FormPage(
title: widget.onDelete == null ? 'New Unsafe Route' : 'Edit Unsafe Route',
changed: changed,
onSave: _onSave,
child: Column(children: [
ConfigSection(children: <Widget>[
ConfigItem(
title: widget.onDelete == null ? 'New Unsafe Route' : 'Edit Unsafe Route',
changed: changed,
onSave: _onSave,
child: Column(
children: [
ConfigSection(
children: <Widget>[
ConfigItem(
label: Text('Route'),
content: CIDRFormField(
initialValue: routeCIDR,
textInputAction: TextInputAction.next,
focusNode: routeFocus,
nextFocusNode: viaFocus,
onSaved: (v) {
route.route = v.toString();
})),
ConfigItem(
initialValue: routeCIDR,
textInputAction: TextInputAction.next,
focusNode: routeFocus,
nextFocusNode: viaFocus,
onSaved: (v) {
route.route = v.toString();
},
),
),
ConfigItem(
label: Text('Via'),
content: IPFormField(
initialValue: route.via ?? '',
ipOnly: true,
help: 'nebula ip',
textAlign: TextAlign.end,
crossAxisAlignment: CrossAxisAlignment.end,
textInputAction: TextInputAction.next,
focusNode: viaFocus,
nextFocusNode: mtuFocus,
onSaved: (v) {
if (v != null) {
route.via = v;
}
})),
//TODO: Android doesn't appear to support route based MTU, figure this out
// ConfigItem(
// label: Text('MTU'),
// content: PlatformTextFormField(
// placeholder: "",
// validator: mtuValidator(false),
// keyboardType: TextInputType.number,
// inputFormatters: [WhitelistingTextInputFormatter.digitsOnly],
// initialValue: route?.mtu.toString(),
// textAlign: TextAlign.end,
// textInputAction: TextInputAction.done,
// focusNode: mtuFocus,
// onSaved: (v) {
// route.mtu = int.tryParse(v);
// })),
]),
initialValue: route.via ?? '',
ipOnly: true,
help: 'nebula ip',
textAlign: TextAlign.end,
crossAxisAlignment: CrossAxisAlignment.end,
textInputAction: TextInputAction.next,
focusNode: viaFocus,
nextFocusNode: mtuFocus,
onSaved: (v) {
if (v != null) {
route.via = v;
}
},
),
),
//TODO: Android doesn't appear to support route based MTU, figure this out
// ConfigItem(
// label: Text('MTU'),
// content: PlatformTextFormField(
// placeholder: "",
// validator: mtuValidator(false),
// keyboardType: TextInputType.number,
// inputFormatters: [WhitelistingTextInputFormatter.digitsOnly],
// initialValue: route?.mtu.toString(),
// textAlign: TextAlign.end,
// textInputAction: TextInputAction.done,
// focusNode: mtuFocus,
// onSaved: (v) {
// route.mtu = int.tryParse(v);
// })),
],
),
widget.onDelete != null
? Padding(
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
child: SizedBox(
width: double.infinity,
child: PlatformElevatedButton(
child: Text('Delete'),
color: CupertinoColors.systemRed.resolveFrom(context),
onPressed: () => Utils.confirmDelete(context, 'Delete unsafe route?', () {
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
child: SizedBox(
width: double.infinity,
child: DangerButton(
child: Text('Delete'),
onPressed:
() => Utils.confirmDelete(context, 'Delete unsafe route?', () {
Navigator.of(context).pop();
widget.onDelete!();
}),
)))
: Container()
]));
),
),
)
: Container(),
],
),
);
}
_onSave() {

View file

@ -8,11 +8,7 @@ import 'package:mobile_nebula/screens/siteConfig/UnsafeRouteScreen.dart';
import 'package:mobile_nebula/services/utils.dart';
class UnsafeRoutesScreen extends StatefulWidget {
const UnsafeRoutesScreen({
Key? key,
required this.unsafeRoutes,
required this.onSave,
}) : super(key: key);
const UnsafeRoutesScreen({super.key, required this.unsafeRoutes, required this.onSave});
final List<UnsafeRoute> unsafeRoutes;
final ValueChanged<List<UnsafeRoute>>? onSave;
@ -28,9 +24,9 @@ class _UnsafeRoutesScreenState extends State<UnsafeRoutesScreen> {
@override
void initState() {
unsafeRoutes = {};
widget.unsafeRoutes.forEach((route) {
for (var route in widget.unsafeRoutes) {
unsafeRoutes[UniqueKey()] = route;
});
}
super.initState();
}
@ -38,12 +34,11 @@ class _UnsafeRoutesScreenState extends State<UnsafeRoutesScreen> {
@override
Widget build(BuildContext context) {
return FormPage(
title: 'Unsafe Routes',
changed: changed,
onSave: _onSave,
child: ConfigSection(
children: _buildRoutes(),
));
title: 'Unsafe Routes',
changed: changed,
onSave: _onSave,
child: ConfigSection(children: _buildRoutes()),
);
}
_onSave() {
@ -57,14 +52,15 @@ class _UnsafeRoutesScreenState extends State<UnsafeRoutesScreen> {
final double ipWidth = Utils.textSize("000.000.000.000/00", CupertinoTheme.of(context).textTheme.textStyle).width;
List<Widget> items = [];
unsafeRoutes.forEach((key, route) {
items.add(ConfigPageItem(
disabled: widget.onSave == null,
label: Text(route.route ?? ''),
labelWidth: ipWidth,
content: Text('via ${route.via}', textAlign: TextAlign.end),
onPressed: () {
Utils.openPage(context, (context) {
return UnsafeRouteScreen(
items.add(
ConfigPageItem(
disabled: widget.onSave == null,
label: Text(route.route ?? ''),
labelWidth: ipWidth,
content: Text('via ${route.via}', textAlign: TextAlign.end),
onPressed: () {
Utils.openPage(context, (context) {
return UnsafeRouteScreen(
route: route,
onSave: (route) {
setState(() {
@ -77,28 +73,33 @@ class _UnsafeRoutesScreenState extends State<UnsafeRoutesScreen> {
changed = true;
unsafeRoutes.remove(key);
});
});
});
},
));
},
);
});
},
),
);
});
if (widget.onSave != null) {
items.add(ConfigButtonItem(
content: Text('Add a new route'),
onPressed: () {
Utils.openPage(context, (context) {
return UnsafeRouteScreen(
items.add(
ConfigButtonItem(
content: Text('Add a new route'),
onPressed: () {
Utils.openPage(context, (context) {
return UnsafeRouteScreen(
route: UnsafeRoute(),
onSave: (route) {
setState(() {
changed = true;
unsafeRoutes[UniqueKey()] = route;
});
});
});
},
));
},
);
});
},
),
);
}
return items;

31
lib/services/logs.dart Normal file
View file

@ -0,0 +1,31 @@
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:mobile_nebula/services/result.dart';
class LogsNotFoundException implements Exception {
String error() => 'No logs found. Logs will be available after starting the site for the first time.';
}
class LogsNotifier extends ChangeNotifier {
Result<String>? logsResult;
LogsNotifier();
loadLogs({required String logFile}) async {
final file = File(logFile);
try {
logsResult = Result.ok(await file.readAsString());
notifyListeners();
} on FileSystemException {
logsResult = Result.error(LogsNotFoundException());
notifyListeners();
} on Exception catch (err) {
logsResult = Result.error(err);
notifyListeners();
} catch (err) {
logsResult = Result.error(Exception(err));
notifyListeners();
}
}
}

52
lib/services/result.dart Normal file
View file

@ -0,0 +1,52 @@
/// Utility class that simplifies handling errors.
///
/// Return a [Result] from a function to indicate success or failure.
///
/// A [Result] is either an [Ok] with a value of type [T]
/// or an [Error] with an [Exception].
///
/// Use [Result.ok] to create a successful result with a value of type [T].
/// Use [Result.error] to create an error result with an [Exception].
///
/// Evaluate the result using a switch statement:
/// ```dart
/// switch (result) {
/// case Ok(): {
/// print(result.value);
/// }
/// case Error(): {
/// print(result.error);
/// }
/// }
/// ```
sealed class Result<T> {
const Result();
/// Creates a successful [Result], completed with the specified [value].
const factory Result.ok(T value) = Ok._;
/// Creates an error [Result], completed with the specified [error].
const factory Result.error(Exception error) = Error._;
}
/// A successful [Result] with a returned [value].
final class Ok<T> extends Result<T> {
const Ok._(this.value);
/// The returned value of this result.
final T value;
@override
String toString() => 'Result<$T>.ok($value)';
}
/// An error [Result] with a resulting [error].
final class Error<T> extends Result<T> {
const Error._(this.error);
/// The resulting error of this result.
final Exception error;
@override
String toString() => 'Result<$T>.error($error)';
}

View file

@ -10,8 +10,8 @@ bool DEFAULT_TRACK_ERRORS = true;
class Settings {
final _storage = Storage();
StreamController _change = StreamController.broadcast();
var _settings = Map<String, dynamic>();
final StreamController _change = StreamController.broadcast();
var _settings = <String, dynamic>{};
bool get useSystemColors {
return _getBool('systemDarkMode', true);

View file

@ -40,8 +40,12 @@ class Share {
/// - title: Title of message or subject if sending an email
/// - filePath: Path to the file to share
/// - filename: An optional filename to override the existing file
static Future<bool> shareFile(BuildContext context,
{required String title, required String filePath, String? filename}) async {
static Future<bool> shareFile(
BuildContext context, {
required String title,
required String filePath,
String? filename,
}) async {
assert(title.isNotEmpty);
assert(filePath.isNotEmpty);
@ -51,8 +55,11 @@ class Share {
// If we want to support that again we will need to save the file to a temporary directory, share that,
// and then delete it
final xFile = sp.XFile(filePath, name: filename);
final result = await sp.Share.shareXFiles([xFile],
subject: title, sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size);
final result = await sp.Share.shareXFiles(
[xFile],
subject: title,
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
);
return result.status == sp.ShareResultStatus.success;
}
}

View file

@ -20,11 +20,14 @@ class Storage {
var completer = Completer<List<FileSystemEntity>>();
Directory(parent).list().listen((FileSystemEntity entity) {
list.add(entity);
}).onDone(() {
completer.complete(list);
});
Directory(parent)
.list()
.listen((FileSystemEntity entity) {
list.add(entity);
})
.onDone(() {
completer.complete(list);
});
return completer.future;
}

392
lib/services/theme.dart Normal file
View file

@ -0,0 +1,392 @@
import "package:flutter/material.dart";
// Originally generated by https://material-foundation.github.io/material-theme-builder/
// from a source color of #5D23DD
class MaterialTheme {
final TextTheme textTheme;
const MaterialTheme(this.textTheme);
static ColorScheme lightScheme() {
return const ColorScheme(
brightness: Brightness.light,
primary: Color(4284700303),
surfaceTint: Color(4284700303),
onPrimary: Color(4294967295),
primaryContainer: Color(4293451519),
onPrimaryContainer: Color(4283121270),
secondary: Color(4284570481),
onSecondary: Color(4294967295),
secondaryContainer: Color(4293385976),
onSecondaryContainer: Color(4282991704),
tertiary: Color(4286403169),
onTertiary: Color(4294967295),
tertiaryContainer: Color(4294957540),
onTertiaryContainer: Color(4284693322),
error: Color(4290386458),
onError: Color(4294967295),
errorContainer: Color(4294957782),
onErrorContainer: Color(4287823882),
surface: Color(4294834175),
onSurface: Color(4280032032),
onSurfaceVariant: Color(4282926414),
outline: Color(4286150015),
outlineVariant: Color(4291478735),
shadow: Color(4278190080),
scrim: Color(4278190080),
inverseSurface: Color(4281478965),
inversePrimary: Color(4291673599),
primaryFixed: Color(4293451519),
onPrimaryFixed: Color(4280225864),
primaryFixedDim: Color(4291673599),
onPrimaryFixedVariant: Color(4283121270),
secondaryFixed: Color(4293385976),
onSecondaryFixed: Color(4280097067),
secondaryFixedDim: Color(4291544028),
onSecondaryFixedVariant: Color(4282991704),
tertiaryFixed: Color(4294957540),
onTertiaryFixed: Color(4281405726),
tertiaryFixedDim: Color(4293834953),
onTertiaryFixedVariant: Color(4284693322),
surfaceDim: Color(4292729056),
surfaceBright: Color(4294834175),
surfaceContainerLowest: Color(4294967295),
surfaceContainerLow: Color(4294439674),
surfaceContainer: Color(4294110452),
surfaceContainerHigh: Color(4293715694),
surfaceContainerHighest: Color(4293321193),
);
}
ThemeData light() {
return theme(lightScheme());
}
static ColorScheme lightMediumContrastScheme() {
return const ColorScheme(
brightness: Brightness.light,
primary: Color(4282002788),
surfaceTint: Color(4284700303),
onPrimary: Color(4294967295),
primaryContainer: Color(4285686943),
onPrimaryContainer: Color(4294967295),
secondary: Color(4281873223),
onSecondary: Color(4294967295),
secondaryContainer: Color(4285557376),
onSecondaryContainer: Color(4294967295),
tertiary: Color(4283444025),
onTertiary: Color(4294967295),
tertiaryContainer: Color(4287455344),
onTertiaryContainer: Color(4294967295),
error: Color(4285792262),
onError: Color(4294967295),
errorContainer: Color(4291767335),
onErrorContainer: Color(4294967295),
surface: Color(4294834175),
onSurface: Color(4279373846),
onSurfaceVariant: Color(4281873725),
outline: Color(4283715930),
outlineVariant: Color(4285492085),
shadow: Color(4278190080),
scrim: Color(4278190080),
inverseSurface: Color(4281478965),
inversePrimary: Color(4291673599),
primaryFixed: Color(4285686943),
onPrimaryFixed: Color(4294967295),
primaryFixedDim: Color(4284042373),
onPrimaryFixedVariant: Color(4294967295),
secondaryFixed: Color(4285557376),
onSecondaryFixed: Color(4294967295),
secondaryFixedDim: Color(4283912807),
onSecondaryFixedVariant: Color(4294967295),
tertiaryFixed: Color(4287455344),
onTertiaryFixed: Color(4294967295),
tertiaryFixedDim: Color(4285679960),
onTertiaryFixedVariant: Color(4294967295),
surfaceDim: Color(4291478989),
surfaceBright: Color(4294834175),
surfaceContainerLowest: Color(4294967295),
surfaceContainerLow: Color(4294439674),
surfaceContainer: Color(4293715694),
surfaceContainerHigh: Color(4292926435),
surfaceContainerHighest: Color(4292202712),
);
}
ThemeData lightMediumContrast() {
return theme(lightMediumContrastScheme());
}
static ColorScheme lightHighContrastScheme() {
return const ColorScheme(
brightness: Brightness.light,
primary: Color(4281344857),
surfaceTint: Color(4284700303),
onPrimary: Color(4294967295),
primaryContainer: Color(4283252856),
onPrimaryContainer: Color(4294967295),
secondary: Color(4281215292),
onSecondary: Color(4294967295),
secondaryContainer: Color(4283123291),
onSecondaryContainer: Color(4294967295),
tertiary: Color(4282655023),
onTertiary: Color(4294967295),
tertiaryContainer: Color(4284824908),
onTertiaryContainer: Color(4294967295),
error: Color(4284481540),
onError: Color(4294967295),
errorContainer: Color(4288151562),
onErrorContainer: Color(4294967295),
surface: Color(4294834175),
onSurface: Color(4278190080),
onSurfaceVariant: Color(4278190080),
outline: Color(4281150259),
outlineVariant: Color(4283123793),
shadow: Color(4278190080),
scrim: Color(4278190080),
inverseSurface: Color(4281478965),
inversePrimary: Color(4291673599),
primaryFixed: Color(4283252856),
onPrimaryFixed: Color(4294967295),
primaryFixedDim: Color(4281739616),
onPrimaryFixedVariant: Color(4294967295),
secondaryFixed: Color(4283123291),
onSecondaryFixed: Color(4294967295),
secondaryFixedDim: Color(4281675843),
onSecondaryFixedVariant: Color(4294967295),
tertiaryFixed: Color(4284824908),
onTertiaryFixed: Color(4294967295),
tertiaryFixedDim: Color(4283180853),
onTertiaryFixedVariant: Color(4294967295),
surfaceDim: Color(4290557887),
surfaceBright: Color(4294834175),
surfaceContainerLowest: Color(4294967295),
surfaceContainerLow: Color(4294242295),
surfaceContainer: Color(4293321193),
surfaceContainerHigh: Color(4292400090),
surfaceContainerHighest: Color(4291478989),
);
}
ThemeData lightHighContrast() {
return theme(lightHighContrastScheme());
}
static ColorScheme darkScheme() {
return const ColorScheme(
brightness: Brightness.dark,
primary: Color(4291673599),
surfaceTint: Color(4291673599),
onPrimary: Color(4281608030),
primaryContainer: Color(4283121270),
onPrimaryContainer: Color(4293451519),
secondary: Color(4291544028),
onSecondary: Color(4281478721),
secondaryContainer: Color(4282991704),
onSecondaryContainer: Color(4293385976),
tertiary: Color(4293834953),
onTertiary: Color(4282983731),
tertiaryContainer: Color(4284693322),
onTertiaryContainer: Color(4294957540),
error: Color(4294948011),
onError: Color(4285071365),
errorContainer: Color(4287823882),
onErrorContainer: Color(4294957782),
surface: Color(4279505688),
onSurface: Color(4293321193),
onSurfaceVariant: Color(4291478735),
outline: Color(4287860633),
outlineVariant: Color(4282926414),
shadow: Color(4278190080),
scrim: Color(4278190080),
inverseSurface: Color(4293321193),
inversePrimary: Color(4284700303),
primaryFixed: Color(4293451519),
onPrimaryFixed: Color(4280225864),
primaryFixedDim: Color(4291673599),
onPrimaryFixedVariant: Color(4283121270),
secondaryFixed: Color(4293385976),
onSecondaryFixed: Color(4280097067),
secondaryFixedDim: Color(4291544028),
onSecondaryFixedVariant: Color(4282991704),
tertiaryFixed: Color(4294957540),
onTertiaryFixed: Color(4281405726),
tertiaryFixedDim: Color(4293834953),
onTertiaryFixedVariant: Color(4284693322),
surfaceDim: Color(4279505688),
surfaceBright: Color(4282005566),
surfaceContainerLowest: Color(4279176467),
surfaceContainerLow: Color(4280032032),
surfaceContainer: Color(4280295204),
surfaceContainerHigh: Color(4281018671),
surfaceContainerHighest: Color(4281742394),
);
}
ThemeData dark() {
return theme(darkScheme());
}
static ColorScheme darkMediumContrastScheme() {
return const ColorScheme(
brightness: Brightness.dark,
primary: Color(4293056255),
surfaceTint: Color(4291673599),
onPrimary: Color(4280884306),
primaryContainer: Color(4288055493),
onPrimaryContainer: Color(4278190080),
secondary: Color(4292991218),
onSecondary: Color(4280754998),
secondaryContainer: Color(4287925669),
onSecondaryContainer: Color(4278190080),
tertiary: Color(4294955230),
onTertiary: Color(4282194472),
tertiaryContainer: Color(4290020244),
onTertiaryContainer: Color(4278190080),
error: Color(4294955724),
onError: Color(4283695107),
errorContainer: Color(4294923337),
onErrorContainer: Color(4278190080),
surface: Color(4279505688),
onSurface: Color(4294967295),
onSurfaceVariant: Color(4292926181),
outline: Color(4290097339),
outlineVariant: Color(4287860377),
shadow: Color(4278190080),
scrim: Color(4278190080),
inverseSurface: Color(4293321193),
inversePrimary: Color(4283187063),
primaryFixed: Color(4293451519),
onPrimaryFixed: Color(4279501629),
primaryFixedDim: Color(4291673599),
onPrimaryFixedVariant: Color(4282002788),
secondaryFixed: Color(4293385976),
onSecondaryFixed: Color(4279438880),
secondaryFixedDim: Color(4291544028),
onSecondaryFixedVariant: Color(4281873223),
tertiaryFixed: Color(4294957540),
onTertiaryFixed: Color(4280550932),
tertiaryFixedDim: Color(4293834953),
onTertiaryFixedVariant: Color(4283444025),
surfaceDim: Color(4279505688),
surfaceBright: Color(4282794826),
surfaceContainerLowest: Color(4278716172),
surfaceContainerLow: Color(4280163618),
surfaceContainer: Color(4280887085),
surfaceContainerHigh: Color(4281610808),
surfaceContainerHighest: Color(4282334531),
);
}
ThemeData darkMediumContrast() {
return theme(darkMediumContrastScheme());
}
static ColorScheme darkHighContrastScheme() {
return const ColorScheme(
brightness: Brightness.dark,
primary: Color(4294241791),
surfaceTint: Color(4291673599),
onPrimary: Color(4278190080),
primaryContainer: Color(4291410427),
onPrimaryContainer: Color(4279107636),
secondary: Color(4294241791),
onSecondary: Color(4278190080),
secondaryContainer: Color(4291280856),
onSecondaryContainer: Color(4279044122),
tertiary: Color(4294962160),
onTertiary: Color(4278190080),
tertiaryContainer: Color(4293571782),
onTertiaryContainer: Color(4280091150),
error: Color(4294962409),
onError: Color(4278190080),
errorContainer: Color(4294946468),
onErrorContainer: Color(4280418305),
surface: Color(4279505688),
onSurface: Color(4294967295),
onSurfaceVariant: Color(4294967295),
outline: Color(4294242041),
outlineVariant: Color(4291215563),
shadow: Color(4278190080),
scrim: Color(4278190080),
inverseSurface: Color(4293321193),
inversePrimary: Color(4283187063),
primaryFixed: Color(4293451519),
onPrimaryFixed: Color(4278190080),
primaryFixedDim: Color(4291673599),
onPrimaryFixedVariant: Color(4279501629),
secondaryFixed: Color(4293385976),
onSecondaryFixed: Color(4278190080),
secondaryFixedDim: Color(4291544028),
onSecondaryFixedVariant: Color(4279438880),
tertiaryFixed: Color(4294957540),
onTertiaryFixed: Color(4278190080),
tertiaryFixedDim: Color(4293834953),
onTertiaryFixedVariant: Color(4280550932),
surfaceDim: Color(4279505688),
surfaceBright: Color(4283584341),
surfaceContainerLowest: Color(4278190080),
surfaceContainerLow: Color(4280295204),
surfaceContainer: Color(4281478965),
surfaceContainerHigh: Color(4282202689),
surfaceContainerHighest: Color(4282926668),
);
}
ThemeData darkHighContrast() {
return theme(darkHighContrastScheme());
}
ThemeData theme(ColorScheme colorScheme) => ThemeData(
useMaterial3: true,
brightness: colorScheme.brightness,
colorScheme: colorScheme,
textTheme: textTheme.apply(bodyColor: colorScheme.onSurface, displayColor: colorScheme.onSurface),
scaffoldBackgroundColor: colorScheme.surface,
canvasColor: colorScheme.surface,
pageTransitionsTheme: PageTransitionsTheme(
builders: Map<TargetPlatform, PageTransitionsBuilder>.fromIterable(
TargetPlatform.values,
value: (_) => const FadeForwardsPageTransitionsBuilder(),
),
),
);
List<ExtendedColor> get extendedColors => [];
}
class ExtendedColor {
final Color seed, value;
final ColorFamily light;
final ColorFamily lightHighContrast;
final ColorFamily lightMediumContrast;
final ColorFamily dark;
final ColorFamily darkHighContrast;
final ColorFamily darkMediumContrast;
const ExtendedColor({
required this.seed,
required this.value,
required this.light,
required this.lightHighContrast,
required this.lightMediumContrast,
required this.dark,
required this.darkHighContrast,
required this.darkMediumContrast,
});
}
class ColorFamily {
const ColorFamily({
required this.color,
required this.onColor,
required this.colorContainer,
required this.onColorContainer,
});
final Color color;
final Color onColor;
final Color colorContainer;
final Color onColorContainer;
}

View file

@ -5,6 +5,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:google_fonts/google_fonts.dart';
class Utils {
/// Minimum size (width or height) of a interactive component
@ -26,37 +27,32 @@ class Utils {
}
static Size textSize(String text, TextStyle style) {
final TextPainter textPainter =
TextPainter(text: TextSpan(text: text, style: style), maxLines: 1, textDirection: TextDirection.ltr)
..layout(minWidth: 0, maxWidth: double.infinity);
final TextPainter textPainter = TextPainter(
text: TextSpan(text: text, style: style),
maxLines: 1,
textDirection: TextDirection.ltr,
)..layout(minWidth: 0, maxWidth: double.infinity);
return textPainter.size;
}
static openPage(BuildContext context, WidgetBuilder pageToDisplayBuilder) {
Navigator.push(
context,
platformPageRoute(
context: context,
builder: pageToDisplayBuilder,
),
);
Navigator.push(context, platformPageRoute(context: context, builder: pageToDisplayBuilder));
}
static String itemCountFormat(int items, {singleSuffix = "item", multiSuffix = "items"}) {
static String itemCountFormat(int items, {String singleSuffix = "item", String multiSuffix = "items"}) {
if (items == 1) {
return items.toString() + " " + singleSuffix;
return "$items $singleSuffix";
}
return items.toString() + " " + multiSuffix;
return "$items $multiSuffix";
}
/// Builds a simple leading widget that pops the current screen.
/// Provide your own onPressed to override that behavior, just remember you have to pop
static Widget leadingBackWidget(BuildContext context, {label = 'Back', Function? onPressed}) {
if (Platform.isIOS) {
return CupertinoButton(
child: Row(children: <Widget>[Icon(context.platformIcons.back), Text(label)]),
padding: EdgeInsets.zero,
return CupertinoNavigationBarBackButton(
previousPageTitle: label,
onPressed: () {
if (onPressed == null) {
Navigator.pop(context);
@ -82,44 +78,48 @@ class Utils {
}
static Widget trailingSaveWidget(BuildContext context, Function onPressed) {
return CupertinoButton(
child: Text('Save',
style: TextStyle(
fontWeight: FontWeight.bold,
//TODO: For some reason on android if inherit is the default of true the text color here turns to the background color
inherit: Platform.isIOS ? true : false)),
padding: Platform.isAndroid ? null : EdgeInsets.zero,
onPressed: () => onPressed());
return PlatformTextButton(
padding: Platform.isAndroid ? null : EdgeInsets.zero,
onPressed: () => onPressed(),
child: Text('Save'),
);
}
/// Simple cross platform delete confirmation dialog - can also be used to confirm throwing away a change by swapping the deleteLabel
static confirmDelete(BuildContext context, String title, Function onConfirm,
{String deleteLabel = 'Delete', String cancelLabel = 'Cancel'}) {
static confirmDelete(
BuildContext context,
String title,
Function onConfirm, {
String deleteLabel = 'Delete',
String cancelLabel = 'Cancel',
}) {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) {
return PlatformAlertDialog(
title: Text(title),
actions: <Widget>[
PlatformDialogAction(
child: Text(deleteLabel,
style:
TextStyle(fontWeight: FontWeight.bold, color: CupertinoColors.systemRed.resolveFrom(context))),
onPressed: () {
Navigator.pop(context);
onConfirm();
},
context: context,
barrierDismissible: false,
builder: (context) {
return PlatformAlertDialog(
title: Text(title),
actions: <Widget>[
PlatformDialogAction(
child: Text(
deleteLabel,
style: TextStyle(fontWeight: FontWeight.bold, color: CupertinoColors.systemRed.resolveFrom(context)),
),
PlatformDialogAction(
child: Text(cancelLabel),
onPressed: () {
Navigator.of(context).pop();
},
)
],
);
});
onPressed: () {
Navigator.pop(context);
onConfirm();
},
),
PlatformDialogAction(
child: Text(cancelLabel),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
}
static popError(BuildContext context, String title, String error, {StackTrace? stack}) {
@ -128,33 +128,38 @@ class Utils {
}
showDialog(
context: context,
barrierDismissible: false,
builder: (context) {
if (Platform.isAndroid) {
return AlertDialog(title: Text(title), content: Text(error), actions: <Widget>[
context: context,
barrierDismissible: false,
builder: (context) {
if (Platform.isAndroid) {
return AlertDialog(
title: Text(title),
content: Text(error),
actions: <Widget>[
TextButton(
child: Text('Ok'),
onPressed: () {
Navigator.of(context).pop();
},
)
]);
}
return CupertinoAlertDialog(
title: Text(title),
content: Text(error),
actions: <Widget>[
CupertinoDialogAction(
child: Text('Ok'),
onPressed: () {
Navigator.of(context).pop();
},
)
),
],
);
});
}
return CupertinoAlertDialog(
title: Text(title),
content: Text(error),
actions: <Widget>[
CupertinoDialogAction(
child: Text('Ok'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
}
static launchUrl(String url, BuildContext context) async {
@ -180,4 +185,19 @@ class Utils {
final file = File(result.files.first.path!);
return file.readAsString();
}
static TextTheme createTextTheme(BuildContext context, String bodyFontString, String displayFontString) {
TextTheme baseTextTheme = Theme.of(context).textTheme;
TextTheme bodyTextTheme = GoogleFonts.getTextTheme(bodyFontString, baseTextTheme);
TextTheme displayTextTheme = GoogleFonts.getTextTheme(displayFontString, baseTextTheme);
TextTheme textTheme = displayTextTheme.copyWith(
bodyLarge: bodyTextTheme.bodyLarge,
bodyMedium: bodyTextTheme.bodyMedium,
bodySmall: bodyTextTheme.bodySmall,
labelLarge: bodyTextTheme.labelLarge,
labelMedium: bodyTextTheme.labelMedium,
labelSmall: bodyTextTheme.labelSmall,
);
return textTheme;
}
}

View file

@ -5,7 +5,7 @@ bool dnsValidator(str, {requireTld = true, allowUnderscore = false}) {
return false;
}
List parts = str.split('.');
List<String> parts = str.split('.');
if (requireTld) {
var tld = parts.removeLast();
if (parts.isEmpty || !RegExp(r'^[a-z]{2,}$').hasMatch(tld)) {

View file

@ -10,7 +10,7 @@ require (
github.com/DefinedNet/dnapi v0.0.0-20241212205635-1d1f0084d118
github.com/sirupsen/logrus v1.9.3
github.com/slackhq/nebula v1.9.5
golang.org/x/crypto v0.31.0
golang.org/x/crypto v0.32.0
gopkg.in/yaml.v2 v2.4.0
)
@ -39,15 +39,16 @@ require (
github.com/vishvananda/netlink v1.3.0 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
golang.org/x/mobile v0.0.0-20241213221354-a87c1cf6cf46 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/net v0.32.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/term v0.27.0 // indirect
golang.org/x/tools v0.28.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.28.0 // indirect
golang.org/x/tools v0.29.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
google.golang.org/protobuf v1.35.2 // indirect
)
replace github.com/slackhq/nebula => github.com/DefinedNet/nebula v1.7.0-pre5.0.20250507182031-48fbd1290b23

View file

@ -3,6 +3,8 @@ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/DefinedNet/dnapi v0.0.0-20241212205635-1d1f0084d118 h1:UvJ/1ox4SC2iVeARN1p6ZfzyqMIHMDcXyHG6VP+Amo8=
github.com/DefinedNet/dnapi v0.0.0-20241212205635-1d1f0084d118/go.mod h1:6LGHsBXaix2kyOPPrmGbzarKSedzR36P941h04+mkkM=
github.com/DefinedNet/nebula v1.7.0-pre5.0.20250507182031-48fbd1290b23 h1:KKTX303FpfcECshrkTVZbN5PpuvwE1zz2p3mEM7+fUo=
github.com/DefinedNet/nebula v1.7.0-pre5.0.20250507182031-48fbd1290b23/go.mod h1:1+4q4wd3dDAjO8rKCttSb9JIVbklQhuJiBp5I0lbIsQ=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
@ -131,8 +133,6 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/slackhq/nebula v1.9.5 h1:ZrxcvP/lxwFglaijmiwXLuCSkybZMJnqSYI1S8DtGnY=
github.com/slackhq/nebula v1.9.5/go.mod h1:1+4q4wd3dDAjO8rKCttSb9JIVbklQhuJiBp5I0lbIsQ=
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8=
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -154,13 +154,11 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20241213221354-a87c1cf6cf46 h1:E+R1qmJL8cmWTyWXBHVtmqRxr7FdiTwntffsba1F1Tg=
golang.org/x/mobile v0.0.0-20241213221354-a87c1cf6cf46/go.mod h1:Sf9LBimL0mWKEdgAjRmJ6iu7Z34osHQTK/devqFbM2I=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@ -176,8 +174,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -203,11 +201,11 @@ golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -216,8 +214,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View file

@ -21,42 +21,42 @@ packages:
dependency: transitive
description:
name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
url: "https://pub.dev"
source: hosted
version: "2.11.0"
version: "2.12.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.4.0"
clock:
dependency: transitive
description:
name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.0"
version: "1.19.1"
convert:
dependency: transitive
description:
@ -89,14 +89,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
dart_pubspec_licenses:
dependency: transitive
description:
name: dart_pubspec_licenses
sha256: "23ddb78ff9204d08e3109ced67cd3c6c6a066f581b0edf5ee092fc3e1127f4ea"
url: "https://pub.dev"
source: hosted
version: "3.0.4"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
version: "1.3.2"
ffi:
dependency: transitive
description:
@ -134,6 +142,22 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_oss_licenses:
dependency: "direct dev"
description:
name: flutter_oss_licenses
sha256: e4bbaeb00bc768e8430ee0c95ad304d2f256fb15194d30b912cea269871c8885
url: "https://pub.dev"
source: hosted
version: "3.0.4"
flutter_platform_widgets:
dependency: "direct main"
description:
@ -164,7 +188,7 @@ packages:
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
@ -176,6 +200,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82
url: "https://pub.dev"
source: hosted
version: "6.2.1"
http:
dependency: transitive
description:
@ -208,22 +240,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.19.0"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
url: "https://pub.dev"
source: hosted
version: "10.0.7"
version: "10.0.8"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://pub.dev"
source: hosted
version: "3.0.8"
version: "3.0.9"
leak_tracker_testing:
dependency: transitive
description:
@ -232,14 +272,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.1"
lints:
dependency: transitive
description:
name: lints
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
url: "https://pub.dev"
source: hosted
version: "5.1.1"
matcher:
dependency: transitive
description:
name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.16+1"
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
@ -252,10 +300,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.15.0"
version: "1.16.0"
mime:
dependency: transitive
description:
@ -289,13 +337,13 @@ packages:
source: hosted
version: "3.0.2"
path:
dependency: transitive
dependency: "direct main"
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
version: "1.9.1"
path_parsing:
dependency: transitive
description:
@ -449,10 +497,10 @@ packages:
dependency: transitive
description:
name: source_span
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev"
source: hosted
version: "1.10.0"
version: "1.10.1"
sprintf:
dependency: transitive
description:
@ -465,26 +513,26 @@ packages:
dependency: transitive
description:
name: stack_trace
sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.0"
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.4.1"
system_info2:
dependency: transitive
description:
@ -497,18 +545,18 @@ packages:
dependency: transitive
description:
name: term_glyph
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://pub.dev"
source: hosted
version: "0.7.3"
version: "0.7.4"
typed_data:
dependency: transitive
description:
@ -569,18 +617,18 @@ packages:
dependency: transitive
description:
name: url_launcher_web
sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e"
sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9"
url: "https://pub.dev"
source: hosted
version: "2.3.3"
version: "2.4.0"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4"
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
version: "3.1.4"
uuid:
dependency: "direct main"
description:
@ -625,10 +673,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
url: "https://pub.dev"
source: hosted
version: "14.3.0"
version: "14.3.1"
web:
dependency: transitive
description:
@ -670,5 +718,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.5.1 <4.0.0"
flutter: ">=3.24.0"
dart: ">=3.7.0 <4.0.0"
flutter: ">=3.27.0"

View file

@ -14,11 +14,13 @@ description: Mobile Nebula Client
version: 0.1.0+54
environment:
sdk: ^3.5.1
sdk: ^3.7.0
dependencies:
flutter:
sdk: flutter
flutter_web_plugins:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
@ -26,6 +28,7 @@ dependencies:
flutter_platform_widgets: ^7.0.1
path_provider: ^2.0.11
file_picker: ^8.1.2
google_fonts: ^6.2.1
uuid: ^4.4.2
package_info_plus: ^8.0.2
url_launcher: ^6.1.6
@ -34,20 +37,21 @@ dependencies:
intl: ^0.19.0
share_plus: ^10.0.2
sentry_flutter: ^8.9.0
sentry_dart_plugin: ^2.0.0
sentry_dart_plugin: ^2.0.0
mobile_scanner: ^7.0.0-beta.3
path: ^1.9.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_oss_licenses: ^3.0.4
flutter_lints: ^5.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.

50
swift-format.sh Executable file
View file

@ -0,0 +1,50 @@
#!/bin/bash
##===----------------------------------------------------------------------===##
##
## This source file is part of the Swift.org open source project
##
## Copyright (c) 2024 Apple Inc. and the Swift project authors
## Licensed under Apache License v2.0 with Runtime Library Exception
##
## See https://swift.org/LICENSE.txt for license information
## See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
##
##===----------------------------------------------------------------------===##
# Vendored from <https://github.com/swiftlang/github-workflows/blob/main/.github/workflows/scripts/check-swift-format.sh> while <https://github.com/swiftlang/swift-format/issues/870> is open.
# This file has been modified to only check formatting, with no linting, and to require a `check` command flag to fail when formatting was performed.
set -euo pipefail
log() { printf -- "** %s\n" "$*" >&2; }
error() { printf -- "** ERROR: %s\n" "$*" >&2; }
fatal() { error "$@"; exit 1; }
if [[ -f .swiftformatignore ]]; then
log "Found swiftformatignore file..."
log "Running swift format format..."
tr '\n' '\0' < .swiftformatignore| xargs -0 -I% printf '":(exclude)%" '| xargs git ls-files -z '*.swift' | xargs -0 xcrun swift-format --parallel --recursive --in-place
# log "Running swift format lint..."
# tr '\n' '\0' < .swiftformatignore | xargs -0 -I% printf '":(exclude)%" '| xargs git ls-files -z '*.swift' | xargs -0 swift format lint --strict --parallel
else
log "Running swift format format..."
git ls-files -z '*.swift' | xargs -0 xcrun swift-format --parallel --recursive --in-place
# log "Running swift format lint..."
# git ls-files -z '*.swift' | xargs -0 swift format lint --strict --parallel
fi
if [ "${1-default}" = "check" ]; then
log "Checking for modified files..."
GIT_PAGER='' git diff --exit-code '*.swift'
log "✅ Found no formatting issues."
fi