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 # Big flutter format run
9934f226e3e79c3567ce07dbab9e9f6443e7afc5 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 runs-on: ubuntu-latest
steps: steps:
- name: Check out code into the Go module directory - name: Check out code into the Go module directory
uses: actions/checkout@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #4.2.2
with: with:
show-progress: false show-progress: false
- name: Set up Go 1.22 - name: Set up Go 1.22
uses: actions/setup-go@v5 uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 #5.3.0
with: with:
go-version: "1.22" go-version: '1.22'
cache-dependency-path: nebula/go.sum cache-dependency-path: nebula/go.sum
- name: Install goimports - name: Install goimports

View file

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

View file

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

1
.swiftformatignore Normal file
View file

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

View file

@ -6,9 +6,9 @@
Install all of the following things: 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) - [`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) - [`gomobile`](https://pkg.go.dev/golang.org/x/mobile/cmd/gomobile)
- [Flutter Android Studio Extension](https://docs.flutter.dev/get-started/editor?tab=androidstudio) - [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 - Copy `env.sh.example` and set it up for your machine
- Ensure you have run `gomobile init` - 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 - 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 `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` - 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. 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 # Release
@ -56,4 +59,4 @@ Upload the android bundle to the google play store https://play.google.com/apps/
## iOS ## iOS
In xcode, Release -> Archive then follow the directions to upload to the app store. If you have issues, https://flutter.dev/docs/deployment/ios#create-a-build-archive In xcode, Release -> Archive then follow the directions to upload to the app store. If you have issues, https://flutter.dev/docs/deployment/ios#create-a-build-archive

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

View file

@ -36,6 +36,10 @@ class MainActivity: FlutterActivity() {
private var apiClient: APIClient? = null private var apiClient: APIClient? = null
private var sites: Sites? = 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 // When starting a site we may need to request VPN permissions. These variables help us
// maintain state while waiting for a permission result. // maintain state while waiting for a permission result.
private var startResult: MethodChannel.Result? = null private var startResult: MethodChannel.Result? = null
@ -440,6 +444,7 @@ class MainActivity: FlutterActivity() {
private val connection = object : ServiceConnection { private val connection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) { override fun onServiceConnected(className: ComponentName, service: IBinder) {
outMessenger = Messenger(service) outMessenger = Messenger(service)
isServiceBound = true
// We want to monitor the service for as long as we are connected to it. // We want to monitor the service for as long as we are connected to it.
try { try {
@ -461,6 +466,7 @@ class MainActivity: FlutterActivity() {
override fun onServiceDisconnected(arg0: ComponentName) { override fun onServiceDisconnected(arg0: ComponentName) {
outMessenger = null outMessenger = null
isServiceBound = false
if (activeSiteId != null) { if (activeSiteId != null) {
//TODO: this indicates the service died, notify that it is disconnected //TODO: this indicates the service died, notify that it is disconnected
} }
@ -510,7 +516,14 @@ class MainActivity: FlutterActivity() {
msg.replyTo = inMessenger msg.replyTo = inMessenger
outMessenger!!.send(msg) outMessenger!!.send(msg)
// Unbind // Unbind
unbindService(connection) if (isServiceBound) {
try {
unbindService(connection)
isServiceBound = false
} catch (e: IllegalArgumentException) {
Log.e(TAG, e.toString())
}
}
} }
outMessenger = null outMessenger = null
} }

View file

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

View file

@ -19,7 +19,7 @@ pluginManagement {
plugins { plugins {
id "org.gradle.toolchains.foojay-resolver-convention" version "0.8.0" id "org.gradle.toolchains.foojay-resolver-convention" version "0.8.0"
id "dev.flutter.flutter-plugin-loader" version "1.0.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 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 # 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 #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 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" let groupName = "group.net.defined.mobileNebula"
class KeyChain { class KeyChain {
class func save(key: String, data: Data, managed: Bool) -> Bool { class func save(key: String, data: Data, managed: Bool) -> Bool {
var query: [String: Any] = [ var query: [String: Any] = [
kSecClass as String : kSecClassGenericPassword as String, kSecClass as String: kSecClassGenericPassword as String,
kSecAttrAccount as String : key, kSecAttrAccount as String: key,
kSecValueData as String : data, kSecValueData as String: data,
kSecAttrAccessGroup as String: groupName, kSecAttrAccessGroup as String: groupName,
] ]
if (managed) {
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
}
// Attempt to delete an existing key to allow for an overwrite if managed {
_ = self.delete(key: key) query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
return SecItemAdd(query as CFDictionary, nil) == 0
} }
class func load(key: String) -> Data? { // Attempt to delete an existing key to allow for an overwrite
let query: [String: Any] = [ _ = self.delete(key: key)
kSecClass as String : kSecClassGenericPassword, return SecItemAdd(query as CFDictionary, nil) == 0
kSecAttrAccount as String : key, }
kSecReturnData as String : kCFBooleanTrue!,
kSecMatchLimit as String : kSecMatchLimitOne,
kSecAttrAccessGroup as String: groupName,
]
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 { let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
return dataTypeRef as! Data?
} else { if status == noErr {
return nil 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 { extension Data {
init<T>(from value: T) { init<T>(from value: T) {
var value = value var value = value
var data = Data() var data = Data()
withUnsafePointer(to: &value, { (ptr: UnsafePointer<T>) -> Void in withUnsafePointer(
data = Data(buffer: UnsafeBufferPointer(start: ptr, count: 1)) to: &value,
}) { (ptr: UnsafePointer<T>) -> Void in
self.init(data) data = Data(buffer: UnsafeBufferPointer(start: ptr, count: 1))
} })
self.init(data)
}
func to<T>(type: T.Type) -> T { func to<T>(type: T.Type) -> T {
return self.withUnsafeBytes { $0.load(as: T.self) } return self.withUnsafeBytes { $0.load(as: T.self) }
} }
} }

View file

@ -1,303 +1,323 @@
import NetworkExtension
import MobileNebula import MobileNebula
import os.log import NetworkExtension
import SwiftyJSON import SwiftyJSON
import os.log
enum VPNStartError: Error { enum VPNStartError: Error {
case noManagers case noManagers
case couldNotFindManager case couldNotFindManager
case noTunFileDescriptor case noTunFileDescriptor
case noProviderConfig case noProviderConfig
} }
enum AppMessageError: Error { enum AppMessageError: Error {
case unknownIPCType(command: String) case unknownIPCType(command: String)
} }
extension AppMessageError: LocalizedError { extension AppMessageError: LocalizedError {
public var description: String? { public var description: String? {
switch self { switch self {
case .unknownIPCType(let command): case .unknownIPCType(let command):
return NSLocalizedString("Unknown IPC message type \(String(command))", comment: "") return NSLocalizedString("Unknown IPC message type \(String(command))", comment: "")
}
} }
}
} }
class PacketTunnelProvider: NEPacketTunnelProvider { class PacketTunnelProvider: NEPacketTunnelProvider {
private var networkMonitor: NWPathMonitor? private var networkMonitor: NWPathMonitor?
private var site: Site? private var site: Site?
private let log = Logger(subsystem: "net.defined.mobileNebula", category: "PacketTunnelProvider") private let log = Logger(subsystem: "net.defined.mobileNebula", category: "PacketTunnelProvider")
private var nebula: MobileNebulaNebula? private var nebula: MobileNebulaNebula?
private var dnUpdater = DNUpdater() private var dnUpdater = DNUpdater()
private var didSleep = false private var didSleep = false
private var cachedRouteDescription: String? private var cachedRouteDescription: String?
override func startTunnel(options: [String : NSObject]? = nil) async throws { 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 // There is currently no way to get initialization errors back to the UI via completionHandler here
// `expectStart` is sent only via the UI which means we should wait for the real start command which has another completion handler the UI can intercept // `expectStart` is sent only via the UI which means we should wait for the real start command which has another completion handler the UI can intercept
if options?["expectStart"] != nil { if options?["expectStart"] != nil {
// startTunnel must complete before IPC will work // startTunnel must complete before IPC will work
return 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 func start() async throws { // 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
var manager: NETunnelProviderManager? // success/fail by the presence of an error or nil
var config: Data try await start()
var key: String }
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() manager = try await self.findManager()
guard let foundManager = manager else { guard let foundManager = manager else {
throw VPNStartError.couldNotFindManager 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
} }
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! let _site = self.site!
key = try _site.getKey() key = try _site.getKey()
guard let fileDescriptor = self.tunnelFileDescriptor else {
throw VPNStartError.noTunFileDescriptor
}
let tunFD = Int(fileDescriptor)
// This is set to 127.0.0.1 because it has to be something.. guard let fileDescriptor = self.tunnelFileDescriptor else {
let tunnelNetworkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") throw VPNStartError.noTunFileDescriptor
}
let tunFD = Int(fileDescriptor)
// Make sure our ip is routed to the tun device // This is set to 127.0.0.1 because it has to be something..
var err: NSError? let tunnelNetworkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1")
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)]
// Add our unsafe routes // Make sure our ip is routed to the tun device
try _site.unsafeRoutes.forEach { unsafeRoute in var err: NSError?
let ipNet = MobileNebulaParseCIDR(unsafeRoute.route, &err) let ipNet = MobileNebulaParseCIDR(_site.cert!.cert.details.ips[0], &err)
if (err != nil) { if err != nil {
throw err! throw err!
} }
routes.append(NEIPv4Route(destinationAddress: ipNet!.network, subnetMask: ipNet!.maskCIDR)) tunnelNetworkSettings.ipv4Settings = NEIPv4Settings(
} addresses: [ipNet!.ip], subnetMasks: [ipNet!.maskCIDR])
var routes: [NEIPv4Route] = [
NEIPv4Route(destinationAddress: ipNet!.network, subnetMask: ipNet!.maskCIDR)
]
tunnelNetworkSettings.ipv4Settings!.includedRoutes = routes // Add our unsafe routes
tunnelNetworkSettings.mtu = _site.mtu as NSNumber 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) tunnelNetworkSettings.ipv4Settings!.includedRoutes = routes
var nebulaErr: NSError? tunnelNetworkSettings.mtu = _site.mtu as NSNumber
self.nebula = MobileNebulaNewNebula(String(data: config, encoding: .utf8), key, self.site!.logFile, tunFD, &nebulaErr)
self.startNetworkMonitor()
if nebulaErr != nil { try await self.setTunnelNetworkSettings(tunnelNetworkSettings)
self.log.error("We had an error starting up: \(nebulaErr, privacy: .public)") var nebulaErr: NSError?
throw nebulaErr! 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)
} }
return try? JSONEncoder().encode(
self.nebula!.start() IPCResponse.init(type: .error, message: JSON(error.localizedDescription)))
self.dnUpdater.updateSingleLoop(site: self.site!, onUpdate: self.handleDNUpdate) }
} }
private func handleDNUpdate(newSite: Site) { if nebula == nil {
do { // Respond with an empty success message in the event a command comes in before we've truly started
self.site = newSite log.warning("Received command but do not have a nebula instance")
try self.nebula?.reload(String(data: newSite.getConfig(), encoding: .utf8), key: newSite.getKey()) return try? JSONEncoder().encode(IPCResponse.init(type: .success, message: nil))
}
} catch {
log.error("Got an error while updating nebula \(error.localizedDescription, privacy: .public)") //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)
} }
} }
if ret != 0 || addr.sc_family != AF_SYSTEM {
//TODO: Sleep/wake get called aggressively and do nothing to help us here, we should locate why that is and make these work appropriately continue
// override func sleep(completionHandler: @escaping () -> Void) { }
// nebula!.sleep() if ctlInfo.ctl_id == 0 {
// completionHandler() ret = ioctl(fd, CTLIOCGINFO, &ctlInfo)
// } if ret != 0 {
continue
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 }
if addr.sc_id == ctlInfo.ctl_id {
// Load vpn configs from system, and find the manager matching the one being started return fd
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
} }
return nil
}
} }

View file

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

View file

@ -1,140 +1,148 @@
import NetworkExtension import NetworkExtension
class SiteList { class SiteList {
private var sites = [String: Site]() 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 /// 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 { static func getRootDir() throws -> URL {
let fileManager = FileManager.default let fileManager = FileManager.default
let rootDir = fileManager.containerURL(forSecurityApplicationGroupIdentifier: "group.net.defined.mobileNebula")! let rootDir = fileManager.containerURL(
forSecurityApplicationGroupIdentifier: "group.net.defined.mobileNebula")!
if (!fileManager.fileExists(atPath: rootDir.absoluteString)) {
try fileManager.createDirectory(at: rootDir, withIntermediateDirectories: true) 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!
} }
completion(sites, err)
return rootDir }
} #else
SiteList.loadAllFromNETPM { sites, err in
/// Gets the directory where all sites live, $rootDir/sites. Does ensure the directory exists if sites != nil {
static func getSitesDir() throws -> URL { self.sites = sites!
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 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 siteDirs.forEach { path in
static func getSiteDir(id: String, create: Bool = false) throws -> URL { do {
let fileManager = FileManager.default let site = try Site(
let siteDir = try getSitesDir().appendingPathComponent(id, isDirectory: true) path: path.appendingPathComponent("config").appendingPathExtension("json"))
if (create && !fileManager.fileExists(atPath: siteDir.absoluteString)) { sites[site.id] = site
try fileManager.createDirectory(at: siteDir, withIntermediateDirectories: true)
} } catch {
return siteDir 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 completion(sites, nil)
static func getSiteConfigFile(id: String, createDir: Bool) throws -> URL { }
return try getSiteDir(id: id, create: createDir).appendingPathComponent("config", isDirectory: false).appendingPathExtension("json")
} private static func loadAllFromNETPM(
completion: @escaping ([String: Site]?, (any Error)?) -> Void
/// Gets the file that represents the site log output, $rootDir/sites/$siteID/log ) {
static func getSiteLogFile(id: String, createDir: Bool) throws -> URL { var sites = [String: Site]()
return try getSiteDir(id: id, create: createDir).appendingPathComponent("logs", isDirectory: false)
} // 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
init(completion: @escaping ([String: Site]?, Error?) -> ()) { let dispatchGroup = DispatchGroup()
#if targetEnvironment(simulator)
SiteList.loadAllFromFS { sites, err in NETunnelProviderManager.loadAllFromPreferences { newManagers, err in
if sites != nil { if err != nil {
self.sites = sites! return completion(nil, err)
} }
completion(sites, err)
} newManagers?.forEach { manager in
#else
SiteList.loadAllFromNETPM { sites, err in
if sites != nil {
self.sites = sites!
}
completion(sites, err)
}
#endif
}
private static func loadAllFromFS(completion: @escaping ([String: Site]?, Error?) -> ()) {
let fileManager = FileManager.default
var siteDirs: [URL]
var sites = [String: Site]()
do { 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 { } catch {
completion(nil, error) //TODO: notify the user about this
return 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 { dispatchGroup.notify(queue: .main) {
let site = try Site(path: path.appendingPathComponent("config").appendingPathExtension("json"))
sites[site.id] = site
} catch {
print(error)
try? fileManager.removeItem(at: path)
print("Deleted non conforming site \(path)")
}
}
completion(sites, nil) completion(sites, nil)
}
} }
}
private static func loadAllFromNETPM(completion: @escaping ([String: Site]?, Error?) -> ()) {
var sites = [String: Site]() func getSites() -> [String: Site] {
return sites
// dispatchGroup is used to ensure we have migrated all sites before returning them }
// If there are no sites to migrate, there are never any entrants
let dispatchGroup = DispatchGroup()
NETunnelProviderManager.loadAllFromPreferences() { newManagers, err in
if (err != nil) {
return completion(nil, err)
}
newManagers?.forEach { manager in
do {
let site = try Site(manager: manager)
if site.needsToMigrateToFS {
dispatchGroup.enter()
site.incomingSite?.save(manager: manager) { error in
if error != nil {
print("Error while migrating site to fs: \(error!.localizedDescription)")
}
print("Migrated site to fs: \(site.name)")
site.needsToMigrateToFS = false
dispatchGroup.leave()
}
}
sites[site.id] = site
} catch {
//TODO: notify the user about this
print("Deleted non conforming site \(manager) \(error)")
manager.removeFromPreferences()
//TODO: delete from disk, we need to try and discover the site id though
}
}
dispatchGroup.notify(queue: .main) {
completion(sites, nil)
}
}
}
func getSites() -> [String: Site] {
return sites
}
} }

View file

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

View file

@ -563,6 +563,18 @@
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = 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"; TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };
@ -772,6 +784,18 @@
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; 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"; TARGETED_DEVICE_FAMILY = "1,2";
}; };
name = Debug; name = Debug;
@ -825,6 +849,18 @@
SUPPORTED_PLATFORMS = iphoneos; SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; 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"; TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };

View file

@ -1,54 +1,57 @@
import MobileNebula import MobileNebula
enum APIClientError: Error { enum APIClientError: Error {
case invalidCredentials case invalidCredentials
} }
class APIClient { class APIClient {
let apiClient: MobileNebulaAPIClient let apiClient: MobileNebulaAPIClient
let json = JSONDecoder() let json = JSONDecoder()
init() { init() {
let packageInfo = PackageInfo() let packageInfo = PackageInfo()
apiClient = MobileNebulaNewAPIClient("MobileNebula/\(packageInfo.getVersion()) (iOS \(packageInfo.getSystemVersion()))")! 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 { if res.fetchedUpdate {
let res = try apiClient.enroll(code) return try decodeIncomingSite(jsonSite: res.site)
return try decodeIncomingSite(jsonSite: res.site)
} }
func tryUpdate(siteName: String, hostID: String, privateKey: String, counter: Int, trustedKeys: String) throws -> IncomingSite? { return nil
let res: MobileNebulaTryUpdateResult }
do {
res = try apiClient.tryUpdate( private func decodeIncomingSite(jsonSite: String) throws -> IncomingSite {
siteName, do {
hostID: hostID, return try json.decode(IncomingSite.self, from: jsonSite.data(using: .utf8)!)
privateKey: privateKey, } catch {
counter: counter, print("decodeIncomingSite: \(error)")
trustedKeys: trustedKeys) throw error
} catch {
// type information from Go is not available, use string matching instead
if (error.localizedDescription == "invalid credentials") {
throw APIClientError.invalidCredentials
}
throw error
}
if (res.fetchedUpdate) {
return try decodeIncomingSite(jsonSite: res.site)
}
return nil
}
private func decodeIncomingSite(jsonSite: String) throws -> IncomingSite {
do {
return try json.decode(IncomingSite.self, from: jsonSite.data(using: .utf8)!)
} catch {
print("decodeIncomingSite: \(error)")
throw error
}
} }
}
} }

View file

@ -1,297 +1,334 @@
import UIKit
import Flutter import Flutter
import MobileNebula import MobileNebula
import NetworkExtension import NetworkExtension
import SwiftyJSON import SwiftyJSON
import UIKit
enum ChannelName { enum ChannelName {
static let vpn = "net.defined.mobileNebula/NebulaVpnService" static let vpn = "net.defined.mobileNebula/NebulaVpnService"
} }
func MissingArgumentError(message: String, details: Any?) -> FlutterError { 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 @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
private let dnUpdater = DNUpdater() private let dnUpdater = DNUpdater()
private let apiClient = APIClient() private let apiClient = APIClient()
private var sites: Sites? private var sites: Sites?
private var ui: FlutterMethodChannel? 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))
}
let encoder = JSONEncoder() override func application(
let data = try! encoder.encode(sites) _ application: UIApplication,
let ret = String(data: data, encoding: .utf8) didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
result(ret) ) -> Bool {
} GeneratedPluginRegistrant.register(with: self)
}
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 dnUpdater.updateAllLoop { site in
result(nil) // 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
func startSite(call: FlutterMethodCall, result: @escaping FlutterResult) { container!.updater.update(connected: site.connected ?? false, replaceSite: site)
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
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 //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 { do {
try session.sendProviderMessage(try JSONEncoder().encode(IPCRequest(command: command, arguments: JSON(args)))) { data in container?.updater.startFunc = { () -> Void in
if data == nil { return self.vpnRequest(command: "start", arguments: args, result: result)
return result(nil) }
} try manager?.connection.startVPNTunnel(options: ["expectStart": NSNumber(1)])
//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 { } 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 { func MissingArgumentError(message: String, details: (any Error)? = nil) -> FlutterError {
return FlutterError(code: "missingArgument", message: message, details: details) 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 { func NoArgumentsError(
return FlutterError(code: "noArguments", message: message, details: details) 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 { 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 import os.log
class DNUpdater { class DNUpdater {
private let apiClient = APIClient() private let apiClient = APIClient()
private let timer = RepeatingTimer(timeInterval: 15 * 60) // 15 * 60 is 15 minutes private let timer = RepeatingTimer(timeInterval: 15 * 60) // 15 * 60 is 15 minutes
private let log = Logger(subsystem: "net.defined.mobileNebula", category: "DNUpdater") 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
}
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 = { func updateAllLoop(onUpdate: @escaping (Site) -> Void) {
self.updateAll(onUpdate: onUpdate) 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()
} return
}
func updateSingleLoop(site: Site, onUpdate: @escaping (Site) -> ()) {
timer.eventHandler = { let siteManager = site.manager
self.updateSite(site: site, onUpdate: onUpdate) let shouldSaveToManager =
} siteManager != nil
timer.resume() || ProcessInfo().isOperatingSystemAtLeast(
} OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0))
func updateSite(site: Site, onUpdate: @escaping (Site) -> ()) { newSite?.save(manager: site.manager, saveToManager: shouldSaveToManager) { error in
do { if error != nil {
if (!site.managed) { self.log.error("failed to save update: \(error!.localizedDescription, privacy: .public)")
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)")
} }
// 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 // From https://medium.com/over-engineering/a-background-repeating-timer-in-swift-412cecfd2ef9
class RepeatingTimer { class RepeatingTimer {
let timeInterval: TimeInterval let timeInterval: TimeInterval
init(timeInterval: TimeInterval) { init(timeInterval: TimeInterval) {
self.timeInterval = timeInterval self.timeInterval = timeInterval
} }
private lazy var timer: DispatchSourceTimer = { private lazy var timer: any DispatchSourceTimer = {
let t = DispatchSource.makeTimerSource() let t = DispatchSource.makeTimerSource()
t.schedule(deadline: .now(), repeating: self.timeInterval) t.schedule(deadline: .now(), repeating: self.timeInterval)
t.setEventHandler(handler: { [weak self] in t.setEventHandler(handler: { [weak self] in
self?.eventHandler?() self?.eventHandler?()
}) })
return t return t
}() }()
var eventHandler: (() -> Void)? var eventHandler: (() -> Void)?
private enum State { private enum State {
case suspended case suspended
case resumed case resumed
} }
private var state: State = .suspended private var state: State = .suspended
deinit { deinit {
timer.setEventHandler {} timer.setEventHandler {}
timer.cancel() timer.cancel()
/* /*
If the timer is suspended, calling cancel without resuming If the timer is suspended, calling cancel without resuming
triggers a crash. This is documented here https://forums.developer.apple.com/thread/15902 triggers a crash. This is documented here https://forums.developer.apple.com/thread/15902
*/ */
resume() resume()
eventHandler = nil eventHandler = nil
} }
func resume() { func resume() {
if state == .resumed { if state == .resumed {
return return
}
state = .resumed
timer.resume()
} }
state = .resumed
timer.resume()
}
func suspend() { func suspend() {
if state == .suspended { if state == .suspended {
return return
}
state = .suspended
timer.suspend()
} }
state = .suspended
timer.suspend()
}
} }

View file

@ -1,26 +1,24 @@
import Foundation import Foundation
class PackageInfo { class PackageInfo {
func getVersion() -> String { func getVersion() -> String {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
"unknown" let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String
let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String
if buildNumber == nil {
if (buildNumber == nil) { return version
return version
}
return "\(version)-\(buildNumber!)"
} }
func getName() -> String { return "\(version)-\(buildNumber!)"
return Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? }
Bundle.main.infoDictionary?["CFBundleName"] as? String ??
"Nebula" 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)" 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 MobileNebula
import NetworkExtension
class SiteContainer { class SiteContainer {
var site: Site var site: Site
var updater: SiteUpdater var updater: SiteUpdater
init(site: Site, updater: SiteUpdater) { init(site: Site, updater: SiteUpdater) {
self.site = site self.site = site
self.updater = updater self.updater = updater
} }
} }
class Sites { class Sites {
private var containers = [String: SiteContainer]() private var containers = [String: SiteContainer]()
private var messenger: FlutterBinaryMessenger? private var messenger: (any FlutterBinaryMessenger)?
init(messenger: FlutterBinaryMessenger?) { init(messenger: (any FlutterBinaryMessenger)?) {
self.messenger = messenger 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?) -> ()) { // Nothing to remove
_ = SiteList { (sites, err) in callback(nil)
if (err != nil) { }
return completion(nil, err)
} func getSite(id: String) -> Site? {
return self.containers[id]?.site
sites?.values.forEach{ site in }
var updater = self.containers[site.id]?.updater
if (updater != nil) { func getUpdater(id: String) -> SiteUpdater? {
updater!.setSite(site: site) return self.containers[id]?.updater
} else { }
updater = SiteUpdater(messenger: self.messenger!, site: site)
} func getContainer(id: String) -> SiteContainer? {
self.containers[site.id] = SiteContainer(site: site, updater: updater!) return self.containers[id]
} }
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]
}
} }
class SiteUpdater: NSObject, FlutterStreamHandler { class SiteUpdater: NSObject, FlutterStreamHandler {
private var eventSink: FlutterEventSink?; private var eventSink: FlutterEventSink?
private var eventChannel: FlutterEventChannel; private var eventChannel: FlutterEventChannel
private var site: Site private var site: Site
private var notification: Any? private var notification: Any?
public var startFunc: (() -> Void)? public var startFunc: (() -> Void)?
private var configFd: Int32? = nil private var configFd: Int32? = nil
private var configObserver: DispatchSourceFileSystemObject? = nil private var configObserver: (any DispatchSourceFileSystemObject)? = nil
init(messenger: FlutterBinaryMessenger, site: Site) { init(messenger: any FlutterBinaryMessenger, site: Site) {
do { do {
let configPath = try SiteList.getSiteConfigFile(id: site.id, createDir: false) let configPath = try SiteList.getSiteConfigFile(id: site.id, createDir: false)
self.configFd = open(configPath.path, O_EVTONLY) self.configFd = open(configPath.path, O_EVTONLY)
self.configObserver = DispatchSource.makeFileSystemObjectSource( self.configObserver = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: self.configFd!, fileDescriptor: self.configFd!,
eventMask: .write eventMask: .write
) )
} catch { } catch {
// SiteList.getSiteConfigFile should never throw because we are not creating it here // SiteList.getSiteConfigFile should never throw because we are not creating it here
self.configObserver = nil 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
} }
/// onListen is called when flutter code attaches an event listener eventChannel = FlutterEventChannel(
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { name: "net.defined.nebula/\(site.id)", binaryMessenger: messenger)
eventSink = events; self.site = site
super.init()
#if !targetEnvironment(simulator)
if site.manager == nil { eventChannel.setStreamHandler(self)
//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. self.configObserver?.setEventHandler(handler: self.configUpdated)
return FlutterError(code: "Internal Error", message: "Flutter manager was not present", details: nil) self.configObserver?.setCancelHandler {
} if self.configFd != nil {
close(self.configFd!)
self.notification = NotificationCenter.default.addObserver(forName: NSNotification.Name.NEVPNStatusDidChange, object: site.manager!.connection , queue: nil) { n in }
let oldConnected = self.site.connected self.configObserver = nil
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
} }
/// onCancel is called when the flutter listener stops listening self.configObserver?.resume()
func onCancel(withArguments arguments: Any?) -> FlutterError? { }
if (self.notification != nil) {
NotificationCenter.default.removeObserver(self.notification!) 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!)
} }
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) { /// update is a way to send information to the flutter listener and generally should not be used directly
site = replaceSite! func update(connected: Bool, replaceSite: Site? = nil) {
} if replaceSite != nil {
site.connected = connected site = replaceSite!
site.status = connected ? "Connected" : "Disconnected"
let encoder = JSONEncoder()
let data = try! encoder.encode(site)
self.eventSink?(String(data: data, encoding: .utf8))
} }
site.connected = connected
private func configUpdated() { site.status = connected ? "Connected" : "Disconnected"
if self.site.connected != true {
return let encoder = JSONEncoder()
} let data = try! encoder.encode(site)
self.eventSink?(String(data: data, encoding: .utf8))
guard let newSite = try? Site(manager: self.site.manager!) else { }
return
} private func configUpdated() {
if self.site.connected != true {
self.update(connected: newSite.connected ?? false, replaceSite: newSite) 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 platform :ios do
desc "Push a new beta build to TestFlight" desc "Push a new beta build to TestFlight"
before_all do
xcode_select("/Applications/Xcode_16.2.0.app")
end
lane :build do lane :build do
# Do some things like setting up a temporary keystore to host secrets in CI # Do some things like setting up a temporary keystore to host secrets in CI
setup_ci setup_ci

View file

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

View file

@ -6,63 +6,66 @@ import 'package:mobile_nebula/validators/ipValidator.dart';
class CIDRFormField extends FormField<CIDR> { class CIDRFormField extends FormField<CIDR> {
//TODO: onSaved, validator, auto-validate, enabled? //TODO: onSaved, validator, auto-validate, enabled?
CIDRFormField({ CIDRFormField({
Key? key, super.key,
autoFocus = false, autoFocus = false,
enableIPV6 = false, enableIPV6 = false,
focusNode, focusNode,
nextFocusNode, nextFocusNode,
ValueChanged<CIDR>? onChanged, ValueChanged<CIDR>? onChanged,
FormFieldSetter<CIDR>? onSaved, super.onSaved,
textInputAction, textInputAction,
CIDR? initialValue, super.initialValue,
this.ipController, this.ipController,
this.bitsController, this.bitsController,
}) : super( }) : super(
key: key, validator: (cidr) {
initialValue: initialValue, if (cidr == null) {
onSaved: onSaved, return "Please fill out this field";
validator: (cidr) { }
if (cidr == null) {
return "Please fill out this field";
}
if (!ipValidator(cidr.ip, enableIPV6)) { if (!ipValidator(cidr.ip, enableIPV6)) {
return 'Please enter a valid ip address'; return 'Please enter a valid ip address';
} }
if (cidr.bits > 32 || cidr.bits < 0) { if (cidr.bits > 32 || cidr.bits < 0) {
return "Please enter a valid number of bits"; return "Please enter a valid number of bits";
} }
return null; return null;
}, },
builder: (FormFieldState<CIDR> field) { builder: (FormFieldState<CIDR> field) {
final _CIDRFormField state = field as _CIDRFormField; final _CIDRFormField state = field as _CIDRFormField;
void onChangedHandler(CIDR value) { void onChangedHandler(CIDR value) {
if (onChanged != null) { if (onChanged != null) {
onChanged(value); onChanged(value);
} }
field.didChange(value); field.didChange(value);
} }
return Column(crossAxisAlignment: CrossAxisAlignment.end, children: <Widget>[ return Column(
CIDRField( crossAxisAlignment: CrossAxisAlignment.end,
autoFocus: autoFocus, children: <Widget>[
focusNode: focusNode, CIDRField(
nextFocusNode: nextFocusNode, autoFocus: autoFocus,
onChanged: onChangedHandler, focusNode: focusNode,
textInputAction: textInputAction, nextFocusNode: nextFocusNode,
ipController: state._effectiveIPController, onChanged: onChangedHandler,
bitsController: state._effectiveBitsController, textInputAction: textInputAction,
), ipController: state._effectiveIPController,
field.hasError bitsController: state._effectiveBitsController,
? Text(field.errorText ?? "Unknown error", ),
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13), field.hasError
textAlign: TextAlign.end) ? Text(
: Container(height: 0) 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? ipController;
final TextEditingController? bitsController; 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 /// SimplePage with a form and built in validation and confirmation to discard changes if any are made
class FormPage extends StatefulWidget { class FormPage extends StatefulWidget {
const FormPage( const FormPage({
{Key? key, super.key,
required this.title, required this.title,
required this.child, required this.child,
required this.onSave, required this.onSave,
required this.changed, required this.changed,
this.hideSave = false, this.hideSave = false,
this.scrollController}) this.scrollController,
: super(key: key); });
final String title; final String title;
final Function onSave; final Function onSave;
@ -39,42 +39,61 @@ class _FormPageState extends State<FormPage> {
changed = widget.changed || changed; changed = widget.changed || changed;
return PopScope<Object?>( return PopScope<Object?>(
canPop: !changed, canPop: !changed,
onPopInvokedWithResult: (bool didPop, Object? result) async { onPopInvokedWithResult: (bool didPop, Object? result) async {
if (didPop) { if (didPop) {
return; return;
} }
final NavigatorState navigator = Navigator.of(context); final NavigatorState navigator = Navigator.of(context);
Utils.confirmDelete(context, 'Discard changes?', () { Utils.confirmDelete(
context,
'Discard changes?',
() {
navigator.pop(); navigator.pop();
}, deleteLabel: 'Yes', cancelLabel: 'No'); },
}, deleteLabel: 'Yes',
child: SimplePage( cancelLabel: 'No',
leadingAction: _buildLeader(context), );
trailingActions: _buildTrailer(context), },
scrollController: widget.scrollController, child: SimplePage(
title: Text(widget.title), leadingAction: _buildLeader(context),
child: Form( trailingActions: _buildTrailer(context),
key: _formKey, scrollController: widget.scrollController,
onChanged: () => setState(() { title: Text(widget.title),
changed = true; child: Form(
}), key: _formKey,
child: widget.child), onChanged:
)); () => setState(() {
changed = true;
}),
child: widget.child,
),
),
);
} }
Widget _buildLeader(BuildContext context) { Widget _buildLeader(BuildContext context) {
return Utils.leadingBackWidget(context, label: changed ? 'Cancel' : 'Back', onPressed: () { return Utils.leadingBackWidget(
if (changed) { context,
Utils.confirmDelete(context, 'Discard changes?', () { label: changed ? 'Cancel' : 'Back',
changed = false; onPressed: () {
if (changed) {
Utils.confirmDelete(
context,
'Discard changes?',
() {
changed = false;
Navigator.pop(context);
},
deleteLabel: 'Yes',
cancelLabel: 'No',
);
} else {
Navigator.pop(context); Navigator.pop(context);
}, deleteLabel: 'Yes', cancelLabel: 'No'); }
} else { },
Navigator.pop(context); );
}
});
} }
List<Widget> _buildTrailer(BuildContext context) { List<Widget> _buildTrailer(BuildContext context) {
@ -83,21 +102,18 @@ class _FormPageState extends State<FormPage> {
} }
return [ return [
Utils.trailingSaveWidget( Utils.trailingSaveWidget(context, () {
context, if (_formKey.currentState == null) {
() { return;
if (_formKey.currentState == null) { }
return;
}
if (!_formKey.currentState!.validate()) { if (!_formKey.currentState!.validate()) {
return; return;
} }
_formKey.currentState!.save(); _formKey.currentState!.save();
widget.onSave(); widget.onSave();
}, }),
)
]; ];
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,32 +1,25 @@
import 'package:flutter/cupertino.dart' as cupertino;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.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'; import 'package:pull_to_refresh/pull_to_refresh.dart';
enum SimpleScrollable { enum SimpleScrollable { none, vertical, horizontal, both }
none,
vertical,
horizontal,
both,
}
class SimplePage extends StatelessWidget { class SimplePage extends StatelessWidget {
const SimplePage( const SimplePage({
{Key? key, super.key,
required this.title, required this.title,
required this.child, required this.child,
this.leadingAction, this.leadingAction,
this.trailingActions = const [], this.trailingActions = const [],
this.scrollable = SimpleScrollable.vertical, this.scrollable = SimpleScrollable.vertical,
this.scrollbar = true, this.scrollbar = true,
this.scrollController, this.scrollController,
this.bottomBar, this.bottomBar,
this.onRefresh, this.onRefresh,
this.onLoading, this.onLoading,
this.alignment, this.alignment,
this.refreshController}) this.refreshController,
: super(key: key); });
final Widget title; final Widget title;
final Widget child; final Widget child;
@ -50,13 +43,14 @@ class SimplePage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget realChild = child; Widget realChild = child;
var addScrollbar = this.scrollbar; var addScrollbar = scrollbar;
if (scrollable == SimpleScrollable.vertical || scrollable == SimpleScrollable.both) { if (scrollable == SimpleScrollable.vertical || scrollable == SimpleScrollable.both) {
realChild = SingleChildScrollView( realChild = SingleChildScrollView(
scrollDirection: Axis.vertical, scrollDirection: Axis.vertical,
child: realChild, controller: refreshController == null ? scrollController : null,
controller: refreshController == null ? scrollController : null); child: realChild,
);
addScrollbar = true; addScrollbar = true;
} }
@ -67,19 +61,20 @@ class SimplePage extends StatelessWidget {
if (refreshController != null) { if (refreshController != null) {
realChild = RefreshConfiguration( realChild = RefreshConfiguration(
headerTriggerDistance: 100, headerTriggerDistance: 100,
footerTriggerDistance: -100, footerTriggerDistance: -100,
maxUnderScrollExtent: 100, maxUnderScrollExtent: 100,
child: SmartRefresher( child: SmartRefresher(
scrollController: scrollController, scrollController: scrollController,
onRefresh: onRefresh, onRefresh: onRefresh,
onLoading: onLoading, onLoading: onLoading,
controller: refreshController!, controller: refreshController!,
child: realChild, enablePullUp: onLoading != null,
enablePullUp: onLoading != null, enablePullDown: onRefresh != null,
enablePullDown: onRefresh != null, footer: ClassicFooter(loadStyle: LoadStyle.ShowWhenLoading),
footer: ClassicFooter(loadStyle: LoadStyle.ShowWhenLoading), child: realChild,
)); ),
);
addScrollbar = true; addScrollbar = true;
} }
@ -88,26 +83,28 @@ class SimplePage extends StatelessWidget {
} }
if (alignment != null) { if (alignment != null) {
realChild = Align(alignment: this.alignment!, child: realChild); realChild = Align(alignment: alignment!, child: realChild);
} }
if (bottomBar != null) { if (bottomBar != null) {
realChild = Column(children: [ realChild = Column(children: [Expanded(child: realChild), bottomBar!]);
Expanded(child: realChild),
bottomBar!,
]);
} }
return PlatformScaffold( return PlatformScaffold(
backgroundColor: cupertino.CupertinoColors.systemGroupedBackground.resolveFrom(context), backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: PlatformAppBar( appBar: PlatformAppBar(
title: title, title: title,
leading: leadingAction != null ? leadingAction : Utils.leadingBackWidget(context), leading: leadingAction,
trailingActions: trailingActions, trailingActions: trailingActions,
cupertino: (_, __) => CupertinoNavigationBarData( cupertino:
transitionBetweenRoutes: false, (_, __) => CupertinoNavigationBarData(
), transitionBetweenRoutes: false,
), // TODO: set title on route, show here instead of just "Back"
body: SafeArea(child: realChild)); 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'; import 'package:mobile_nebula/services/utils.dart';
class SiteItem extends StatelessWidget { class SiteItem extends StatelessWidget {
const SiteItem({Key? key, required this.site, this.onPressed}) : super(key: key); const SiteItem({super.key, required this.site, this.onPressed});
final Site site; final Site site;
final onPressed; final void Function()? onPressed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final borderColor = site.errors.length > 0 final borderColor =
? CupertinoColors.systemRed.resolveFrom(context) site.errors.isNotEmpty
: site.connected ? CupertinoColors.systemRed.resolveFrom(context)
: site.connected
? CupertinoColors.systemGreen.resolveFrom(context) ? CupertinoColors.systemGreen.resolveFrom(context)
: CupertinoColors.systemGrey2.resolveFrom(context); : CupertinoColors.systemGrey2.resolveFrom(context);
final border = BorderSide(color: borderColor, width: 10); final border = BorderSide(color: borderColor, width: 10);
return Container( return Container(
margin: EdgeInsets.symmetric(vertical: 6), margin: EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(border: Border(left: border)), decoration: BoxDecoration(border: Border(left: border)),
child: _buildContent(context)); child: _buildContent(context),
);
} }
Widget _buildContent(BuildContext 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'; Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg';
return SpecialButton( return SpecialButton(
decoration: decoration: BoxDecoration(
BoxDecoration(border: Border(top: border, bottom: border), color: Utils.configItemBackground(context)), border: Border(top: border, bottom: border),
onPressed: onPressed, color: Utils.configItemBackground(context),
child: Padding( ),
padding: EdgeInsets.fromLTRB(10, 10, 5, 10), onPressed: onPressed,
child: Row( child: Padding(
crossAxisAlignment: CrossAxisAlignment.center, padding: EdgeInsets.fromLTRB(10, 10, 5, 10),
children: <Widget>[ child: Row(
site.managed crossAxisAlignment: CrossAxisAlignment.center,
? Padding(padding: EdgeInsets.only(right: 10), child: SvgPicture.asset(dnIcon, width: 12)) children: <Widget>[
: Container(), site.managed
Expanded(child: Text(site.name, style: TextStyle(fontWeight: FontWeight.bold))), ? Padding(padding: EdgeInsets.only(right: 10), child: SvgPicture.asset(dnIcon, width: 12))
Padding(padding: EdgeInsets.only(right: 10)), : Container(),
Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18) 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 // This is a button that pushes the bare minimum onto you, it doesn't even respect button themes - unless you tell it to
class SpecialButton extends StatefulWidget { class SpecialButton extends StatefulWidget {
const SpecialButton({Key? key, this.child, this.color, this.onPressed, this.useButtonTheme = false, this.decoration}) const SpecialButton({
: super(key: key); super.key,
this.child,
this.color,
this.onPressed,
this.useButtonTheme = false,
this.decoration,
});
final Widget? child; final Widget? child;
final Color? color; final Color? color;
@ -26,20 +32,19 @@ class _SpecialButtonState extends State<SpecialButton> with SingleTickerProvider
} }
Widget _buildAndroid() { Widget _buildAndroid() {
var textStyle; TextStyle? textStyle;
if (widget.useButtonTheme) { if (widget.useButtonTheme) {
textStyle = Theme.of(context).textTheme.labelLarge; textStyle = Theme.of(context).textTheme.labelLarge;
} }
return Material( return Material(
textStyle: textStyle, textStyle: textStyle,
child: Ink( child: Ink(
decoration: widget.decoration, decoration: widget.decoration,
color: widget.color, color: widget.color,
child: InkWell( child: InkWell(onTap: widget.onPressed, child: widget.child),
child: widget.child, ),
onTap: widget.onPressed, );
)));
} }
Widget _buildGeneric() { Widget _buildGeneric() {
@ -49,21 +54,22 @@ class _SpecialButtonState extends State<SpecialButton> with SingleTickerProvider
} }
return Container( return Container(
decoration: widget.decoration, decoration: widget.decoration,
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTapDown: _handleTapDown, onTapDown: _handleTapDown,
onTapUp: _handleTapUp, onTapUp: _handleTapUp,
onTapCancel: _handleTapCancel, onTapCancel: _handleTapCancel,
onTap: widget.onPressed, onTap: widget.onPressed,
child: Semantics( child: Semantics(
button: true, button: true,
child: FadeTransition( child: FadeTransition(
opacity: _opacityAnimation!, opacity: _opacityAnimation!,
child: DefaultTextStyle(style: textStyle, child: Container(child: widget.child, color: widget.color)), child: DefaultTextStyle(style: textStyle, child: Container(color: widget.color, child: widget.child)),
),
), ),
)); ),
),
);
} }
// Eyeballed values. Feel free to tweak. // Eyeballed values. Feel free to tweak.
@ -77,11 +83,7 @@ class _SpecialButtonState extends State<SpecialButton> with SingleTickerProvider
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_animationController = AnimationController( _animationController = AnimationController(duration: const Duration(milliseconds: 200), value: 0.0, vsync: this);
duration: const Duration(milliseconds: 200),
value: 0.0,
vsync: this,
);
_opacityAnimation = _animationController!.drive(CurveTween(curve: Curves.decelerate)).drive(_opacityTween); _opacityAnimation = _animationController!.drive(CurveTween(curve: Curves.decelerate)).drive(_opacityTween);
_setTween(); _setTween();
} }
@ -131,9 +133,10 @@ class _SpecialButtonState extends State<SpecialButton> with SingleTickerProvider
} }
final bool wasHeldDown = _buttonHeldDown; final bool wasHeldDown = _buttonHeldDown;
final TickerFuture ticker = _buttonHeldDown final TickerFuture ticker =
? _animationController!.animateTo(1.0, duration: kFadeOutDuration) _buttonHeldDown
: _animationController!.animateTo(0.0, duration: kFadeInDuration); ? _animationController!.animateTo(1.0, duration: kFadeOutDuration)
: _animationController!.animateTo(0.0, duration: kFadeInDuration);
ticker.then<void>((void value) { ticker.then<void>((void value) {
if (mounted && wasHeldDown != _buttonHeldDown) { if (mounted && wasHeldDown != _buttonHeldDown) {

View file

@ -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 /// A normal TextField or CupertinoTextField that looks the same on all platforms
class SpecialTextField extends StatefulWidget { class SpecialTextField extends StatefulWidget {
const SpecialTextField( const SpecialTextField({
{Key? key, super.key,
this.placeholder, this.placeholder,
this.suffix, this.suffix,
this.controller, this.controller,
this.focusNode, this.focusNode,
this.nextFocusNode, this.nextFocusNode,
this.autocorrect, this.autocorrect,
this.minLines, this.minLines,
this.maxLines, this.maxLines,
this.maxLength, this.maxLength,
this.maxLengthEnforcement, this.maxLengthEnforcement,
this.style, this.style,
this.keyboardType, this.keyboardType,
this.textInputAction, this.textInputAction,
this.textCapitalization, this.textCapitalization,
this.textAlign, this.textAlign,
this.autofocus, this.autofocus,
this.onChanged, this.onChanged,
this.enabled, this.enabled,
this.expands, this.expands,
this.keyboardAppearance, this.keyboardAppearance,
this.textAlignVertical, this.textAlignVertical,
this.inputFormatters}) this.inputFormatters,
: super(key: key); });
final String? placeholder; final String? placeholder;
final TextEditingController? controller; final TextEditingController? controller;
@ -64,7 +64,7 @@ class _SpecialTextFieldState extends State<SpecialTextField> {
@override @override
void initState() { void initState() {
if (widget.inputFormatters == null || formatters.length == 0) { if (widget.inputFormatters == null || formatters.isEmpty) {
formatters = [FilteringTextInputFormatter.allow(RegExp(r'[^\t]'))]; formatters = [FilteringTextInputFormatter.allow(RegExp(r'[^\t]'))];
} else { } else {
formatters = widget.inputFormatters!; formatters = widget.inputFormatters!;
@ -76,42 +76,48 @@ class _SpecialTextFieldState extends State<SpecialTextField> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PlatformTextField( return PlatformTextField(
autocorrect: widget.autocorrect, autocorrect: widget.autocorrect,
minLines: widget.minLines, minLines: widget.minLines,
maxLines: widget.maxLines, maxLines: widget.maxLines,
maxLength: widget.maxLength, maxLength: widget.maxLength,
maxLengthEnforcement: widget.maxLengthEnforcement, maxLengthEnforcement: widget.maxLengthEnforcement,
keyboardType: widget.keyboardType, keyboardType: widget.keyboardType,
keyboardAppearance: widget.keyboardAppearance, keyboardAppearance: widget.keyboardAppearance,
textInputAction: widget.textInputAction, textInputAction: widget.textInputAction,
textCapitalization: widget.textCapitalization, textCapitalization: widget.textCapitalization,
textAlign: widget.textAlign, textAlign: widget.textAlign,
textAlignVertical: widget.textAlignVertical, textAlignVertical: widget.textAlignVertical,
autofocus: widget.autofocus, autofocus: widget.autofocus,
focusNode: widget.focusNode, focusNode: widget.focusNode,
onChanged: widget.onChanged, onChanged: widget.onChanged,
enabled: widget.enabled ?? true, enabled: widget.enabled ?? true,
onSubmitted: (_) { onSubmitted: (_) {
if (widget.nextFocusNode != null) { if (widget.nextFocusNode != null) {
FocusScope.of(context).requestFocus(widget.nextFocusNode); FocusScope.of(context).requestFocus(widget.nextFocusNode);
} }
}, },
expands: widget.expands, expands: widget.expands,
inputFormatters: formatters, inputFormatters: formatters,
material: (_, __) => MaterialTextFieldData( material:
(_, __) => MaterialTextFieldData(
decoration: InputDecoration( decoration: InputDecoration(
border: InputBorder.none, border: InputBorder.none,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
isDense: true, isDense: true,
hintText: widget.placeholder, hintText: widget.placeholder,
counterText: '', counterText: '',
suffix: widget.suffix)), suffix: widget.suffix,
cupertino: (_, __) => CupertinoTextFieldData( ),
),
cupertino:
(_, __) => CupertinoTextFieldData(
decoration: BoxDecoration(), decoration: BoxDecoration(),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
placeholder: widget.placeholder, placeholder: widget.placeholder,
suffix: widget.suffix), suffix: widget.suffix,
style: widget.style, ),
controller: widget.controller); 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 // A config item that detects tapping and calls back on a tap
class ConfigButtonItem extends StatelessWidget { class ConfigButtonItem extends StatelessWidget {
const ConfigButtonItem({Key? key, this.content, this.onPressed}) : super(key: key); const ConfigButtonItem({super.key, this.content, this.onPressed});
final Widget? content; final Widget? content;
final onPressed; final void Function()? onPressed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SpecialButton( return SpecialButton(
color: Utils.configItemBackground(context), color: Utils.configItemBackground(context),
onPressed: onPressed, onPressed: onPressed,
useButtonTheme: true, useButtonTheme: true,
child: Container( child: Container(
constraints: BoxConstraints(minHeight: Utils.minInteractiveSize, minWidth: double.infinity), constraints: BoxConstraints(minHeight: Utils.minInteractiveSize, minWidth: double.infinity),
child: Center(child: content), child: Center(child: content),
)); ),
);
} }
} }

View file

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

View file

@ -9,7 +9,7 @@ TextStyle basicTextStyle(BuildContext context) =>
const double _headerFontSize = 13.0; const double _headerFontSize = 13.0;
class ConfigHeader extends StatelessWidget { class ConfigHeader extends StatelessWidget {
const ConfigHeader({Key? key, required this.label, this.color}) : super(key: key); const ConfigHeader({super.key, required this.label, this.color});
final String label; final String label;
final Color? color; 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), padding: const EdgeInsets.only(left: 10.0, top: 30.0, bottom: 5.0, right: 10.0),
child: Text( child: Text(
label, label,
style: basicTextStyle(context).copyWith( style: basicTextStyle(
color: color ?? CupertinoColors.secondaryLabel.resolveFrom(context), context,
fontSize: _headerFontSize, ).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'; import 'package:mobile_nebula/services/utils.dart';
class ConfigItem extends StatelessWidget { class ConfigItem extends StatelessWidget {
const ConfigItem( const ConfigItem({
{Key? key, super.key,
this.label, this.label,
required this.content, required this.content,
this.labelWidth = 100, this.labelWidth = 100,
this.crossAxisAlignment = CrossAxisAlignment.center}) this.crossAxisAlignment = CrossAxisAlignment.center,
: super(key: key); });
final Widget? label; final Widget? label;
final Widget content; final Widget content;
@ -20,7 +20,7 @@ class ConfigItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var textStyle; TextStyle textStyle;
if (Platform.isAndroid) { if (Platform.isAndroid) {
textStyle = Theme.of(context).textTheme.labelLarge!.copyWith(fontWeight: FontWeight.normal); textStyle = Theme.of(context).textTheme.labelLarge!.copyWith(fontWeight: FontWeight.normal);
} else { } else {
@ -28,15 +28,16 @@ class ConfigItem extends StatelessWidget {
} }
return Container( return Container(
color: Utils.configItemBackground(context), color: Utils.configItemBackground(context),
padding: EdgeInsets.only(top: 2, bottom: 2, left: 15, right: 20), padding: EdgeInsets.symmetric(vertical: 6, horizontal: 15),
constraints: BoxConstraints(minHeight: Utils.minInteractiveSize), constraints: BoxConstraints(minHeight: Utils.minInteractiveSize),
child: Row( child: Row(
crossAxisAlignment: crossAxisAlignment, crossAxisAlignment: crossAxisAlignment,
children: <Widget>[ children: <Widget>[
Container(width: labelWidth, child: DefaultTextStyle(style: textStyle, child: Container(child: label))), SizedBox(width: labelWidth, child: DefaultTextStyle(style: textStyle, child: Container(child: label))),
Expanded(child: DefaultTextStyle(style: textStyle, child: Container(child: content))), 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'; import 'package:mobile_nebula/services/utils.dart';
class ConfigPageItem extends StatelessWidget { class ConfigPageItem extends StatelessWidget {
const ConfigPageItem( const ConfigPageItem({
{Key? key, super.key,
this.label, this.label,
this.content, this.content,
this.labelWidth = 100, this.labelWidth = 100,
this.onPressed, this.onPressed,
this.disabled = false, this.disabled = false,
this.crossAxisAlignment = CrossAxisAlignment.center}) this.crossAxisAlignment = CrossAxisAlignment.center,
: super(key: key); });
final Widget? label; final Widget? label;
final Widget? content; final Widget? content;
final double labelWidth; final double labelWidth;
final CrossAxisAlignment crossAxisAlignment; final CrossAxisAlignment crossAxisAlignment;
final onPressed; final void Function()? onPressed;
final bool disabled; final bool disabled;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var theme; dynamic theme;
if (Platform.isAndroid) { if (Platform.isAndroid) {
final origTheme = Theme.of(context); final origTheme = Theme.of(context);
theme = origTheme.copyWith( theme = origTheme.copyWith(
textTheme: origTheme.textTheme textTheme: origTheme.textTheme.copyWith(
.copyWith(labelLarge: origTheme.textTheme.labelLarge!.copyWith(fontWeight: FontWeight.normal))); labelLarge: origTheme.textTheme.labelLarge!.copyWith(fontWeight: FontWeight.normal),
),
);
return Theme(data: theme, child: _buildContent(context)); return Theme(data: theme, child: _buildContent(context));
} else { } else {
final origTheme = CupertinoTheme.of(context); final origTheme = CupertinoTheme.of(context);
@ -42,21 +44,22 @@ class ConfigPageItem extends StatelessWidget {
Widget _buildContent(BuildContext context) { Widget _buildContent(BuildContext context) {
return SpecialButton( return SpecialButton(
onPressed: this.disabled ? null : onPressed, onPressed: disabled ? null : onPressed,
color: Utils.configItemBackground(context), color: Utils.configItemBackground(context),
child: Container( child: Container(
padding: EdgeInsets.only(left: 15, right: 15), padding: EdgeInsets.symmetric(vertical: 6, horizontal: 15),
constraints: BoxConstraints(minHeight: Utils.minInteractiveSize, minWidth: double.infinity), constraints: BoxConstraints(minHeight: Utils.minInteractiveSize, minWidth: double.infinity),
child: Row( child: Row(
crossAxisAlignment: crossAxisAlignment, crossAxisAlignment: crossAxisAlignment,
children: <Widget>[ children: <Widget>[
label != null ? Container(width: labelWidth, child: label) : Container(), label != null ? SizedBox(width: labelWidth, child: label) : Container(),
Expanded(child: Container(child: content, padding: EdgeInsets.only(right: 10))), Expanded(child: Container(padding: EdgeInsets.only(right: 10), child: content)),
this.disabled disabled
? Container() ? Container()
: Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18) : 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'; import 'ConfigHeader.dart';
class ConfigSection extends StatelessWidget { class ConfigSection extends StatelessWidget {
const ConfigSection({Key? key, this.label, required this.children, this.borderColor, this.labelColor}) const ConfigSection({super.key, this.label, required this.children, this.borderColor, this.labelColor});
: super(key: key);
final List<Widget> children; final List<Widget> children;
final String? label; final String? label;
@ -16,30 +15,38 @@ class ConfigSection extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final border = BorderSide(color: borderColor ?? Utils.configSectionBorder(context)); final border = BorderSide(color: borderColor ?? Utils.configSectionBorder(context));
List<Widget> _children = []; List<Widget> mappedChildren = [];
final len = children.length; final len = children.length;
for (var i = 0; i < len; i++) { for (var i = 0; i < len; i++) {
_children.add(children[i]); mappedChildren.add(children[i]);
if (i < len - 1) { if (i < len - 1) {
double pad = 15; double pad = 15;
if (children[i + 1].runtimeType.toString() == 'ConfigButtonItem') { if (children[i + 1].runtimeType.toString() == 'ConfigButtonItem') {
pad = 0; pad = 0;
} }
_children.add(Padding( mappedChildren.add(
child: Divider(height: 1, color: Utils.configSectionBorder(context)), padding: EdgeInsets.only(left: pad))); Padding(
padding: EdgeInsets.only(left: pad),
child: Divider(height: 1, color: Utils.configSectionBorder(context)),
),
);
} }
} }
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ return Column(
label != null ? ConfigHeader(label: label!, color: labelColor) : Container(height: 20), crossAxisAlignment: CrossAxisAlignment.start,
Container( children: [
decoration: label != null ? ConfigHeader(label: label!, color: labelColor) : Container(height: 20),
BoxDecoration(border: Border(top: border, bottom: border), color: Utils.configItemBackground(context)), Container(
child: Column( decoration: BoxDecoration(
children: _children, 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/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:mobile_nebula/components/SpecialTextField.dart';
class ConfigTextItem extends StatelessWidget { class ConfigTextItem extends StatelessWidget {
const ConfigTextItem( const ConfigTextItem({
{Key? key, this.placeholder, this.controller, this.style = const TextStyle(fontFamily: 'RobotoMono')}) super.key,
: super(key: key); this.placeholder,
this.controller,
this.style = const TextStyle(fontFamily: 'RobotoMono'),
});
final String? placeholder; final String? placeholder;
final TextEditingController? controller; final TextEditingController? controller;
@ -15,14 +14,13 @@ class ConfigTextItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return CupertinoTextFormFieldRow(
padding: Platform.isAndroid ? EdgeInsets.all(5) : EdgeInsets.zero, autocorrect: false,
child: SpecialTextField( minLines: 3,
autocorrect: false, maxLines: 10,
minLines: 3, placeholder: placeholder,
maxLines: 10, style: style,
placeholder: placeholder, controller: controller,
style: style, );
controller: controller));
} }
} }

View file

@ -1,8 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/cupertino.dart' show CupertinoThemeData, DefaultCupertinoLocalizations; import 'package:flutter/cupertino.dart' show CupertinoThemeData, DefaultCupertinoLocalizations;
import 'package:flutter/material.dart' import 'package:flutter/material.dart' show DefaultMaterialLocalizations, TextTheme, ThemeMode;
show BottomSheetThemeData, Colors, DefaultMaterialLocalizations, ThemeData, ThemeMode;
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.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/MainScreen.dart';
import 'package:mobile_nebula/screens/EnrollmentScreen.dart'; import 'package:mobile_nebula/screens/EnrollmentScreen.dart';
import 'package:mobile_nebula/services/settings.dart'; import 'package:mobile_nebula/services/settings.dart';
import 'package:flutter_web_plugins/url_strategy.dart'; import 'package:mobile_nebula/services/theme.dart';
import 'package:mobile_nebula/services/utils.dart';
import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
Future<void> main() async { Future<void> main() async {
usePathUrlStrategy(); usePathUrlStrategy();
var settings = Settings(); var settings = Settings();
if (settings.trackErrors) { if (settings.trackErrors) {
await SentryFlutter.init( await SentryFlutter.init((options) {
(options) { options.dsn = 'https://96106df405ade3f013187dfc8e4200e7@o920269.ingest.us.sentry.io/4508132321001472';
options.dsn = 'https://96106df405ade3f013187dfc8e4200e7@o920269.ingest.us.sentry.io/4508132321001472'; // Capture all traces. May need to adjust if overwhelming
// Capture all traces. May need to adjust if overwhelming options.tracesSampleRate = 1.0;
options.tracesSampleRate = 1.0; // For each trace, capture all profiles
// For each trace, capture all profiles options.profilesSampleRate = 1.0;
options.profilesSampleRate = 1.0; }, appRunner: () => runApp(Main()));
},
appRunner: () => runApp(Main()),
);
} else { } else {
runApp(Main()); runApp(Main());
} }
@ -36,12 +33,16 @@ Future<void> main() async {
//TODO: EventChannel might be better than the stream controller we are using now //TODO: EventChannel might be better than the stream controller we are using now
class Main extends StatelessWidget { class Main extends StatelessWidget {
const Main({super.key});
// This widget is the root of your application. // This widget is the root of your application.
@override @override
Widget build(BuildContext context) => App(); Widget build(BuildContext context) => App();
} }
class App extends StatefulWidget { class App extends StatefulWidget {
const App({super.key});
@override @override
_AppState createState() => _AppState(); _AppState createState() => _AppState();
} }
@ -85,68 +86,47 @@ class _AppState extends State<App> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ThemeData lightTheme = ThemeData( TextTheme textTheme = Utils.createTextTheme(context, "Public Sans", "Public Sans");
useMaterial3: false, MaterialTheme theme = MaterialTheme(textTheme);
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],
),
);
return PlatformProvider( return PlatformProvider(
settings: PlatformSettingsData(iosUsesMaterialWidgets: true), settings: PlatformSettingsData(iosUsesMaterialWidgets: true),
builder: (context) => PlatformApp( builder:
debugShowCheckedModeBanner: false, (context) => PlatformApp(
localizationsDelegates: <LocalizationsDelegate<dynamic>>[ debugShowCheckedModeBanner: false,
DefaultMaterialLocalizations.delegate, localizationsDelegates: <LocalizationsDelegate<dynamic>>[
DefaultWidgetsLocalizations.delegate, DefaultMaterialLocalizations.delegate,
DefaultCupertinoLocalizations.delegate, DefaultWidgetsLocalizations.delegate,
], DefaultCupertinoLocalizations.delegate,
title: 'Nebula', ],
material: (_, __) { title: 'Nebula',
return new MaterialAppData( material: (_, __) {
themeMode: brightness == Brightness.light ? ThemeMode.light : ThemeMode.dark, return MaterialAppData(
theme: brightness == Brightness.light ? lightTheme : darkTheme, themeMode: brightness == Brightness.light ? ThemeMode.light : ThemeMode.dark,
); theme: brightness == Brightness.light ? theme.light() : theme.dark(),
}, );
cupertino: (_, __) => CupertinoAppData( },
theme: CupertinoThemeData(brightness: brightness), cupertino: (_, __) => CupertinoAppData(theme: CupertinoThemeData(brightness: brightness)),
), onGenerateRoute: (settings) {
onGenerateRoute: (settings) { print(settings);
if (settings.name == '/') { if (settings.name == '/') {
return platformPageRoute(context: context, builder: (context) => MainScreen(this.dnEnrolled)); return platformPageRoute(context: context, builder: (context) => MainScreen(dnEnrolled));
} }
final uri = Uri.parse(settings.name!); final uri = Uri.parse(settings.name!);
if (uri.path == EnrollmentScreen.routeName) { 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 // TODO: maybe implement this as a dialog instead of a page, you can stack multiple enrollment screens which is annoying in dev
return platformPageRoute( return platformPageRoute(
context: context, context: context,
builder: (context) => builder:
EnrollmentScreen(code: EnrollmentScreen.parseCode(settings.name!), stream: this.dnEnrolled), (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'; throw 'Invalid CIDR string';
} }
return CIDR( return CIDR(ip: parts[0], bits: int.parse(parts[1]));
ip: parts[0],
bits: int.parse(parts[1]),
);
} }
} }

View file

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

View file

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

View file

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

View file

@ -5,16 +5,10 @@ class UnsafeRoute {
UnsafeRoute({this.route, this.via}); UnsafeRoute({this.route, this.via});
factory UnsafeRoute.fromJson(Map<String, dynamic> json) { factory UnsafeRoute.fromJson(Map<String, dynamic> json) {
return UnsafeRoute( return UnsafeRoute(route: json['route'], via: json['via']);
route: json['route'],
via: json['via'],
);
} }
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {'route': route, 'via': via};
'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/ConfigPageItem.dart';
import 'package:mobile_nebula/components/config/ConfigSection.dart'; import 'package:mobile_nebula/components/config/ConfigSection.dart';
import 'package:mobile_nebula/gen.versions.dart'; import 'package:mobile_nebula/gen.versions.dart';
import 'package:mobile_nebula/screens/LicensesScreen.dart';
import 'package:mobile_nebula/services/utils.dart'; import 'package:mobile_nebula/services/utils.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
class AboutScreen extends StatefulWidget { class AboutScreen extends StatefulWidget {
const AboutScreen({Key? key}) : super(key: key); const AboutScreen({super.key});
@override @override
_AboutScreenState createState() => _AboutScreenState(); _AboutScreenState createState() => _AboutScreenState();
@ -36,47 +37,67 @@ class _AboutScreenState extends State<AboutScreen> {
// packageInfo is null until ready is true // packageInfo is null until ready is true
if (!ready) { if (!ready) {
return Center( return Center(
child: PlatformCircularProgressIndicator(cupertino: (_, __) { child: PlatformCircularProgressIndicator(
return CupertinoProgressIndicatorData(radius: 50); cupertino: (_, __) {
}), return CupertinoProgressIndicatorData(radius: 50);
},
),
); );
} }
return SimplePage( return SimplePage(
title: Text('About'), title: Text('About'),
child: Column(children: [ child: Column(
ConfigSection(children: <Widget>[ children: [
ConfigItem( ConfigSection(
label: Text('App version'), children: <Widget>[
labelWidth: 150, ConfigItem(
content: _buildText('${packageInfo!.version}-${packageInfo!.buildNumber} (sha: $gitSha)')), label: Text('App version'),
ConfigItem( labelWidth: 150,
label: Text('Nebula version'), labelWidth: 150, content: _buildText('$nebulaVersion ($goVersion)')), content: _buildText('${packageInfo!.version}-${packageInfo!.buildNumber} (sha: $gitSha)'),
ConfigItem( ),
label: Text('Flutter version'), ConfigItem(
labelWidth: 150, label: Text('Nebula version'),
content: _buildText(flutterVersion['frameworkVersion'] ?? 'Unknown')), labelWidth: 150,
ConfigItem( content: _buildText('$nebulaVersion ($goVersion)'),
label: Text('Dart version'), ),
labelWidth: 150, ConfigItem(
content: _buildText(flutterVersion['dartSdkVersion'] ?? 'Unknown')), label: Text('Flutter version'),
]), labelWidth: 150,
ConfigSection(children: <Widget>[ content: _buildText(flutterVersion['frameworkVersion'] ?? 'Unknown'),
//TODO: wire up these other pages ),
// ConfigPageItem(label: Text('Changelog'), labelWidth: 300, onPressed: () => Utils.launchUrl('https://defined.net/mobile/changelog', context)), ConfigItem(
ConfigPageItem( label: Text('Dart version'),
label: Text('Privacy policy'), labelWidth: 150,
labelWidth: 300, content: _buildText(flutterVersion['dartSdkVersion'] ?? 'Unknown'),
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( 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), padding: EdgeInsets.only(top: 20),
child: Text( child: Text('Copyright © 2024 Defined Networking, Inc', textAlign: TextAlign.center),
'Copyright © 2024 Defined Networking, Inc', ),
textAlign: TextAlign.center, ],
)), ),
]),
); );
} }

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.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:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:mobile_nebula/components/SimplePage.dart'; import 'package:mobile_nebula/components/SimplePage.dart';
import 'package:mobile_nebula/components/buttons/PrimaryButton.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../components/config/ConfigSection.dart';
class EnrollmentScreen extends StatefulWidget { class EnrollmentScreen extends StatefulWidget {
final String? code; final String? code;
final StreamController? stream; final StreamController? stream;
@ -45,6 +49,7 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService'); static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
@override
void initState() { void initState() {
code = widget.code; code = widget.code;
super.initState(); super.initState();
@ -90,91 +95,127 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
} else { } else {
// No code, show the error // No code, show the error
child = Padding( child = Padding(
child: Center( padding: EdgeInsets.only(top: 20),
child: Text( child: Center(
child: Text(
'No valid enrollment code was found.\n\nContact your administrator to obtain a new enrollment code.', 'No valid enrollment code was found.\n\nContact your administrator to obtain a new enrollment code.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
)), ),
padding: EdgeInsets.only(top: 20)); ),
);
} }
} else if (this.error != null) { } else if (error != null) {
// Error while enrolling, display it // Error while enrolling, display it
child = Center( child = Center(
child: Column( child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.center,
Padding( mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: SelectableText( child: SelectableText(
'There was an issue while attempting to enroll this device. Contact your administrator to obtain a new enrollment code.'), '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: [ Padding(
TextSpan(text: 'If the problem persists, please let us know at '), padding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: SelectableText.rich(
TextSpan( TextSpan(
text: 'support@defined.net', children: [
style: bodyTextStyle.apply(color: colorScheme.primary), TextSpan(text: 'If the problem persists, please let us know at '),
recognizer: TapGestureRecognizer() TextSpan(
..onTap = () async { text: 'support@defined.net',
if (await canLaunchUrl(contactUri)) { style: bodyTextStyle.apply(color: colorScheme.primary),
print(await launchUrl(contactUri)); 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(
Container( color: Theme.of(context).colorScheme.errorContainer,
child: Padding(child: SelectableText(this.error!), padding: EdgeInsets.all(10)), child: Padding(padding: EdgeInsets.all(16), child: SelectableText(error!)),
color: Theme.of(context).colorScheme.errorContainer, ),
), ],
], ),
crossAxisAlignment: CrossAxisAlignment.center, );
mainAxisAlignment: MainAxisAlignment.center, } else if (enrolled) {
));
} else if (this.enrolled) {
// Enrollment complete! // Enrollment complete!
child = Padding( child = Padding(
child: Center( padding: EdgeInsets.only(top: 20),
child: Text( child: Center(child: Text('Enrollment complete! 🎉', textAlign: TextAlign.center)),
'Enrollment complete! 🎉', );
textAlign: TextAlign.center,
)),
padding: EdgeInsets.only(top: 20));
} else { } else {
// Have a code and actively enrolling // Have a code and actively enrolling
alignment = Alignment.center; alignment = Alignment.center;
child = Center( child = Center(
child: Column(children: [ child: Column(
Padding(child: Text('Contacting DN for enrollment'), padding: EdgeInsets.only(bottom: 25)), children: [
PlatformCircularProgressIndicator(cupertino: (_, __) { Padding(padding: EdgeInsets.only(bottom: 25), child: Text('Contacting DN for enrollment')),
return CupertinoProgressIndicatorData(radius: 50); PlatformCircularProgressIndicator(
}) cupertino: (_, __) {
])); return CupertinoProgressIndicatorData(radius: 50);
},
),
],
),
);
} }
return SimplePage( return SimplePage(title: Text('Enroll with Managed Nebula'), alignment: alignment, child: child);
title: Text('Enroll with Managed Nebula', style: TextStyle(fontWeight: FontWeight.bold)),
child: Padding(child: child, padding: EdgeInsets.symmetric(horizontal: 10)),
alignment: alignment);
} }
Widget _codeEntry() { Widget _codeEntry() {
return Column(children: [ final GlobalKey<FormState> formKey = GlobalKey<FormState>();
Padding(
padding: EdgeInsets.only(top: 20), String? validator(String? value) {
child: PlatformTextField( if (value == null || value.isEmpty) {
hintText: 'defined.net enrollment code or link', return 'Code or link is required';
controller: enrollInput, }
)), return null;
PlatformTextButton( }
child: Text('Submit'),
onPressed: () { Future<void> onSubmit() async {
setState(() { final bool isValid = formKey.currentState?.validate() ?? false;
code = EnrollmentScreen.parseCode(enrollInput.text); if (!isValid) {
error = null; return;
_enroll(); }
});
}, 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/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:mobile_nebula/components/DangerButton.dart';
import 'package:mobile_nebula/components/SimplePage.dart'; import 'package:mobile_nebula/components/SimplePage.dart';
import 'package:mobile_nebula/components/config/ConfigCheckboxItem.dart'; import 'package:mobile_nebula/components/config/ConfigCheckboxItem.dart';
import 'package:mobile_nebula/components/config/ConfigItem.dart'; import 'package:mobile_nebula/components/config/ConfigItem.dart';
@ -15,14 +15,14 @@ import 'package:pull_to_refresh/pull_to_refresh.dart';
class HostInfoScreen extends StatefulWidget { class HostInfoScreen extends StatefulWidget {
const HostInfoScreen({ const HostInfoScreen({
Key? key, super.key,
required this.hostInfo, required this.hostInfo,
required this.isLighthouse, required this.isLighthouse,
required this.pending, required this.pending,
this.onChanged, this.onChanged,
required this.site, required this.site,
required this.supportsQRScanning, required this.supportsQRScanning,
}) : super(key: key); });
final bool isLighthouse; final bool isLighthouse;
final bool pending; final bool pending;
@ -53,52 +53,66 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
final title = widget.pending ? 'Pending' : 'Active'; final title = widget.pending ? 'Pending' : 'Active';
return SimplePage( return SimplePage(
title: Text('$title Host Info'), title: Text('$title Host Info'),
refreshController: refreshController, refreshController: refreshController,
onRefresh: () async { onRefresh: () async {
await _getHostInfo(); await _getHostInfo();
refreshController.refreshCompleted(); refreshController.refreshCompleted();
}, },
leadingAction: Utils.leadingBackWidget(context, onPressed: () { child: Column(
Navigator.pop(context); children: [_buildMain(), _buildDetails(), _buildRemotes(), !widget.pending ? _buildClose() : Container()],
}), ),
child: Column( );
children: [_buildMain(), _buildDetails(), _buildRemotes(), !widget.pending ? _buildClose() : Container()]));
} }
Widget _buildMain() { Widget _buildMain() {
return ConfigSection(children: [ return ConfigSection(
ConfigItem(label: Text('VPN IP'), labelWidth: 150, content: SelectableText(hostInfo.vpnIp)), children: [
hostInfo.cert != null ConfigItem(label: Text('VPN IP'), labelWidth: 150, content: SelectableText(hostInfo.vpnIp)),
? ConfigPageItem( hostInfo.cert != null
? ConfigPageItem(
label: Text('Certificate'), label: Text('Certificate'),
labelWidth: 150, labelWidth: 150,
content: Text(hostInfo.cert!.details.name), content: Text(hostInfo.cert!.details.name),
onPressed: () => Utils.openPage( onPressed:
context, () => Utils.openPage(
(context) => CertificateDetailsScreen( context,
certInfo: CertificateInfo(cert: hostInfo.cert!), (context) => CertificateDetailsScreen(
supportsQRScanning: widget.supportsQRScanning, certInfo: CertificateInfo(cert: hostInfo.cert!),
))) supportsQRScanning: widget.supportsQRScanning,
: Container(), ),
]); ),
)
: Container(),
],
);
} }
Widget _buildDetails() { Widget _buildDetails() {
return ConfigSection(children: <Widget>[ return ConfigSection(
ConfigItem( children: <Widget>[
label: Text('Lighthouse'), labelWidth: 150, content: SelectableText(widget.isLighthouse ? 'Yes' : 'No')), ConfigItem(
ConfigItem(label: Text('Local Index'), labelWidth: 150, content: SelectableText('${hostInfo.localIndex}')), label: Text('Lighthouse'),
ConfigItem(label: Text('Remote Index'), labelWidth: 150, content: SelectableText('${hostInfo.remoteIndex}')), labelWidth: 150,
ConfigItem( content: SelectableText(widget.isLighthouse ? 'Yes' : 'No'),
label: Text('Message Counter'), labelWidth: 150, content: SelectableText('${hostInfo.messageCounter}')), ),
]); 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() { Widget _buildRemotes() {
if (hostInfo.remoteAddresses.length == 0) { if (hostInfo.remoteAddresses.isEmpty) {
return ConfigSection( 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(); return widget.pending ? _buildStaticRemotes() : _buildEditRemotes();
@ -110,31 +124,33 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
final double ipWidth = final double ipWidth =
Utils.textSize("000.000.000.000:000000", CupertinoTheme.of(context).textTheme.textStyle).width; 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(); String remote = remoteObj.toString();
items.add(ConfigCheckboxItem( items.add(
key: Key(remote), ConfigCheckboxItem(
label: Text(remote), //TODO: need to do something to adjust the font size in the event we have an ipv6 address key: Key(remote),
labelWidth: ipWidth, label: Text(remote), //TODO: need to do something to adjust the font size in the event we have an ipv6 address
checked: currentRemote == remote, labelWidth: ipWidth,
onChanged: () async { checked: currentRemote == remote,
if (remote == currentRemote) { onChanged: () async {
return; if (remote == currentRemote) {
} return;
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.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() { Widget _buildStaticRemotes() {
@ -143,38 +159,43 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
final double ipWidth = final double ipWidth =
Utils.textSize("000.000.000.000:000000", CupertinoTheme.of(context).textTheme.textStyle).width; 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(); String remote = remoteObj.toString();
items.add(ConfigCheckboxItem( items.add(
key: Key(remote), ConfigCheckboxItem(
label: Text(remote), //TODO: need to do something to adjust the font size in the event we have an ipv6 address key: Key(remote),
labelWidth: ipWidth, label: Text(remote), //TODO: need to do something to adjust the font size in the event we have an ipv6 address
checked: currentRemote == remote, 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() { Widget _buildClose() {
return Padding( return Padding(
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10), padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: PlatformElevatedButton( child: DangerButton(
child: Text('Close Tunnel'), child: Text('Close Tunnel'),
color: CupertinoColors.systemRed.resolveFrom(context), onPressed:
onPressed: () => Utils.confirmDelete(context, 'Close Tunnel?', () async { () => Utils.confirmDelete(context, 'Close Tunnel?', () async {
try { try {
await widget.site.closeTunnel(hostInfo.vpnIp); await widget.site.closeTunnel(hostInfo.vpnIp);
if (widget.onChanged != null) { if (widget.onChanged != null) {
widget.onChanged!(); widget.onChanged!();
} }
Navigator.pop(context); Navigator.pop(context);
} catch (err) { } catch (err) {
Utils.popError(context, 'Error while trying to close the tunnel', err.toString()); Utils.popError(context, 'Error while trying to close the tunnel', err.toString());
} }
}, deleteLabel: 'Close')))); }, deleteLabel: 'Close'),
),
),
);
} }
_getHostInfo() async { _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 { class MainScreen extends StatefulWidget {
const MainScreen(this.dnEnrollStream, {Key? key}) : super(key: key); const MainScreen(this.dnEnrollStream, {super.key});
final StreamController dnEnrollStream; final StreamController dnEnrollStream;
@ -115,12 +115,8 @@ class _MainScreenState extends State<MainScreen> {
if (kDebugMode) { if (kDebugMode) {
debugSite = Row( debugSite = Row(
children: [
_debugSave(badDebugSave),
_debugSave(goodDebugSave),
_debugClearKeys(),
],
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [_debugSave(badDebugSave), _debugSave(goodDebugSave), _debugClearKeys()],
); );
} }
@ -141,13 +137,15 @@ class _MainScreenState extends State<MainScreen> {
leadingAction: PlatformIconButton( leadingAction: PlatformIconButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
icon: Icon(Icons.add, size: 28.0), icon: Icon(Icons.add, size: 28.0),
onPressed: () => Utils.openPage(context, (context) { onPressed:
return SiteConfigScreen( () => Utils.openPage(context, (context) {
onSave: (_) { return SiteConfigScreen(
_loadSites(); onSave: (_) {
}, _loadSites();
supportsQRScanning: supportsQRScanning); },
}), supportsQRScanning: supportsQRScanning,
);
}),
), ),
refreshController: refreshController, refreshController: refreshController,
onRefresh: () { onRefresh: () {
@ -157,7 +155,7 @@ class _MainScreenState extends State<MainScreen> {
trailingActions: <Widget>[ trailingActions: <Widget>[
PlatformIconButton( PlatformIconButton(
padding: EdgeInsets.zero, 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)), onPressed: () => Utils.openPage(context, (_) => SettingsScreen(widget.dnEnrollStream)),
), ),
], ],
@ -169,13 +167,15 @@ class _MainScreenState extends State<MainScreen> {
Widget _buildBody() { Widget _buildBody() {
if (error != null) { if (error != null) {
return Center( return Center(
child: Padding( child: Padding(
child: Column( padding: EdgeInsets.symmetric(vertical: 0, horizontal: 10),
mainAxisAlignment: MainAxisAlignment.center, child: Column(
crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: error!, crossAxisAlignment: CrossAxisAlignment.center,
), children: error!,
padding: EdgeInsets.symmetric(vertical: 0, horizontal: 10))); ),
),
);
} }
return _buildSites(); return _buildSites();
@ -183,29 +183,33 @@ class _MainScreenState extends State<MainScreen> {
Widget _buildNoSites() { Widget _buildNoSites() {
return Padding( return Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Center( child: Center(
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
Padding( Padding(
padding: const EdgeInsets.fromLTRB(0, 8.0, 0, 8.0), padding: const EdgeInsets.fromLTRB(0, 8.0, 0, 8.0),
child: Text('Welcome to Nebula!', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)), child: Text('Welcome to Nebula!', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
), ),
Text('You don\'t have any site configurations installed yet. Hit the plus button above to get started.', Text(
textAlign: TextAlign.center), 'You don\'t have any site configurations installed yet. Hit the plus button above to get started.',
], textAlign: TextAlign.center,
), ),
)); ],
),
),
);
} }
Widget _buildSites() { Widget _buildSites() {
if (sites == null || sites!.length == 0) { if (sites == null || sites!.isEmpty) {
return _buildNoSites(); return _buildNoSites();
} }
List<Widget> items = []; List<Widget> items = [];
sites!.forEach((site) { for (var site in sites!) {
items.add(SiteItem( items.add(
SiteItem(
key: Key(site.id), key: Key(site.id),
site: site, site: site,
onPressed: () { onPressed: () {
@ -216,44 +220,47 @@ class _MainScreenState extends State<MainScreen> {
supportsQRScanning: supportsQRScanning, supportsQRScanning: supportsQRScanning,
); );
}); });
})); },
}); ),
);
}
Widget child = ReorderableListView( Widget child = ReorderableListView(
shrinkWrap: true, shrinkWrap: true,
scrollController: scrollController, scrollController: scrollController,
padding: EdgeInsets.symmetric(vertical: 5), padding: EdgeInsets.symmetric(vertical: 5),
children: items, children: items,
onReorder: (oldI, newI) async { onReorder: (oldI, newI) async {
if (oldI < newI) { if (oldI < newI) {
// removing the item at oldIndex will shorten the list by 1. // removing the item at oldIndex will shorten the list by 1.
newI -= 1; newI -= 1;
} }
setState(() { setState(() {
final Site moved = sites!.removeAt(oldI); final Site moved = sites!.removeAt(oldI);
sites!.insert(newI, moved); sites!.insert(newI, moved);
});
for (var i = 0; i < sites!.length; i++) {
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();
}); });
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) { 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 // 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 uuid = Uuid();
var s = Site( var s = Site(
name: siteConfig['name']!, name: siteConfig['name']!,
id: uuid.v4(), id: uuid.v4(),
staticHostmap: { staticHostmap: {
"10.1.0.1": StaticHost( "10.1.0.1": StaticHost(
lighthouse: true, lighthouse: true,
destinations: [IPAndPort(ip: '10.1.1.53', port: 4242), IPAndPort(ip: '1::1', port: 4242)]) 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']), ca: [CertificateInfo.debug(rawCert: siteConfig['ca'])],
unsafeRoutes: [UnsafeRoute(route: '10.3.3.3/32', via: '10.1.0.1')]); certInfo: CertificateInfo.debug(rawCert: siteConfig['cert']),
unsafeRoutes: [UnsafeRoute(route: '10.3.3.3/32', via: '10.1.0.1')],
);
s.key = siteConfig['key']; s.key = siteConfig['key'];
@ -309,14 +318,17 @@ class _MainScreenState extends State<MainScreen> {
var site = Site.fromJson(rawSite); var site = Site.fromJson(rawSite);
//TODO: we need to cancel change listeners when we rebuild //TODO: we need to cancel change listeners when we rebuild
site.onChange().listen((_) { site.onChange().listen(
setState(() {}); (_) {
}, onError: (err) { setState(() {});
setState(() {}); },
if (ModalRoute.of(context)!.isCurrent) { onError: (err) {
Utils.popError(context, "${site.name} Error", err); setState(() {});
} if (ModalRoute.of(context)!.isCurrent) {
}); Utils.popError(context, "${site.name} Error", err);
}
},
);
sites!.add(site); sites!.add(site);
} catch (err) { } catch (err) {

View file

@ -27,7 +27,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
void initState() { void initState() {
//TODO: we need to unregister on dispose? //TODO: we need to unregister on dispose?
settings.onChange().listen((_) { settings.onChange().listen((_) {
if (this.mounted) { if (mounted) {
setState(() {}); setState(() {});
} }
}); });
@ -38,10 +38,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
List<Widget> colorSection = []; List<Widget> colorSection = [];
colorSection.add(ConfigItem( colorSection.add(
label: Text('Use system colors'), ConfigItem(
labelWidth: 200, label: Text('Use system colors'),
content: Align( labelWidth: 200,
content: Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Switch.adaptive( child: Switch.adaptive(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
@ -49,13 +50,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
settings.useSystemColors = value; settings.useSystemColors = value;
}, },
value: settings.useSystemColors, value: settings.useSystemColors,
)), ),
)); ),
),
);
if (!settings.useSystemColors) { if (!settings.useSystemColors) {
colorSection.add(ConfigItem( colorSection.add(
label: Text('Dark mode'), ConfigItem(
content: Align( label: Text('Dark mode'),
content: Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Switch.adaptive( child: Switch.adaptive(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
@ -63,16 +67,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
settings.darkMode = value; settings.darkMode = value;
}, },
value: settings.darkMode, value: settings.darkMode,
)), ),
)); ),
),
);
} }
List<Widget> items = []; List<Widget> items = [];
items.add(ConfigSection(children: colorSection)); items.add(ConfigSection(children: colorSection));
items.add(ConfigItem( items.add(
label: Text('Wrap log output'), ConfigItem(
labelWidth: 200, label: Text('Wrap log output'),
content: Align( labelWidth: 200,
content: Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Switch.adaptive( child: Switch.adaptive(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
@ -82,14 +89,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
settings.logWrap = value; settings.logWrap = value;
}); });
}, },
)), ),
)); ),
),
);
items.add(ConfigSection(children: [ items.add(
ConfigItem( ConfigSection(
label: Text('Report errors automatically'), children: [
labelWidth: 250, ConfigItem(
content: Align( label: Text('Report errors automatically'),
labelWidth: 250,
content: Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Switch.adaptive( child: Switch.adaptive(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
@ -99,27 +110,35 @@ class _SettingsScreenState extends State<SettingsScreen> {
settings.trackErrors = value; 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/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:flutter_svg/svg.dart';
import 'package:mobile_nebula/components/SimplePage.dart'; import 'package:mobile_nebula/components/SimplePage.dart';
import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
import 'package:mobile_nebula/components/config/ConfigItem.dart'; import 'package:mobile_nebula/components/config/ConfigItem.dart';
@ -17,16 +16,14 @@ import 'package:mobile_nebula/screens/siteConfig/SiteConfigScreen.dart';
import 'package:mobile_nebula/services/utils.dart'; import 'package:mobile_nebula/services/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart'; import 'package: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: 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) //TODO: ios is now the problem with connecting screwing our ability to query the hostmap (its a race)
class SiteDetailScreen extends StatefulWidget { class SiteDetailScreen extends StatefulWidget {
const SiteDetailScreen({ const SiteDetailScreen({super.key, required this.site, this.onChanged, required this.supportsQRScanning});
Key? key,
required this.site,
this.onChanged,
required this.supportsQRScanning,
}) : super(key: key);
final Site site; final Site site;
final Function? onChanged; final Function? onChanged;
@ -52,21 +49,24 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
_listHostmap(); _listHostmap();
} }
onChange = site.onChange().listen((_) { onChange = site.onChange().listen(
// TODO: Gross hack... we get site.connected = true to trigger the toggle before the VPN service has started. (_) {
// If we fetch the hostmap now we'll never get a response. Wait until Nebula is running. // TODO: Gross hack... we get site.connected = true to trigger the toggle before the VPN service has started.
if (site.status == 'Connected') { // If we fetch the hostmap now we'll never get a response. Wait until Nebula is running.
_listHostmap(); if (site.status == 'Connected') {
} else { _listHostmap();
activeHosts = null; } else {
pendingHosts = null; activeHosts = null;
} pendingHosts = null;
}
setState(() {}); setState(() {});
}, onError: (err) { },
setState(() {}); onError: (err) {
Utils.popError(context, "Error", err); setState(() {});
}); Utils.popError(context, "Error", err);
},
);
super.initState(); super.initState();
} }
@ -79,49 +79,52 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dnIcon = final title = SiteTitle(site: widget.site);
Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg';
final title = Row(children: [
site.managed
? Padding(padding: EdgeInsets.only(right: 10), child: SvgPicture.asset(dnIcon, width: 12))
: Container(),
Expanded(child: Text(site.name, style: TextStyle(fontWeight: FontWeight.bold)))
]);
return SimplePage( return SimplePage(
title: title, title: title,
leadingAction: Utils.leadingBackWidget(context, onPressed: () { leadingAction: Utils.leadingBackWidget(
context,
onPressed: () {
if (changed && widget.onChanged != null) { if (changed && widget.onChanged != null) {
widget.onChanged!(); widget.onChanged!();
} }
Navigator.pop(context); Navigator.pop(context);
}),
refreshController: refreshController,
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(), _buildErrors(),
_buildConfig(), _buildConfig(),
site.connected ? _buildHosts() : Container(), site.connected ? _buildHosts() : Container(),
_buildSiteDetails(), _buildSiteDetails(),
_buildDelete(), _buildDelete(),
])); ],
),
);
} }
Widget _buildErrors() { Widget _buildErrors() {
if (site.errors.length == 0) { if (site.errors.isEmpty) {
return Container(); return Container();
} }
List<Widget> items = []; List<Widget> items = [];
site.errors.forEach((error) { for (var error in site.errors) {
items.add(ConfigItem( items.add(
labelWidth: 0, content: Padding(padding: EdgeInsets.symmetric(vertical: 10), child: SelectableText(error)))); ConfigItem(
}); labelWidth: 0,
content: Padding(padding: EdgeInsets.symmetric(vertical: 10), child: SelectableText(error)),
),
);
}
return ConfigSection( return ConfigSection(
label: 'ERRORS', label: 'ERRORS',
@ -132,40 +135,51 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
} }
Widget _buildConfig() { Widget _buildConfig() {
return ConfigSection(children: <Widget>[ void handleChange(v) async {
ConfigItem( 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'), label: Text('Status'),
content: Row(mainAxisAlignment: MainAxisAlignment.end, children: <Widget>[ content: Row(
Padding( mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
Padding(
padding: EdgeInsets.only(right: 5), padding: EdgeInsets.only(right: 5),
child: Text(widget.site.status, child: Text(
style: TextStyle(color: CupertinoColors.secondaryLabel.resolveFrom(context)))), widget.site.status,
Switch.adaptive( style: TextStyle(color: CupertinoColors.secondaryLabel.resolveFrom(context)),
value: widget.site.connected, ),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ),
onChanged: (v) async { Switch.adaptive(
try { value: widget.site.connected,
if (v) { materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
await widget.site.start(); onChanged: widget.site.errors.isNotEmpty && !widget.site.connected ? null : handleChange,
} else { ),
await widget.site.stop(); ],
} ),
} catch (error) { ),
var action = v ? 'start' : 'stop'; ConfigPageItem(
Utils.popError(context, 'Failed to $action the site', error.toString()); label: Text('Logs'),
} onPressed: () {
}, Utils.openPage(context, (context) {
) return SiteLogsScreen(site: widget.site);
])), });
ConfigPageItem( },
label: Text('Logs'), ),
onPressed: () { ],
Utils.openPage(context, (context) { );
return SiteLogsScreen(site: widget.site);
});
},
),
]);
} }
Widget _buildHosts() { Widget _buildHosts() {
@ -187,83 +201,92 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
label: "TUNNELS", label: "TUNNELS",
children: <Widget>[ children: <Widget>[
ConfigPageItem( ConfigPageItem(
onPressed: () { onPressed: () {
if (activeHosts == null) return; if (activeHosts == null) return;
Utils.openPage( Utils.openPage(
context, context,
(context) => SiteTunnelsScreen( (context) => SiteTunnelsScreen(
pending: false, pending: false,
tunnels: activeHosts!, tunnels: activeHosts!,
site: site, site: site,
onChanged: (hosts) { onChanged: (hosts) {
setState(() { setState(() {
activeHosts = hosts; activeHosts = hosts;
}); });
}, },
supportsQRScanning: widget.supportsQRScanning, supportsQRScanning: widget.supportsQRScanning,
)); ),
}, );
label: Text("Active"), },
content: Container(alignment: Alignment.centerRight, child: active)), label: Text("Active"),
content: Container(alignment: Alignment.centerRight, child: active),
),
ConfigPageItem( ConfigPageItem(
onPressed: () { onPressed: () {
if (pendingHosts == null) return; if (pendingHosts == null) return;
Utils.openPage( Utils.openPage(
context, context,
(context) => SiteTunnelsScreen( (context) => SiteTunnelsScreen(
pending: true, pending: true,
tunnels: pendingHosts!, tunnels: pendingHosts!,
site: site, site: site,
onChanged: (hosts) { onChanged: (hosts) {
setState(() { setState(() {
pendingHosts = hosts; pendingHosts = hosts;
}); });
}, },
supportsQRScanning: widget.supportsQRScanning, supportsQRScanning: widget.supportsQRScanning,
)); ),
}, );
label: Text("Pending"), },
content: Container(alignment: Alignment.centerRight, child: pending)) label: Text("Pending"),
content: Container(alignment: Alignment.centerRight, child: pending),
),
], ],
); );
} }
Widget _buildSiteDetails() { Widget _buildSiteDetails() {
return ConfigSection(children: <Widget>[ return ConfigSection(
ConfigPageItem( children: <Widget>[
crossAxisAlignment: CrossAxisAlignment.center, ConfigPageItem(
content: Text('Configuration'), crossAxisAlignment: CrossAxisAlignment.center,
onPressed: () { content: Text('Configuration'),
Utils.openPage(context, (context) { onPressed: () {
return SiteConfigScreen( Utils.openPage(context, (context) {
site: widget.site, return SiteConfigScreen(
onSave: (site) async { site: widget.site,
changed = true; onSave: (site) async {
setState(() {}); changed = true;
}, setState(() {});
supportsQRScanning: widget.supportsQRScanning, },
); supportsQRScanning: widget.supportsQRScanning,
}); );
}, });
), },
]); ),
],
);
} }
Widget _buildDelete() { Widget _buildDelete() {
return Padding( return Padding(
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10), padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: PlatformElevatedButton( child: DangerButton(
child: Text('Delete'), child: Text('Delete'),
color: CupertinoColors.systemRed.resolveFrom(context), onPressed:
onPressed: () => Utils.confirmDelete(context, 'Delete Site?', () async { () => Utils.confirmDelete(context, 'Delete Site?', () async {
if (await _deleteSite()) { if (await _deleteSite()) {
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
})))); }),
),
),
);
} }
_listHostmap() async { _listHostmap() async {

View file

@ -3,16 +3,19 @@ import 'dart:io';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:flutter_svg/svg.dart';
import 'package:mobile_nebula/components/SimplePage.dart'; import 'package:mobile_nebula/components/SimplePage.dart';
import 'package:mobile_nebula/models/Site.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/settings.dart';
import 'package:mobile_nebula/services/share.dart'; import 'package:mobile_nebula/services/share.dart';
import 'package:mobile_nebula/services/utils.dart'; import 'package:mobile_nebula/services/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart'; import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../components/SiteTitle.dart';
class SiteLogsScreen extends StatefulWidget { class SiteLogsScreen extends StatefulWidget {
const SiteLogsScreen({Key? key, required this.site}) : super(key: key); const SiteLogsScreen({super.key, required this.site});
final Site site; final Site site;
@ -21,14 +24,14 @@ class SiteLogsScreen extends StatefulWidget {
} }
class _SiteLogsScreenState extends State<SiteLogsScreen> { class _SiteLogsScreenState extends State<SiteLogsScreen> {
String logs = ''; final ScrollController controller = ScrollController();
ScrollController controller = ScrollController(); final RefreshController refreshController = RefreshController(initialRefresh: false);
RefreshController refreshController = RefreshController(initialRefresh: false); final LogsNotifier logsNotifier = LogsNotifier();
var settings = Settings(); var settings = Settings();
@override @override
void initState() { void initState() {
loadLogs(); logsNotifier.loadLogs(logFile: widget.site.logFile);
super.initState(); super.initState();
} }
@ -40,93 +43,119 @@ class _SiteLogsScreenState extends State<SiteLogsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dnIcon = final title = SiteTitle(site: widget.site);
Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg';
final title = Row(children: [
widget.site.managed
? Padding(padding: EdgeInsets.only(right: 10), child: SvgPicture.asset(dnIcon, width: 12))
: Container(),
Expanded(child: Text(widget.site.name, style: TextStyle(fontWeight: FontWeight.bold)))
]);
return SimplePage( return SimplePage(
title: title, title: title,
trailingActions: [Padding(padding: const EdgeInsets.only(right: 8), child: _buildTextWrapToggle())],
scrollable: SimpleScrollable.both, scrollable: SimpleScrollable.both,
scrollController: controller, scrollController: controller,
onRefresh: () async { onRefresh: () async {
await loadLogs(); await logsNotifier.loadLogs(logFile: widget.site.logFile);
refreshController.refreshCompleted(); refreshController.refreshCompleted();
}, },
onLoading: () async { onLoading: () async {
await loadLogs(); await logsNotifier.loadLogs(logFile: widget.site.logFile);
refreshController.loadComplete(); refreshController.loadComplete();
}, },
refreshController: refreshController, refreshController: refreshController,
child: Container(
padding: EdgeInsets.all(5),
constraints: logBoxConstraints(context),
child: SelectableText(logs.trim(), style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14))),
bottomBar: _buildBottomBar(), 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() { Widget _buildBottomBar() {
var borderSide = BorderSide( var borderSide = BorderSide(color: CupertinoColors.separator, style: BorderStyle.solid, width: 0.0);
color: CupertinoColors.separator,
style: BorderStyle.solid,
width: 0.0,
);
var padding = Platform.isAndroid ? EdgeInsets.fromLTRB(0, 20, 0, 30) : EdgeInsets.all(10); var padding = Platform.isAndroid ? EdgeInsets.fromLTRB(0, 20, 0, 30) : EdgeInsets.all(10);
return Container( return PlatformWidgetBuilder(
decoration: BoxDecoration( child: Row(
border: Border(top: borderSide), mainAxisAlignment: MainAxisAlignment.spaceEvenly,
), spacing: 8,
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ children: <Widget>[
Expanded(child: Builder(builder: (BuildContext context) { Tooltip(
return PlatformIconButton( message: "Share logs",
padding: padding, child: PlatformIconButton(
icon: Icon(context.platformIcons.share, size: 30), icon: Icon(context.platformIcons.share),
onPressed: () { onPressed: () {
Share.shareFile(context, Share.shareFile(
title: '${widget.site.name} logs', context,
filePath: widget.site.logFile, title: '${widget.site.name} logs',
filename: '${widget.site.name}.log'); filePath: widget.site.logFile,
filename: '${widget.site.name}.log',
);
}, },
); ),
})), ),
Expanded( Tooltip(
child: PlatformIconButton( message: 'Go to latest',
padding: padding, child: PlatformIconButton(
icon: Icon(context.platformIcons.downArrow, size: 30), icon: Icon(context.platformIcons.downArrow),
onPressed: () async { onPressed: () async {
controller.animateTo(controller.position.maxScrollExtent, controller.animateTo(
duration: const Duration(milliseconds: 500), curve: Curves.linearToEaseOut); 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(); cupertino:
(context, child, platform) =>
setState(() { Container(decoration: BoxDecoration(border: Border(top: borderSide)), padding: padding, child: child),
logs = v; material: (context, child, platform) => BottomAppBar(child: child),
}); );
} 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();
} }
logBoxConstraints(BuildContext context) { logBoxConstraints(BuildContext context) {

View file

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

View file

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

View file

@ -40,11 +40,7 @@ class Advanced {
} }
class AdvancedScreen extends StatefulWidget { class AdvancedScreen extends StatefulWidget {
const AdvancedScreen({ const AdvancedScreen({super.key, required this.site, required this.onSave});
Key? key,
required this.site,
required this.onSave,
}) : super(key: key);
final Site site; final Site site;
final ValueChanged<Advanced> onSave; final ValueChanged<Advanced> onSave;
@ -73,22 +69,24 @@ class _AdvancedScreenState extends State<AdvancedScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FormPage( return FormPage(
title: 'Advanced Settings', title: 'Advanced Settings',
changed: changed, changed: changed,
onSave: () { onSave: () {
Navigator.pop(context); Navigator.pop(context);
widget.onSave(settings); widget.onSave(settings);
}, },
child: Column(children: [ child: Column(
children: [
ConfigSection( ConfigSection(
children: [ children: [
ConfigItem( ConfigItem(
label: Text("Lighthouse interval"), label: Text("Lighthouse interval"),
labelWidth: 200, labelWidth: 200,
//TODO: Auto select on focus? //TODO: Auto select on focus?
content: widget.site.managed content:
? Text(settings.lhDuration.toString() + " seconds", textAlign: TextAlign.right) widget.site.managed
: PlatformTextFormField( ? Text("${settings.lhDuration} seconds", textAlign: TextAlign.right)
: PlatformTextFormField(
initialValue: settings.lhDuration.toString(), initialValue: settings.lhDuration.toString(),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
suffix: Text("seconds"), suffix: Text("seconds"),
@ -102,14 +100,16 @@ class _AdvancedScreenState extends State<AdvancedScreen> {
} }
}); });
}, },
)), ),
),
ConfigItem( ConfigItem(
label: Text("Listen port"), label: Text("Listen port"),
labelWidth: 150, labelWidth: 150,
//TODO: Auto select on focus? //TODO: Auto select on focus?
content: widget.site.managed content:
? Text(settings.port.toString(), textAlign: TextAlign.right) widget.site.managed
: PlatformTextFormField( ? Text(settings.port.toString(), textAlign: TextAlign.right)
: PlatformTextFormField(
initialValue: settings.port.toString(), initialValue: settings.port.toString(),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
textAlign: TextAlign.right, textAlign: TextAlign.right,
@ -122,13 +122,15 @@ class _AdvancedScreenState extends State<AdvancedScreen> {
} }
}); });
}, },
)), ),
),
ConfigItem( ConfigItem(
label: Text("MTU"), label: Text("MTU"),
labelWidth: 150, labelWidth: 150,
content: widget.site.managed content:
? Text(settings.mtu.toString(), textAlign: TextAlign.right) widget.site.managed
: PlatformTextFormField( ? Text(settings.mtu.toString(), textAlign: TextAlign.right)
: PlatformTextFormField(
initialValue: settings.mtu.toString(), initialValue: settings.mtu.toString(),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
textAlign: TextAlign.right, textAlign: TextAlign.right,
@ -141,41 +143,46 @@ class _AdvancedScreenState extends State<AdvancedScreen> {
} }
}); });
}, },
)), ),
),
ConfigPageItem( ConfigPageItem(
disabled: widget.site.managed, disabled: widget.site.managed,
label: Text('Cipher'), label: Text('Cipher'),
labelWidth: 150, labelWidth: 150,
content: Text(settings.cipher, textAlign: TextAlign.end), content: Text(settings.cipher, textAlign: TextAlign.end),
onPressed: () { onPressed: () {
Utils.openPage(context, (context) { Utils.openPage(context, (context) {
return CipherScreen( return CipherScreen(
cipher: settings.cipher, cipher: settings.cipher,
onSave: (cipher) { onSave: (cipher) {
setState(() { setState(() {
settings.cipher = cipher; settings.cipher = cipher;
changed = true; changed = true;
}); });
}); },
}); );
}), });
},
),
ConfigPageItem( ConfigPageItem(
disabled: widget.site.managed, disabled: widget.site.managed,
label: Text('Log verbosity'), label: Text('Log verbosity'),
labelWidth: 150, labelWidth: 150,
content: Text(settings.verbosity, textAlign: TextAlign.end), content: Text(settings.verbosity, textAlign: TextAlign.end),
onPressed: () { onPressed: () {
Utils.openPage(context, (context) { Utils.openPage(context, (context) {
return LogVerbosityScreen( return LogVerbosityScreen(
verbosity: settings.verbosity, verbosity: settings.verbosity,
onSave: (verbosity) { onSave: (verbosity) {
setState(() { setState(() {
settings.verbosity = verbosity; settings.verbosity = verbosity;
changed = true; changed = true;
}); });
}); },
}); );
}), });
},
),
ConfigPageItem( ConfigPageItem(
label: Text('Unsafe routes'), label: Text('Unsafe routes'),
labelWidth: 150, labelWidth: 150,
@ -183,18 +190,20 @@ class _AdvancedScreenState extends State<AdvancedScreen> {
onPressed: () { onPressed: () {
Utils.openPage(context, (context) { Utils.openPage(context, (context) {
return UnsafeRoutesScreen( return UnsafeRoutesScreen(
unsafeRoutes: settings.unsafeRoutes, unsafeRoutes: settings.unsafeRoutes,
onSave: widget.site.managed onSave:
? null widget.site.managed
: (routes) { ? null
: (routes) {
setState(() { setState(() {
settings.unsafeRoutes = routes; settings.unsafeRoutes = routes;
changed = true; changed = true;
}); });
}); },
);
}); });
}, },
) ),
], ],
), ),
ConfigSection( ConfigSection(
@ -211,9 +220,11 @@ class _AdvancedScreenState extends State<AdvancedScreen> {
Utils.popError(context, 'Failed to render the site config', err.toString()); 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) //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 { class CAListScreen extends StatefulWidget {
const CAListScreen({ const CAListScreen({super.key, required this.cas, this.onSave, required this.supportsQRScanning});
Key? key,
required this.cas,
this.onSave,
required this.supportsQRScanning,
}) : super(key: key);
final List<CertificateInfo> cas; final List<CertificateInfo> cas;
final ValueChanged<List<CertificateInfo>>? onSave; final ValueChanged<List<CertificateInfo>>? onSave;
@ -44,9 +39,9 @@ class _CAListScreenState extends State<CAListScreen> {
@override @override
void initState() { void initState() {
widget.cas.forEach((ca) { for (var ca in widget.cas) {
cas[ca.cert.fingerprint] = ca; cas[ca.cert.fingerprint] = ca;
}); }
super.initState(); super.initState();
} }
@ -56,7 +51,7 @@ class _CAListScreenState extends State<CAListScreen> {
List<Widget> items = []; List<Widget> items = [];
final caItems = _buildCAs(); final caItems = _buildCAs();
if (caItems.length > 0) { if (caItems.isNotEmpty) {
items.add(ConfigSection(children: caItems)); items.add(ConfigSection(children: caItems));
} }
@ -65,41 +60,47 @@ class _CAListScreenState extends State<CAListScreen> {
} }
return FormPage( return FormPage(
title: 'Certificate Authorities', title: 'Certificate Authorities',
changed: changed, changed: changed,
onSave: () { onSave: () {
if (widget.onSave != null) { if (widget.onSave != null) {
Navigator.pop(context); Navigator.pop(context);
widget.onSave!(cas.values.map((ca) { widget.onSave!(
cas.values.map((ca) {
return ca; return ca;
}).toList()); }).toList(),
} );
}, }
child: Column(children: items)); },
child: Column(children: items),
);
} }
List<Widget> _buildCAs() { List<Widget> _buildCAs() {
List<Widget> items = []; List<Widget> items = [];
cas.forEach((key, ca) { cas.forEach((key, ca) {
items.add(ConfigPageItem( items.add(
content: Text(ca.cert.details.name), ConfigPageItem(
onPressed: () { content: Text(ca.cert.details.name),
Utils.openPage(context, (context) { onPressed: () {
return CertificateDetailsScreen( Utils.openPage(context, (context) {
certInfo: ca, return CertificateDetailsScreen(
onDelete: widget.onSave == null certInfo: ca,
? null onDelete:
: () { widget.onSave == null
setState(() { ? null
changed = true; : () {
cas.remove(key); setState(() {
}); changed = true;
}, cas.remove(key);
supportsQRScanning: widget.supportsQRScanning, });
); },
}); supportsQRScanning: widget.supportsQRScanning,
}, );
)); });
},
),
);
}); });
return items; return items;
@ -114,14 +115,14 @@ class _CAListScreenState extends State<CAListScreen> {
var ignored = 0; var ignored = 0;
List<dynamic> certs = jsonDecode(rawCerts); List<dynamic> certs = jsonDecode(rawCerts);
certs.forEach((rawCert) { for (var rawCert in certs) {
final info = CertificateInfo.fromJson(rawCert); final info = CertificateInfo.fromJson(rawCert);
if (!info.cert.details.isCa) { if (!info.cert.details.isCa) {
ignored++; ignored++;
return; continue;
} }
cas[info.cert.fingerprint] = info; cas[info.cert.fingerprint] = info;
}); }
if (ignored > 0) { if (ignored > 0) {
error = 'One or more certificates were ignored because they were not certificate authorities.'; 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() { List<Widget> _addCA() {
Map<String, Widget> children = { Map<String, Widget> children = {'paste': Text('Copy/Paste'), 'file': Text('File')};
'paste': Text('Copy/Paste'),
'file': Text('File'),
};
// not all devices have a camera for QR codes // not all devices have a camera for QR codes
if (widget.supportsQRScanning) { if (widget.supportsQRScanning) {
@ -149,18 +147,19 @@ class _CAListScreenState extends State<CAListScreen> {
List<Widget> items = [ List<Widget> items = [
Padding( Padding(
padding: EdgeInsets.fromLTRB(10, 25, 10, 0), padding: EdgeInsets.fromLTRB(10, 25, 10, 0),
child: CupertinoSlidingSegmentedControl( child: CupertinoSlidingSegmentedControl(
groupValue: inputType, groupValue: inputType,
onValueChanged: (v) { onValueChanged: (v) {
if (v != null) { if (v != null) {
setState(() { setState(() {
inputType = v; inputType = v;
}); });
} }
}, },
children: children, children: children,
)) ),
),
]; ];
if (inputType == 'paste') { if (inputType == 'paste') {
@ -178,25 +177,23 @@ class _CAListScreenState extends State<CAListScreen> {
return [ return [
ConfigSection( ConfigSection(
children: [ children: [
ConfigTextItem( ConfigTextItem(placeholder: 'CA PEM contents', controller: pasteController),
placeholder: 'CA PEM contents',
controller: pasteController,
),
ConfigButtonItem( ConfigButtonItem(
content: Text('Load CA'), content: Text('Load CA'),
onPressed: () { onPressed: () {
_addCAEntry(pasteController.text, (err) { _addCAEntry(pasteController.text, (err) {
print(err); print(err);
if (err != null) { if (err != null) {
return Utils.popError(context, 'Failed to parse CA content', err); return Utils.popError(context, 'Failed to parse CA content', err);
} }
pasteController.text = ''; pasteController.text = '';
setState(() {}); setState(() {});
}); });
}), },
),
], ],
) ),
]; ];
} }
@ -205,27 +202,28 @@ class _CAListScreenState extends State<CAListScreen> {
ConfigSection( ConfigSection(
children: [ children: [
ConfigButtonItem( ConfigButtonItem(
content: Text('Choose a file'), content: Text('Choose a file'),
onPressed: () async { onPressed: () async {
try { try {
final content = await Utils.pickFile(context); final content = await Utils.pickFile(context);
if (content == null) { if (content == null) {
return; 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());
} }
})
_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 { onPressed: () async {
var result = await Navigator.push( var result = await Navigator.push(
context, context,
platformPageRoute( platformPageRoute(context: context, builder: (context) => ScanQRScreen()),
context: context,
builder: (context) => new ScanQRScreen(),
),
); );
if (result != null) { if (result != null) {
_addCAEntry(result, (err) { _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/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:mobile_nebula/components/DangerButton.dart';
import 'package:mobile_nebula/components/FormPage.dart'; import 'package:mobile_nebula/components/FormPage.dart';
import 'package:mobile_nebula/components/config/ConfigItem.dart'; import 'package:mobile_nebula/components/config/ConfigItem.dart';
import 'package:mobile_nebula/components/config/ConfigSection.dart'; import 'package:mobile_nebula/components/config/ConfigSection.dart';
@ -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) /// Displays the details of a CertificateInfo object. Respects incomplete objects (missing validity or rawCert)
class CertificateDetailsScreen extends StatefulWidget { class CertificateDetailsScreen extends StatefulWidget {
const CertificateDetailsScreen({ const CertificateDetailsScreen({
Key? key, super.key,
required this.certInfo, required this.certInfo,
this.onDelete, this.onDelete,
this.onSave, this.onSave,
@ -19,7 +19,7 @@ class CertificateDetailsScreen extends StatefulWidget {
this.pubKey, this.pubKey,
this.privKey, this.privKey,
required this.supportsQRScanning, required this.supportsQRScanning,
}) : super(key: key); });
final CertificateInfo certInfo; final CertificateInfo certInfo;
@ -76,58 +76,63 @@ class _CertificateDetailsScreenState extends State<CertificateDetailsScreen> {
} }
}, },
hideSave: widget.onSave == null && widget.onReplace == null, hideSave: widget.onSave == null && widget.onReplace == null,
child: Column(children: [ child: Column(
_buildID(), children: [_buildID(), _buildFilters(), _buildValid(), _buildAdvanced(), _buildReplace(), _buildDelete()],
_buildFilters(), ),
_buildValid(),
_buildAdvanced(),
_buildReplace(),
_buildDelete(),
]),
); );
} }
Widget _buildID() { Widget _buildID() {
return ConfigSection(children: <Widget>[ return ConfigSection(
ConfigItem(label: Text('Name'), content: SelectableText(certInfo.cert.details.name)), children: <Widget>[
ConfigItem( ConfigItem(label: Text('Name'), content: SelectableText(certInfo.cert.details.name)),
label: Text('Type'), content: Text(certInfo.cert.details.isCa ? 'CA certificate' : 'Client certificate')), ConfigItem(
]); label: Text('Type'),
content: Text(certInfo.cert.details.isCa ? 'CA certificate' : 'Client certificate'),
),
],
);
} }
Widget _buildValid() { Widget _buildValid() {
var valid = Text('yes'); var valid = Text('yes');
if (certInfo.validity != null && !certInfo.validity!.valid) { if (certInfo.validity != null && !certInfo.validity!.valid) {
valid = Text(certInfo.validity!.valid ? 'yes' : certInfo.validity!.reason, valid = Text(
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(context))); certInfo.validity!.valid ? 'yes' : certInfo.validity!.reason,
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(context)),
);
} }
return ConfigSection( return ConfigSection(
label: 'VALIDITY', label: 'VALIDITY',
children: <Widget>[ children: <Widget>[
ConfigItem(label: Text('Valid?'), content: valid), ConfigItem(label: Text('Valid?'), content: valid),
ConfigItem( ConfigItem(
label: Text('Created'), content: SelectableText(certInfo.cert.details.notBefore.toLocal().toString())), label: Text('Created'),
content: SelectableText(certInfo.cert.details.notBefore.toLocal().toString()),
),
ConfigItem( 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() { Widget _buildFilters() {
List<Widget> items = []; 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(', ')))); 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(', ')))); 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(', ')))); 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) ? ConfigSection(label: certInfo.cert.details.isCa ? 'FILTERS' : 'DETAILS', children: items)
: Container(); : Container();
} }
@ -136,20 +141,24 @@ class _CertificateDetailsScreenState extends State<CertificateDetailsScreen> {
return ConfigSection( return ConfigSection(
children: <Widget>[ children: <Widget>[
ConfigItem( ConfigItem(
label: Text('Fingerprint'), label: Text('Fingerprint'),
content: content: SelectableText(certInfo.cert.fingerprint, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
SelectableText(certInfo.cert.fingerprint, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)), crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start), ),
ConfigItem( ConfigItem(
label: Text('Public Key'), label: Text('Public Key'),
content: SelectableText(certInfo.cert.details.publicKey, content: SelectableText(
style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)), certInfo.cert.details.publicKey,
crossAxisAlignment: CrossAxisAlignment.start), style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14),
),
crossAxisAlignment: CrossAxisAlignment.start,
),
certInfo.rawCert != null certInfo.rawCert != null
? ConfigItem( ? ConfigItem(
label: Text('PEM Format'), label: Text('PEM Format'),
content: SelectableText(certInfo.rawCert!, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)), content: SelectableText(certInfo.rawCert!, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
crossAxisAlignment: CrossAxisAlignment.start) crossAxisAlignment: CrossAxisAlignment.start,
)
: Container(), : Container(),
], ],
); );
@ -161,31 +170,32 @@ class _CertificateDetailsScreenState extends State<CertificateDetailsScreen> {
} }
return Padding( return Padding(
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10), padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: PlatformElevatedButton( child: DangerButton(
child: Text('Replace certificate'), child: Text('Replace certificate'),
color: CupertinoColors.systemRed.resolveFrom(context), onPressed: () {
onPressed: () { Utils.openPage(context, (context) {
Utils.openPage(context, (context) { return AddCertificateScreen(
return AddCertificateScreen( onReplace: (result) {
onReplace: (result) { setState(() {
setState(() { changed = true;
changed = true; certResult = result;
certResult = result; certInfo = result.certInfo;
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,
);
}); });
}))); // 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() { Widget _buildDelete() {
@ -196,15 +206,18 @@ class _CertificateDetailsScreenState extends State<CertificateDetailsScreen> {
var title = certInfo.cert.details.isCa ? 'Delete CA?' : 'Delete cert?'; var title = certInfo.cert.details.isCa ? 'Delete CA?' : 'Delete cert?';
return Padding( return Padding(
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10), padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: PlatformElevatedButton( child: DangerButton(
child: Text('Delete'), child: Text('Delete'),
color: CupertinoColors.systemRed.resolveFrom(context), onPressed:
onPressed: () => Utils.confirmDelete(context, title, () async { () => Utils.confirmDelete(context, title, () async {
Navigator.pop(context); Navigator.pop(context);
widget.onDelete!(); widget.onDelete!();
})))); }),
),
),
);
} }
} }

View file

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

View file

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

View file

@ -7,11 +7,7 @@ class RenderedConfigScreen extends StatelessWidget {
final String config; final String config;
final String name; final String name;
RenderedConfigScreen({ const RenderedConfigScreen({super.key, required this.config, required this.name});
Key? key,
required this.config,
required this.name,
}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -19,18 +15,21 @@ class RenderedConfigScreen extends StatelessWidget {
title: Text('Rendered Site Config'), title: Text('Rendered Site Config'),
scrollable: SimpleScrollable.both, scrollable: SimpleScrollable.both,
trailingActions: <Widget>[ trailingActions: <Widget>[
Builder(builder: (BuildContext context) { Builder(
return PlatformIconButton( builder: (BuildContext context) {
padding: EdgeInsets.zero, return PlatformIconButton(
icon: Icon(context.platformIcons.share, size: 28.0), padding: EdgeInsets.zero,
onPressed: () => Share.share(context, title: '$name.yaml', text: config, filename: '$name.yaml'), icon: Icon(context.platformIcons.share, size: 28.0),
); onPressed: () => Share.share(context, title: '$name.yaml', text: config, filename: '$name.yaml'),
}), );
},
),
], ],
child: Container( child: Container(
padding: EdgeInsets.all(5), padding: EdgeInsets.all(5),
constraints: BoxConstraints(minWidth: MediaQuery.of(context).size.width), constraints: BoxConstraints(minWidth: MediaQuery.of(context).size.width),
child: SelectableText(config, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14))), 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'; import 'package:mobile_scanner/mobile_scanner.dart';
class ScanQRScreen extends StatefulWidget { class ScanQRScreen extends StatefulWidget {
const ScanQRScreen({super.key});
@override @override
State<ScanQRScreen> createState() => _ScanQRScreenState(); State<ScanQRScreen> createState() => _ScanQRScreenState();
} }
@ -14,29 +16,28 @@ class _ScanQRScreenState extends State<ScanQRScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scanWindow = Rect.fromCenter( final scanWindow = Rect.fromCenter(center: MediaQuery.sizeOf(context).center(Offset.zero), width: 250, height: 250);
center: MediaQuery.sizeOf(context).center(Offset.zero),
width: 250,
height: 250,
);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Scan QR')), appBar: AppBar(title: const Text('Scan QR')),
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: Stack(fit: StackFit.expand, children: [ body: Stack(
fit: StackFit.expand,
children: [
Center( Center(
child: MobileScanner( child: MobileScanner(
fit: BoxFit.contain, fit: BoxFit.contain,
controller: cameraController, controller: cameraController,
scanWindow: scanWindow, scanWindow: scanWindow,
onDetect: (BarcodeCapture barcodes) { onDetect: (BarcodeCapture barcodes) {
var barcode = barcodes.barcodes.firstOrNull; var barcode = barcodes.barcodes.firstOrNull;
if (barcode != null && mounted) { if (barcode != null && mounted) {
cameraController.stop().then((_) { cameraController.stop().then((_) {
Navigator.pop(context, barcode.rawValue); Navigator.pop(context, barcode.rawValue);
}); });
} }
}), },
),
), ),
ValueListenableBuilder( ValueListenableBuilder(
valueListenable: cameraController, valueListenable: cameraController,
@ -45,9 +46,7 @@ class _ScanQRScreenState extends State<ScanQRScreen> {
return const SizedBox(); return const SizedBox();
} }
return CustomPaint( return CustomPaint(painter: ScannerOverlay(scanWindow: scanWindow));
painter: ScannerOverlay(scanWindow: scanWindow),
);
}, },
), ),
Align( Align(
@ -63,15 +62,14 @@ class _ScanQRScreenState extends State<ScanQRScreen> {
), ),
), ),
), ),
])); ],
),
);
} }
} }
class ScannerOverlay extends CustomPainter { class ScannerOverlay extends CustomPainter {
const ScannerOverlay({ const ScannerOverlay({required this.scanWindow, this.borderRadius = 12.0});
required this.scanWindow,
this.borderRadius = 12.0,
});
final Rect scanWindow; final Rect scanWindow;
final double borderRadius; final double borderRadius;
@ -81,32 +79,30 @@ class ScannerOverlay extends CustomPainter {
// we need to pass the size to the custom paint widget // we need to pass the size to the custom paint widget
final backgroundPath = Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height)); final backgroundPath = Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height));
final cutoutPath = Path() final cutoutPath =
..addRRect( Path()..addRRect(
RRect.fromRectAndCorners( RRect.fromRectAndCorners(
scanWindow, scanWindow,
topLeft: Radius.circular(borderRadius), topLeft: Radius.circular(borderRadius),
topRight: Radius.circular(borderRadius), topRight: Radius.circular(borderRadius),
bottomLeft: Radius.circular(borderRadius), bottomLeft: Radius.circular(borderRadius),
bottomRight: Radius.circular(borderRadius), bottomRight: Radius.circular(borderRadius),
), ),
); );
final backgroundPaint = Paint() final backgroundPaint =
..color = Colors.black.withOpacity(0.5) Paint()
..style = PaintingStyle.fill ..color = Colors.black.withValues(alpha: 0.5)
..blendMode = BlendMode.srcOver; ..style = PaintingStyle.fill
..blendMode = BlendMode.srcOver;
final backgroundWithCutout = Path.combine( final backgroundWithCutout = Path.combine(PathOperation.difference, backgroundPath, cutoutPath);
PathOperation.difference,
backgroundPath,
cutoutPath,
);
final borderPaint = Paint() final borderPaint =
..color = Colors.white Paint()
..style = PaintingStyle.stroke ..color = Colors.white
..strokeWidth = 4.0; ..style = PaintingStyle.stroke
..strokeWidth = 4.0;
final borderRect = RRect.fromRectAndCorners( final borderRect = RRect.fromRectAndCorners(
scanWindow, scanWindow,
@ -214,14 +210,7 @@ class ToggleFlashlightButton extends StatelessWidget {
}, },
); );
case TorchState.unavailable: case TorchState.unavailable:
return const SizedBox.square( return const SizedBox.square(dimension: 48.0, child: Icon(Icons.no_flash, size: 32.0, color: Colors.grey));
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 //TODO: Enforce a name
class SiteConfigScreen extends StatefulWidget { class SiteConfigScreen extends StatefulWidget {
const SiteConfigScreen({ const SiteConfigScreen({super.key, this.site, required this.onSave, required this.supportsQRScanning});
Key? key,
this.site,
required this.onSave,
required this.supportsQRScanning,
}) : super(key: key);
final Site? site; final Site? site;
@ -71,42 +66,45 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (pubKey == null || privKey == null) { if (pubKey == null || privKey == null) {
return Center( return Center(
child: fpw.PlatformCircularProgressIndicator(cupertino: (_, __) { child: fpw.PlatformCircularProgressIndicator(
return fpw.CupertinoProgressIndicatorData(radius: 50); cupertino: (_, __) {
}), return fpw.CupertinoProgressIndicatorData(radius: 50);
},
),
); );
} }
return FormPage( return FormPage(
title: newSite ? 'New Site' : 'Edit Site', title: newSite ? 'New Site' : 'Edit Site',
changed: changed, changed: changed,
onSave: () async { onSave: () async {
site.name = nameController.text; site.name = nameController.text;
try { try {
await site.save(); await site.save();
} catch (error) { } catch (error) {
return Utils.popError(context, 'Failed to save the site configuration', error.toString()); return Utils.popError(context, 'Failed to save the site configuration', error.toString());
} }
Navigator.pop(context); Navigator.pop(context);
widget.onSave(site); widget.onSave(site);
}, },
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
_main(), _main(),
_keys(), _keys(),
_hosts(), _hosts(),
_advanced(), _advanced(),
_managed(), _managed(),
kDebugMode ? _debugConfig() : Container(height: 0), kDebugMode ? _debugConfig() : Container(height: 0),
], ],
)); ),
);
} }
Widget _debugConfig() { Widget _debugConfig() {
var data = ""; var data = "";
try { try {
final encoder = new JsonEncoder.withIndent(' '); final encoder = JsonEncoder.withIndent(' ');
data = encoder.convert(site); data = encoder.convert(site);
} catch (err) { } catch (err) {
data = err.toString(); data = err.toString();
@ -116,8 +114,9 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
} }
Widget _main() { Widget _main() {
return ConfigSection(children: <Widget>[ return ConfigSection(
ConfigItem( children: <Widget>[
ConfigItem(
label: Text("Name"), label: Text("Name"),
content: PlatformTextFormField( content: PlatformTextFormField(
placeholder: 'Required', placeholder: 'Required',
@ -128,8 +127,10 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
} }
return null; return null;
}, },
)) ),
]); ),
],
);
} }
Widget _managed() { Widget _managed() {
@ -140,15 +141,19 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
} }
return site.managed return site.managed
? ConfigSection(label: "MANAGED CONFIG", children: <Widget>[ ? ConfigSection(
label: "MANAGED CONFIG",
children: <Widget>[
ConfigItem( ConfigItem(
label: Text("Last Update"), label: Text("Last Update"),
content: content: Wrap(
Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[ alignment: WrapAlignment.end,
Text(lastUpdate), crossAxisAlignment: WrapCrossAlignment.center,
]), children: <Widget>[Text(lastUpdate)],
) ),
]) ),
],
)
: Container(); : Container();
} }
@ -156,13 +161,13 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
final certError = site.certInfo == null || site.certInfo!.validity == null || !site.certInfo!.validity!.valid; final certError = site.certInfo == null || site.certInfo!.validity == null || !site.certInfo!.validity!.valid;
var caError = false; var caError = false;
if (!site.managed) { if (!site.managed) {
caError = site.ca.length == 0; caError = site.ca.isEmpty;
if (!caError) { if (!caError) {
site.ca.forEach((ca) { for (var ca in site.ca) {
if (ca.validity == null || !ca.validity!.valid) { if (ca.validity == null || !ca.validity!.valid) {
caError = true; caError = true;
} }
}); }
} }
} }
@ -171,14 +176,19 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
children: [ children: [
ConfigPageItem( ConfigPageItem(
label: Text('Certificate'), label: Text('Certificate'),
content: Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[ content: Wrap(
certError alignment: WrapAlignment.end,
? Padding( crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
certError
? Padding(
padding: EdgeInsets.only(right: 5),
child: Icon(Icons.error, color: CupertinoColors.systemRed.resolveFrom(context), size: 20), child: Icon(Icons.error, color: CupertinoColors.systemRed.resolveFrom(context), size: 20),
padding: EdgeInsets.only(right: 5)) )
: Container(), : Container(),
certError ? Text('Needs attention') : Text(site.certInfo?.cert.details.name ?? 'Unknown certificate') certError ? Text('Needs attention') : Text(site.certInfo?.cert.details.name ?? 'Unknown certificate'),
]), ],
),
onPressed: () { onPressed: () {
Utils.openPage(context, (context) { Utils.openPage(context, (context) {
if (site.certInfo != null) { if (site.certInfo != null) {
@ -186,15 +196,16 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
certInfo: site.certInfo!, certInfo: site.certInfo!,
pubKey: pubKey, pubKey: pubKey,
privKey: privKey, privKey: privKey,
onReplace: site.managed onReplace:
? null site.managed
: (result) { ? null
setState(() { : (result) {
changed = true; setState(() {
site.certInfo = result.certInfo; changed = true;
site.key = result.key; site.certInfo = result.certInfo;
}); site.key = result.key;
}, });
},
supportsQRScanning: widget.supportsQRScanning, supportsQRScanning: widget.supportsQRScanning,
); );
} }
@ -215,32 +226,38 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
}, },
), ),
ConfigPageItem( ConfigPageItem(
label: Text("CA"), label: Text("CA"),
content: content: Wrap(
Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[ alignment: WrapAlignment.end,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
caError caError
? Padding( ? 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(), : Container(),
caError ? Text('Needs attention') : Text(Utils.itemCountFormat(site.ca.length)) caError ? Text('Needs attention') : Text(Utils.itemCountFormat(site.ca.length)),
]), ],
onPressed: () { ),
Utils.openPage(context, (context) { onPressed: () {
return CAListScreen( Utils.openPage(context, (context) {
cas: site.ca, return CAListScreen(
onSave: site.managed cas: site.ca,
? null onSave:
: (ca) { site.managed
? null
: (ca) {
setState(() { setState(() {
changed = true; changed = true;
site.ca = ca; site.ca = ca;
}); });
}, },
supportsQRScanning: widget.supportsQRScanning, supportsQRScanning: widget.supportsQRScanning,
); );
}); });
}) },
),
], ],
); );
} }
@ -251,28 +268,35 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
children: <Widget>[ children: <Widget>[
ConfigPageItem( ConfigPageItem(
label: Text('Hosts'), label: Text('Hosts'),
content: Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[ content: Wrap(
site.staticHostmap.length == 0 alignment: WrapAlignment.end,
? Padding( 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), child: Icon(Icons.error, color: CupertinoColors.systemRed.resolveFrom(context), size: 20),
padding: EdgeInsets.only(right: 5)) )
: Container(), : Container(),
site.staticHostmap.length == 0 site.staticHostmap.isEmpty
? Text('Needs attention') ? Text('Needs attention')
: Text(Utils.itemCountFormat(site.staticHostmap.length)) : Text(Utils.itemCountFormat(site.staticHostmap.length)),
]), ],
),
onPressed: () { onPressed: () {
Utils.openPage(context, (context) { Utils.openPage(context, (context) {
return StaticHostsScreen( return StaticHostsScreen(
hostmap: site.staticHostmap, hostmap: site.staticHostmap,
onSave: site.managed onSave:
? null site.managed
: (map) { ? null
: (map) {
setState(() { setState(() {
changed = true; changed = true;
site.staticHostmap = map; site.staticHostmap = map;
}); });
}); },
);
}); });
}, },
), ),
@ -285,24 +309,26 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
label: "ADVANCED", label: "ADVANCED",
children: <Widget>[ children: <Widget>[
ConfigPageItem( ConfigPageItem(
label: Text('Advanced'), label: Text('Advanced'),
onPressed: () { onPressed: () {
Utils.openPage(context, (context) { Utils.openPage(context, (context) {
return AdvancedScreen( return AdvancedScreen(
site: site, site: site,
onSave: (settings) { onSave: (settings) {
setState(() { setState(() {
changed = true; changed = true;
site.cipher = settings.cipher; site.cipher = settings.cipher;
site.lhDuration = settings.lhDuration; site.lhDuration = settings.lhDuration;
site.port = settings.port; site.port = settings.port;
site.logVerbosity = settings.verbosity; site.logVerbosity = settings.verbosity;
site.unsafeRoutes = settings.unsafeRoutes; site.unsafeRoutes = settings.unsafeRoutes;
site.mtu = settings.mtu; site.mtu = settings.mtu;
}); });
}); },
}); );
}) });
},
),
], ],
); );
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -20,11 +20,14 @@ class Storage {
var completer = Completer<List<FileSystemEntity>>(); var completer = Completer<List<FileSystemEntity>>();
Directory(parent).list().listen((FileSystemEntity entity) { Directory(parent)
list.add(entity); .list()
}).onDone(() { .listen((FileSystemEntity entity) {
completer.complete(list); list.add(entity);
}); })
.onDone(() {
completer.complete(list);
});
return completer.future; 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/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:google_fonts/google_fonts.dart';
class Utils { class Utils {
/// Minimum size (width or height) of a interactive component /// Minimum size (width or height) of a interactive component
@ -26,37 +27,32 @@ class Utils {
} }
static Size textSize(String text, TextStyle style) { static Size textSize(String text, TextStyle style) {
final TextPainter textPainter = final TextPainter textPainter = TextPainter(
TextPainter(text: TextSpan(text: text, style: style), maxLines: 1, textDirection: TextDirection.ltr) text: TextSpan(text: text, style: style),
..layout(minWidth: 0, maxWidth: double.infinity); maxLines: 1,
textDirection: TextDirection.ltr,
)..layout(minWidth: 0, maxWidth: double.infinity);
return textPainter.size; return textPainter.size;
} }
static openPage(BuildContext context, WidgetBuilder pageToDisplayBuilder) { static openPage(BuildContext context, WidgetBuilder pageToDisplayBuilder) {
Navigator.push( Navigator.push(context, platformPageRoute(context: context, builder: pageToDisplayBuilder));
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) { 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. /// Builds a simple leading widget that pops the current screen.
/// Provide your own onPressed to override that behavior, just remember you have to pop /// Provide your own onPressed to override that behavior, just remember you have to pop
static Widget leadingBackWidget(BuildContext context, {label = 'Back', Function? onPressed}) { static Widget leadingBackWidget(BuildContext context, {label = 'Back', Function? onPressed}) {
if (Platform.isIOS) { if (Platform.isIOS) {
return CupertinoButton( return CupertinoNavigationBarBackButton(
child: Row(children: <Widget>[Icon(context.platformIcons.back), Text(label)]), previousPageTitle: label,
padding: EdgeInsets.zero,
onPressed: () { onPressed: () {
if (onPressed == null) { if (onPressed == null) {
Navigator.pop(context); Navigator.pop(context);
@ -82,44 +78,48 @@ class Utils {
} }
static Widget trailingSaveWidget(BuildContext context, Function onPressed) { static Widget trailingSaveWidget(BuildContext context, Function onPressed) {
return CupertinoButton( return PlatformTextButton(
child: Text('Save', padding: Platform.isAndroid ? null : EdgeInsets.zero,
style: TextStyle( onPressed: () => onPressed(),
fontWeight: FontWeight.bold, child: Text('Save'),
//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());
} }
/// Simple cross platform delete confirmation dialog - can also be used to confirm throwing away a change by swapping the deleteLabel /// 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, static confirmDelete(
{String deleteLabel = 'Delete', String cancelLabel = 'Cancel'}) { BuildContext context,
String title,
Function onConfirm, {
String deleteLabel = 'Delete',
String cancelLabel = 'Cancel',
}) {
showDialog<void>( showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (context) { builder: (context) {
return PlatformAlertDialog( return PlatformAlertDialog(
title: Text(title), title: Text(title),
actions: <Widget>[ actions: <Widget>[
PlatformDialogAction( PlatformDialogAction(
child: Text(deleteLabel, child: Text(
style: deleteLabel,
TextStyle(fontWeight: FontWeight.bold, color: CupertinoColors.systemRed.resolveFrom(context))), style: TextStyle(fontWeight: FontWeight.bold, color: CupertinoColors.systemRed.resolveFrom(context)),
onPressed: () {
Navigator.pop(context);
onConfirm();
},
), ),
PlatformDialogAction( onPressed: () {
child: Text(cancelLabel), Navigator.pop(context);
onPressed: () { onConfirm();
Navigator.of(context).pop(); },
}, ),
) PlatformDialogAction(
], child: Text(cancelLabel),
); onPressed: () {
}); Navigator.of(context).pop();
},
),
],
);
},
);
} }
static popError(BuildContext context, String title, String error, {StackTrace? stack}) { static popError(BuildContext context, String title, String error, {StackTrace? stack}) {
@ -128,33 +128,38 @@ class Utils {
} }
showDialog( showDialog(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (context) { builder: (context) {
if (Platform.isAndroid) { if (Platform.isAndroid) {
return AlertDialog(title: Text(title), content: Text(error), actions: <Widget>[ return AlertDialog(
title: Text(title),
content: Text(error),
actions: <Widget>[
TextButton( TextButton(
child: Text('Ok'), child: Text('Ok'),
onPressed: () { onPressed: () {
Navigator.of(context).pop(); 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 { static launchUrl(String url, BuildContext context) async {
@ -180,4 +185,19 @@ class Utils {
final file = File(result.files.first.path!); final file = File(result.files.first.path!);
return file.readAsString(); 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; return false;
} }
List parts = str.split('.'); List<String> parts = str.split('.');
if (requireTld) { if (requireTld) {
var tld = parts.removeLast(); var tld = parts.removeLast();
if (parts.isEmpty || !RegExp(r'^[a-z]{2,}$').hasMatch(tld)) { 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/DefinedNet/dnapi v0.0.0-20241212205635-1d1f0084d118
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/slackhq/nebula v1.9.5 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 gopkg.in/yaml.v2 v2.4.0
) )
@ -39,15 +39,16 @@ require (
github.com/vishvananda/netlink v1.3.0 // indirect github.com/vishvananda/netlink v1.3.0 // indirect
github.com/vishvananda/netns v0.0.4 // indirect github.com/vishvananda/netns v0.0.4 // indirect
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // 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/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/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.27.0 // indirect golang.org/x/term v0.28.0 // indirect
golang.org/x/tools 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/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
google.golang.org/protobuf v1.35.2 // 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= 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 h1:UvJ/1ox4SC2iVeARN1p6ZfzyqMIHMDcXyHG6VP+Amo8=
github.com/DefinedNet/dnapi v0.0.0-20241212205635-1d1f0084d118/go.mod h1:6LGHsBXaix2kyOPPrmGbzarKSedzR36P941h04+mkkM= 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-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/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= 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.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 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 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8=
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= 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= 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-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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 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.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 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 h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= 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/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.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.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.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-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-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.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.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= 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/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-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/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.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.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.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.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 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.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.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 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-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-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.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= 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-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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/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 dependency: transitive
description: description:
name: async name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.0" version: "2.12.0"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
name: boolean_selector name: boolean_selector
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "2.1.2"
characters: characters:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.4.0"
clock: clock:
dependency: transitive dependency: transitive
description: description:
name: clock name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.2"
collection: collection:
dependency: transitive dependency: transitive
description: description:
name: collection name: collection
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.0" version: "1.19.1"
convert: convert:
dependency: transitive dependency: transitive
description: description:
@ -89,14 +89,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" 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: fake_async:
dependency: transitive dependency: transitive
description: description:
name: fake_async name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.1" version: "1.3.2"
ffi: ffi:
dependency: transitive dependency: transitive
description: description:
@ -134,6 +142,22 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: flutter_platform_widgets:
dependency: "direct main" dependency: "direct main"
description: description:
@ -164,7 +188,7 @@ packages:
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_web_plugins: flutter_web_plugins:
dependency: transitive dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
@ -176,6 +200,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" 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: http:
dependency: transitive dependency: transitive
description: description:
@ -208,22 +240,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.19.0" 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: leak_tracker:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.7" version: "10.0.8"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.8" version: "3.0.9"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
@ -232,14 +272,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" version: "3.0.1"
lints:
dependency: transitive
description:
name: lints
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
url: "https://pub.dev"
source: hosted
version: "5.1.1"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.16+1" version: "0.12.17"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@ -252,10 +300,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.15.0" version: "1.16.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@ -289,13 +337,13 @@ packages:
source: hosted source: hosted
version: "3.0.2" version: "3.0.2"
path: path:
dependency: transitive dependency: "direct main"
description: description:
name: path name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.0" version: "1.9.1"
path_parsing: path_parsing:
dependency: transitive dependency: transitive
description: description:
@ -449,10 +497,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: source_span name: source_span
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.0" version: "1.10.1"
sprintf: sprintf:
dependency: transitive dependency: transitive
description: description:
@ -465,26 +513,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: stack_trace name: stack_trace
sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.12.0" version: "1.12.1"
stream_channel: stream_channel:
dependency: transitive dependency: transitive
description: description:
name: stream_channel name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.4"
string_scanner: string_scanner:
dependency: transitive dependency: transitive
description: description:
name: string_scanner name: string_scanner
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.4.1"
system_info2: system_info2:
dependency: transitive dependency: transitive
description: description:
@ -497,18 +545,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: term_glyph name: term_glyph
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.1" version: "1.2.2"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.3" version: "0.7.4"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@ -569,18 +617,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_web name: url_launcher_web
sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.3" version: "2.4.0"
url_launcher_windows: url_launcher_windows:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_windows name: url_launcher_windows
sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4" sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.3" version: "3.1.4"
uuid: uuid:
dependency: "direct main" dependency: "direct main"
description: description:
@ -625,10 +673,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.3.0" version: "14.3.1"
web: web:
dependency: transitive dependency: transitive
description: description:
@ -670,5 +718,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.5.1 <4.0.0" dart: ">=3.7.0 <4.0.0"
flutter: ">=3.24.0" flutter: ">=3.27.0"

View file

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