Compare commits
601 Commits
0.0.201901
...
master
Author | SHA1 | Date | |
---|---|---|---|
00702ecbec | |||
2fec12a6e1 | |||
7b279383d1 | |||
901fe1cf58 | |||
ccc7472fd7 | |||
12b095470a | |||
9c07693951 | |||
23618f994f | |||
ba644415c7 | |||
10da5cfdef | |||
75b6925deb | |||
03a59ff38e | |||
abf506c1fe | |||
dfb685f258 | |||
f3798d0e11 | |||
86afd1a46a | |||
7171df84fa | |||
d882a486a9 | |||
adcbd17ebe | |||
3d8de22b96 | |||
ba4d1e7b21 | |||
f5a14b8434 | |||
b74eb7239a | |||
a8226b35d2 | |||
73c708d902 | |||
3668f3af9f | |||
54697a3240 | |||
3428bfbc9e | |||
cfd1b16801 | |||
ca70fe9ddc | |||
55c587b443 | |||
b6831c1aca | |||
2ac17da7cb | |||
274c4cd092 | |||
95e1409bfb | |||
2c2c53b1f8 | |||
9cbfec99df | |||
1bd6dcb7e7 | |||
c1fe8b0162 | |||
64c2fb337d | |||
147ac02f0d | |||
03ef79c0fd | |||
a261d84fc6 | |||
abaf1f1454 | |||
1e9e21bacf | |||
ac9f7b9f5e | |||
a115dd3bd9 | |||
df9934a4b8 | |||
40f18de4d2 | |||
13b720442d | |||
c1f509d65b | |||
87f0526f09 | |||
060c027325 | |||
23bf3cfccb | |||
7f5ad3e503 | |||
820fa55380 | |||
eb528c766b | |||
53235eb38f | |||
b9ff5c2e94 | |||
b7f69d20b6 | |||
6c4f4109eb | |||
7b5b564a6e | |||
695f868b1f | |||
e724c043d9 | |||
491301f58b | |||
c4f79beb8d | |||
a613fec2ff | |||
e54a5d9a13 | |||
6d57c8b6f9 | |||
b67acaccff | |||
d8568b0e31 | |||
373bb2ae99 | |||
631286e2d1 | |||
74cd7041dc | |||
21d920c8b0 | |||
44c4df1cd5 | |||
a4fc0f64b8 | |||
9269c7c1c1 | |||
403ee63615 | |||
b622fde291 | |||
386fe4eb12 | |||
49b7d083f1 | |||
db4e2915f3 | |||
20bdf46792 | |||
4ded3f6bfe | |||
b51113f680 | |||
be96dea04a | |||
a786f5df60 | |||
9a483a46fa | |||
5d2a337332 | |||
facf776602 | |||
d3400e3a80 | |||
92517bd21e | |||
761f635e16 | |||
44704ba892 | |||
9231c03513 | |||
27b32e60b2 | |||
9d5b376dcf | |||
8fd4883d7e | |||
d414cec9aa | |||
54e3333b72 | |||
9f8d0e24df | |||
3de7c99301 | |||
d4fd17cd8f | |||
d875266db5 | |||
d696e31b6e | |||
90acf2b220 | |||
27ef0c6dba | |||
8f67435d4a | |||
b4ebe2440f | |||
d440a91b0e | |||
9e909a3294 | |||
75bcf97ab2 | |||
0edde8b46f | |||
bcc34e0bb6 | |||
90b41aed89 | |||
7930b94981 | |||
54a89f6a0e | |||
8976a53b05 | |||
9849dedf1d | |||
547077a808 | |||
0b0898dc3c | |||
9f9d1ffed8 | |||
5a044e4129 | |||
35f0ada8a9 | |||
39e7ee07ac | |||
5a1c9598f1 | |||
101a45b6f1 | |||
fd527f73e6 | |||
de6aa3eb58 | |||
c9eafd82ac | |||
ec57408570 | |||
9c38a1b897 | |||
b34625f511 | |||
2e356d3d8f | |||
697d449dc8 | |||
4f9f61f7a7 | |||
bceb0a827d | |||
2329f712cf | |||
d2c38702c8 | |||
def921801f | |||
41e006a407 | |||
384b514290 | |||
a6858bd126 | |||
ef7de2500f | |||
6099975b71 | |||
828756e8ba | |||
4deaf905c1 | |||
76c8487a56 | |||
a05f1233f9 | |||
95b833c754 | |||
8c057bf928 | |||
ddf8ade9c6 | |||
4cb21b5eb0 | |||
a03df7d8cc | |||
57f66f16f8 | |||
737f847c0d | |||
671a594945 | |||
3646430528 | |||
e9bd6e576f | |||
35300d1c5f | |||
112545248e | |||
78e6ecc4bc | |||
174a6e8e32 | |||
20bcabbca4 | |||
0a3554cedd | |||
2acc7db63d | |||
31af7049fc | |||
52062a45c1 | |||
30406dec6d | |||
edde27a0a0 | |||
cfff596c30 | |||
ba1c968cdf | |||
c48406ac38 | |||
14437477e6 | |||
68d928192b | |||
028e76eb3f | |||
cb0c965294 | |||
d7ce621cb2 | |||
a1ca4f6eb5 | |||
437f0dc46d | |||
547eabb4ae | |||
bb16d3ebc8 | |||
1b6170cbc9 | |||
4c37a4b7a7 | |||
226166bdaf | |||
80fa72cabc | |||
d976d159d0 | |||
84ca7fcf40 | |||
f120a6aab0 | |||
0d8108d8da | |||
e072ebee58 | |||
c6767d9007 | |||
bb5760cca4 | |||
26b7971ba6 | |||
b286ede3c6 | |||
4ef7afe3ca | |||
377f2f0496 | |||
7ed5893fc6 | |||
e70c397e54 | |||
6a6be9edde | |||
37f8500fe6 | |||
207f82dd9d | |||
de8fedf87a | |||
98d306da5b | |||
c7b7b1247b | |||
a66f13eb01 | |||
f50e7ae686 | |||
4d6692548c | |||
1dccd39818 | |||
4cb775c72f | |||
4cb783c447 | |||
d20daa345a | |||
168ba2da8a | |||
714d6a41bd | |||
9b92a8f933 | |||
5e9780ef8f | |||
9faf814e8b | |||
30da10a0e9 | |||
a18614d6b3 | |||
5100e597aa | |||
0340641c4c | |||
813dea6902 | |||
c30d491edc | |||
88c80d6694 | |||
393718dfaf | |||
f852b6f919 | |||
8926434682 | |||
493c7b102e | |||
717bc8a26f | |||
e582155a10 | |||
70d19691a7 | |||
40b1f0bac8 | |||
52ac9b82c2 | |||
fc1fdbbcdb | |||
300268daa0 | |||
9bf304a9ac | |||
586a592b68 | |||
c0526d2efb | |||
fdbd4f875e | |||
4a037cc706 | |||
5190fc2249 | |||
3f25d54dcc | |||
f9880907a2 | |||
404fa741e8 | |||
6e1f03e41c | |||
6d8965e97d | |||
5e5481b69b | |||
69b33c0fad | |||
167e4f0bf2 | |||
6e3b28852a | |||
5914e868ab | |||
83ea9d6fa7 | |||
b954e9a4fd | |||
76894fba68 | |||
89a564ce62 | |||
178fe86d36 | |||
571349bb3d | |||
98ebd55208 | |||
83d0d34411 | |||
8c7c4b6792 | |||
5b34f49166 | |||
cef3957875 | |||
d9e88c51bd | |||
db876647d6 | |||
90eb45e287 | |||
9f3d86723a | |||
11063d0f88 | |||
557ee4390b | |||
9ce42d152d | |||
4c1b2e1258 | |||
3bd611aa7c | |||
adbe0b065e | |||
6015661beb | |||
6e6a6b88fb | |||
9690365dd4 | |||
0299c3929e | |||
0043233872 | |||
a74dd24578 | |||
dd9506ecee | |||
0c2eb003a0 | |||
f83f159f97 | |||
6175de0438 | |||
bd61be52e6 | |||
909f88be70 | |||
b7c3bd0d8c | |||
4237ab4a6f | |||
0fcaf6debb | |||
dbd5ea1ff0 | |||
9afe230c10 | |||
4bdfbb518e | |||
fbe101eabb | |||
cda3170970 | |||
a5e7c3906b | |||
b21fdfed67 | |||
5f8843e247 | |||
7a3f65fd2f | |||
dca0fb29f6 | |||
af9c800af8 | |||
a9b925c69b | |||
f93f9d62f4 | |||
fc163fc9ff | |||
adc5a7cac2 | |||
0dd22ca45a | |||
bca9fead5e | |||
51822f722a | |||
121d223229 | |||
439fb6bbac | |||
9c8231dcf7 | |||
0440c4a33a | |||
01be43aa7a | |||
e29c6900e5 | |||
a334c25aff | |||
f56b2ad968 | |||
503ac6c8a2 | |||
5f30e021ef | |||
d748382fce | |||
63299a2752 | |||
b7f8f74b56 | |||
8e5a9215de | |||
64925cab89 | |||
062b4d4b16 | |||
d9bdc61fb9 | |||
0ae8d25134 | |||
574d8433b3 | |||
bd339e2876 | |||
fff75adfe1 | |||
01604dd8d1 | |||
bdeb89a9e5 | |||
36dc252512 | |||
5941bf181c | |||
7a450089c0 | |||
5d757982ba | |||
3767a12983 | |||
9795b0609a | |||
0f98312d15 | |||
f81275812c | |||
b2b5e0e379 | |||
a6f80135ef | |||
e23c221aff | |||
50bc994762 | |||
3e05da4486 | |||
c750f28c67 | |||
f6c70500a7 | |||
663923864c | |||
9250780ffc | |||
9bc17034dd | |||
db6f0729c6 | |||
4503c11b0c | |||
fe4f8b666d | |||
90c0f7e92e | |||
3afcee04be | |||
202e7a4890 | |||
d7b16ffb1f | |||
b1dabf5a00 | |||
a389bd93cb | |||
b2a2110d8c | |||
5ed28907ec | |||
ab6d714070 | |||
d3df8734c2 | |||
ea5996abe0 | |||
ce405f856e | |||
98a967acc8 | |||
b01d09dfb5 | |||
7a580e8941 | |||
39fb52a2e3 | |||
69a064d954 | |||
eb684ef711 | |||
b0eff424f9 | |||
c195760b15 | |||
ba3f0db92c | |||
5031a7db4c | |||
a355232e09 | |||
6f7214ff38 | |||
4c88f477a2 | |||
2fb9d6af71 | |||
38ac66071c | |||
910fdfc321 | |||
c38a88988b | |||
cf51344444 | |||
fda36b571d | |||
1be3224d7a | |||
ca088e9ddc | |||
fcca2d4fec | |||
58181a4d40 | |||
cf816ede13 | |||
fbac282bdc | |||
61f5e017c8 | |||
4547e01283 | |||
5792db22a6 | |||
9d5aa1d8fa | |||
6331b81b5d | |||
77f929789c | |||
b5b72b309f | |||
966fa7909b | |||
115059f2bb | |||
e53c2d4d17 | |||
0a3a5ee900 | |||
7720307fc9 | |||
ea827e2ebd | |||
91b1734b7a | |||
bac4851e95 | |||
0e2556544e | |||
2ec51ba8cf | |||
998bbd73e4 | |||
38a6ba7091 | |||
407b367c8d | |||
a231410c52 | |||
f518c00722 | |||
0539929d0c | |||
05547861b6 | |||
9eed5fd898 | |||
1b8b9ed7ee | |||
ef6af03412 | |||
a99a755c34 | |||
ecd66defe5 | |||
1f3ec042e0 | |||
631e9bb70d | |||
446c3e3698 | |||
02e9172940 | |||
8676f3a663 | |||
394a0cbeb0 | |||
868fee0477 | |||
0cddb562fc | |||
bebcaa012b | |||
ed8dc516dc | |||
8c3557a907 | |||
a26d620f11 | |||
30a73a75fd | |||
71d26b4122 | |||
71525c9d4e | |||
02a96d4566 | |||
466db151b8 | |||
1be133f269 | |||
80de2ac6ac | |||
8a6a60482c | |||
657ec34d19 | |||
f7a31ca7bb | |||
3c61db3a21 | |||
618d89941a | |||
cbc602245e | |||
ca61e09536 | |||
4ff6105053 | |||
4134baced1 | |||
0c5739db82 | |||
1f51ff6b17 | |||
08e5d65045 | |||
1189b3d700 | |||
f292a0ec7a | |||
3b29578524 | |||
70ac48ceba | |||
acecc70397 | |||
b0bb2e993a | |||
d1f83d167e | |||
a796c6c485 | |||
6ad3487a9d | |||
eabeb8ff05 | |||
52eec55d36 | |||
3c80490273 | |||
c36a9e4ffd | |||
812e660491 | |||
2fe9f83ba5 | |||
22625e8cc4 | |||
ab3cbee6a2 | |||
fadef52e1b | |||
19f353127e | |||
e5a76be6fd | |||
76527ef0f8 | |||
11e44f9ae5 | |||
77d4a02139 | |||
54f45cb3f8 | |||
704de3b26c | |||
d05b735703 | |||
9f362e8cb0 | |||
2677efc9bf | |||
2aad8cf0e8 | |||
668c4a475c | |||
557b093232 | |||
6f9fa35c5a | |||
d37e230ee0 | |||
e812683d6c | |||
6d8e5cf3ed | |||
244a2df2dd | |||
0848765f50 | |||
8951ea338f | |||
273ee04450 | |||
e2b068af1a | |||
4e2f4e7124 | |||
6509009afc | |||
0b2a4c2811 | |||
e1198b6e08 | |||
d7a88300f6 | |||
b22aeaeb20 | |||
e0798b8a97 | |||
1bc2177883 | |||
15517c4c3d | |||
f6c9ce2d71 | |||
5aa0f1a25f | |||
602639ee25 | |||
d06887d435 | |||
470e4e7f7f | |||
86165d25f7 | |||
69b973efdc | |||
dc8f27c5c3 | |||
796342ddec | |||
b7a5b8eff1 | |||
8a0245190c | |||
98775bf8a0 | |||
7d5eb476e4 | |||
c477d24d67 | |||
bf9cb092a9 | |||
dbd5108475 | |||
47c5f23a0a | |||
a2871e63a7 | |||
811714e21a | |||
f63c9fd598 | |||
e29cf19fdd | |||
26ea353933 | |||
cd5427ef92 | |||
595f7943e2 | |||
55f022688d | |||
96bd50504b | |||
97fec0d992 | |||
470532d146 | |||
321b88864c | |||
ba731e0099 | |||
70848c04de | |||
13e8c6b178 | |||
53e915c578 | |||
bc8ea55023 | |||
e0af06844d | |||
8980b5a524 | |||
df8ab96139 | |||
4a8366421f | |||
c9ee549a2e | |||
f5059ce55b | |||
5a73244ec9 | |||
922b6f76b2 | |||
fc9e2de72c | |||
80977b95de | |||
bbeb732ef3 | |||
94c4922913 | |||
fc03c635c1 | |||
b0612df990 | |||
c2a6241b5c | |||
96fa6d3ba6 | |||
64fe415879 | |||
59bfa7f1df | |||
c2633987c3 | |||
f7b2f73015 | |||
c72f7056b3 | |||
cb778fe7e0 | |||
f3c2904241 | |||
df8b400850 | |||
252d940d34 | |||
efb64b1959 | |||
dfc4b37518 | |||
60cfceec4f | |||
361830a69e | |||
f6ea25573b | |||
de12c27d5b | |||
a221cb566b | |||
f33cd0b6fd | |||
38bb0faf86 | |||
8d9c5e2950 | |||
09f4be17de | |||
60e18dfdd5 | |||
5bc0c5b2b4 | |||
4a4eeb4a21 | |||
ada7db3dca | |||
c946c0ea48 | |||
4a4690b5fa | |||
37fce31d16 | |||
7934d6b0c7 | |||
98e9088aba | |||
2c81c3a379 | |||
04f6ee0f11 | |||
545f8c88f4 | |||
6a27626fc0 | |||
fb1607d4a2 | |||
51a2c272b9 | |||
b5751b6321 | |||
110012dbcc | |||
5c7a149167 | |||
629009d3be | |||
d7d4355f5e | |||
55d6961a2f | |||
c8cd663a05 | |||
a754c4d7ab | |||
95415cd917 | |||
b32b897181 | |||
d5c1acb57e | |||
573f9640de | |||
f6772dc353 | |||
0cbe66df99 | |||
3cd33ebe8f | |||
c7a40d3cb0 | |||
d02b0fd10e | |||
09d7a5229a | |||
1ca5407b85 | |||
10982a57ef |
9
.gitignore
vendored
@ -31,6 +31,10 @@ xcuserdata
|
||||
*.hmap
|
||||
*.ipa
|
||||
|
||||
# Swift Package Manager
|
||||
.swiftpm
|
||||
.build/
|
||||
|
||||
# Fastlane
|
||||
*.app.dSYM.zip
|
||||
*.mobileprovision
|
||||
@ -41,4 +45,7 @@ Preview.html
|
||||
output
|
||||
|
||||
# Wireguard specific
|
||||
WireGuard/WireGuard/Config/Developer.xcconfig
|
||||
Sources/WireGuardApp/Config/Developer.xcconfig
|
||||
|
||||
# Vim
|
||||
.*.sw*
|
||||
|
3
.gitmodules
vendored
@ -1,3 +0,0 @@
|
||||
[submodule "wireguard-go"]
|
||||
path = wireguard-go
|
||||
url = https://git.zx2c4.com/wireguard-go
|
@ -2,6 +2,12 @@ disabled_rules:
|
||||
- line_length
|
||||
- trailing_whitespace
|
||||
- todo
|
||||
- cyclomatic_complexity
|
||||
- file_length
|
||||
- type_body_length
|
||||
- function_body_length
|
||||
- nesting
|
||||
- inclusive_language
|
||||
opt_in_rules:
|
||||
- empty_count
|
||||
- empty_string
|
||||
@ -14,11 +20,7 @@ opt_in_rules:
|
||||
- toggle_bool
|
||||
- unneeded_parentheses_in_closure_argument
|
||||
- unused_import
|
||||
# - trailing_closure
|
||||
file_length:
|
||||
warning: 500
|
||||
cyclomatic_complexity:
|
||||
warning: 10
|
||||
error: 25
|
||||
function_body_length:
|
||||
warning: 45
|
||||
- trailing_closure
|
||||
identifier_name:
|
||||
min_length:
|
||||
warning: 0
|
2
COPYING
@ -1,4 +1,4 @@
|
||||
Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
|
140
MOBILECONFIG.md
Normal file
@ -0,0 +1,140 @@
|
||||
# Installing WireGuard tunnels using Configuration Profiles
|
||||
|
||||
WireGuard configurations can be installed using Configuration Profiles
|
||||
through .mobileconfig files.
|
||||
|
||||
### Top-level payload entries
|
||||
|
||||
A .mobileconfig file is a plist file in XML format. The top-level XML item is a top-level payload dictionary (dict). This payload dictionary should contain the following keys:
|
||||
|
||||
- `PayloadDisplayName` (string): The name of the configuration profile, visible when installing the profile
|
||||
|
||||
- `PayloadType` (string): Should be `Configuration`
|
||||
|
||||
- `PayloadVersion` (integer): Should be `1`
|
||||
|
||||
- `PayloadIdentifier` (string): A reverse-DNS style unique identifier for the profile file.
|
||||
If you install another .mobileconfig file with the same identifier, the new one
|
||||
overwrites the old one.
|
||||
|
||||
- `PayloadUUID` (string): A randomly generated UUID for this payload
|
||||
|
||||
- `PayloadContent` (array): Should contain an array of payload dictionaries.
|
||||
Each of these payload dictionaries can represent a WireGuard tunnel
|
||||
configuration.
|
||||
|
||||
Here's an example .mobileconfig with the above fields filled in:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>WireGuard Demo Configuration Profile</string>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.your-org.wireguard.FCC9BF80-C540-44C1-B243-521FDD1B2905</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>F346AAF4-53A2-4FA1-ACA3-EEE74DBED029</string>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<!-- An array of WireGuard configuration payload dictionaries -->
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
### WireGuard payload entries
|
||||
|
||||
Each WireGuard configuration payload dictionary should contain the following
|
||||
keys:
|
||||
|
||||
- `PayloadDisplayName` (string): Should be `VPN`
|
||||
|
||||
- `PayloadType` (string): Should be `com.apple.vpn.managed`
|
||||
|
||||
- `PayloadVersion` (integer): Should be `1`
|
||||
|
||||
- `PayloadIdentifier` (string): A reverse-DNS style unique identifier for the WireGuard configuration profile.
|
||||
|
||||
- `PayloadUUID` (string): A randomly generated UUID for this payload
|
||||
|
||||
- `UserDefinedName` (string): The name of the WireGuard tunnel.
|
||||
This name shall be used to represent the tunnel in the WireGuard app, and in the System UI for VPNs (Settings > VPN on iOS, System Preferences > Network on macOS).
|
||||
|
||||
- `VPNType` (string): Should be `VPN`
|
||||
|
||||
- `VPNSubType` (string): Should be set as the bundle identifier of the WireGuard app.
|
||||
|
||||
- iOS: `com.wireguard.ios`
|
||||
- macOS: `com.wireguard.macos`
|
||||
|
||||
- `VendorConfig` (dict): Should be a dictionary with the following key:
|
||||
|
||||
- `WgQuickConfig` (string): Should be a WireGuard configuration in [wg-quick(8)] / [wg(8)] format.
|
||||
The keys 'FwMark', 'Table', 'PreUp', 'PostUp', 'PreDown', 'PostDown' and 'SaveConfig' are not supported.
|
||||
|
||||
- `VPN` (dict): Should be a dictionary with the following keys:
|
||||
|
||||
- `RemoteAddress` (string): A non-empty string.
|
||||
This string is displayed as the server name in the System UI for
|
||||
VPNs (Settings > VPN on iOS, System Preferences > Network on macOS).
|
||||
|
||||
- `AuthenticationMethod` (string): Should be `Password`
|
||||
|
||||
Here's an example WireGuard configuration payload dictionary:
|
||||
|
||||
```xml
|
||||
<!-- A WireGuard configuration payload dictionary -->
|
||||
<dict>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>VPN</string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.vpn.managed</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.your-org.wireguard.demo-profile-1.demo-tunnel</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>44CDFE9F-4DC7-472A-956F-61C68055117C</string>
|
||||
<key>UserDefinedName</key>
|
||||
<string>Demo from MobileConfig file</string>
|
||||
<key>VPNType</key>
|
||||
<string>VPN</string>
|
||||
<key>VPNSubType</key>
|
||||
<string>com.wireguard.ios</string>
|
||||
<key>VendorConfig</key>
|
||||
<dict>
|
||||
<key>WgQuickConfig</key>
|
||||
<string>
|
||||
[Interface]
|
||||
PrivateKey = mInDaw06K0NgfULRObHJjkWD3ahUC8XC1tVjIf6W+Vo=
|
||||
Address = 10.10.1.0/24
|
||||
DNS = 1.1.1.1, 1.0.0.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = JRI8Xc0zKP9kXk8qP84NdUQA04h6DLfFbwJn4g+/PFs=
|
||||
Endpoint = demo.wireguard.com:12912
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
</string>
|
||||
</dict>
|
||||
<key>VPN</key>
|
||||
<dict>
|
||||
<key>RemoteAddress</key>
|
||||
<string>demo.wireguard.com:12912</string>
|
||||
<key>AuthenticationMethod</key>
|
||||
<string>Password</string>
|
||||
</dict>
|
||||
</dict>
|
||||
```
|
||||
|
||||
### Caveats
|
||||
|
||||
Configurations added via .mobileconfig will not be migrated into keychain until the WireGuard application is opened once.
|
||||
|
||||
[wg-quick(8)]: https://git.zx2c4.com/wireguard-tools/about/src/man/wg-quick.8
|
||||
[wg(8)]: https://git.zx2c4.com/wireguard-tools/about/src/man/wg.8
|
40
Package.swift
Normal file
@ -0,0 +1,40 @@
|
||||
// swift-tools-version:5.3
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "WireGuardKit",
|
||||
platforms: [
|
||||
.macOS(.v14),
|
||||
.iOS(.v17)
|
||||
],
|
||||
products: [
|
||||
.library(name: "WireGuardKit", targets: ["WireGuardKit"])
|
||||
],
|
||||
dependencies: [],
|
||||
targets: [
|
||||
.target(
|
||||
name: "WireGuardKit",
|
||||
dependencies: ["WireGuardKitGo", "WireGuardKitC"]
|
||||
),
|
||||
.target(
|
||||
name: "WireGuardKitC",
|
||||
dependencies: [],
|
||||
publicHeadersPath: "."
|
||||
),
|
||||
.target(
|
||||
name: "WireGuardKitGo",
|
||||
dependencies: [],
|
||||
exclude: [
|
||||
"goruntime-boottime-over-monotonic.diff",
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
"api-apple.go",
|
||||
"Makefile"
|
||||
],
|
||||
publicHeadersPath: ".",
|
||||
linkerSettings: [.linkedLibrary("wg-go")]
|
||||
)
|
||||
]
|
||||
)
|
75
README.md
@ -1,41 +1,82 @@
|
||||
# [WireGuard](https://www.wireguard.com/) for iOS
|
||||
# [WireGuard](https://www.wireguard.com/) for iOS and macOS
|
||||
|
||||
This project contains an application for iOS and for macOS, as well as many components shared between the two of them. You may toggle between the two platforms by selecting the target from within Xcode.
|
||||
|
||||
## Building
|
||||
|
||||
- Clone this repo:
|
||||
|
||||
```
|
||||
$ git clone https://git.zx2c4.com/wireguard-ios
|
||||
$ cd wireguard-ios
|
||||
```
|
||||
|
||||
- Init and update submodule:
|
||||
|
||||
```
|
||||
$ git submodule init
|
||||
$ git submodule update
|
||||
$ git clone https://git.zx2c4.com/wireguard-apple
|
||||
$ cd wireguard-apple
|
||||
```
|
||||
|
||||
- Rename and populate developer team ID file:
|
||||
|
||||
```
|
||||
$ cp WireGuard/WireGuard/Config/Developer.xcconfig.template WireGuard/WireGuard/Config/Developer.xcconfig
|
||||
$ vim WireGuard/WireGuard/Config/Developer.xcconfig
|
||||
$ cp Sources/WireGuardApp/Config/Developer.xcconfig.template Sources/WireGuardApp/Config/Developer.xcconfig
|
||||
$ vim Sources/WireGuardApp/Config/Developer.xcconfig
|
||||
```
|
||||
|
||||
- Install swiftlint:
|
||||
- Install swiftlint and go 1.19:
|
||||
|
||||
```
|
||||
$ brew install swiftlint
|
||||
$ brew install swiftlint go
|
||||
```
|
||||
|
||||
- Open project in XCode:
|
||||
- Open project in Xcode:
|
||||
|
||||
```
|
||||
$ open ./WireGuard/WireGuard.xcodeproj
|
||||
$ open WireGuard.xcodeproj
|
||||
```
|
||||
|
||||
- Flip switches, press buttons, and make whirling noises until XCode builds it.
|
||||
- Flip switches, press buttons, and make whirling noises until Xcode builds it.
|
||||
|
||||
## WireGuardKit integration
|
||||
|
||||
1. Open your Xcode project and add the Swift package with the following URL:
|
||||
|
||||
```
|
||||
https://git.zx2c4.com/wireguard-apple
|
||||
```
|
||||
|
||||
2. `WireGuardKit` links against `wireguard-go-bridge` library, but it cannot build it automatically
|
||||
due to Swift package manager limitations. So it needs a little help from a developer.
|
||||
Please follow the instructions below to create a build target(s) for `wireguard-go-bridge`.
|
||||
|
||||
- In Xcode, click File -> New -> Target. Switch to "Other" tab and choose "External Build
|
||||
System".
|
||||
- Type in `WireGuardGoBridge<PLATFORM>` under the "Product name", replacing the `<PLATFORM>`
|
||||
placeholder with the name of the platform. For example, when targeting macOS use `macOS`, or
|
||||
when targeting iOS use `iOS`.
|
||||
Make sure the build tool is set to: `/usr/bin/make` (default).
|
||||
- In the appeared "Info" tab of a newly created target, type in the "Directory" path under
|
||||
the "External Build Tool Configuration":
|
||||
|
||||
```
|
||||
${BUILD_DIR%Build/*}SourcePackages/checkouts/wireguard-apple/Sources/WireGuardKitGo
|
||||
```
|
||||
|
||||
- Switch to "Build Settings" and find `SDKROOT`.
|
||||
Type in `macosx` if you target macOS, or type in `iphoneos` if you target iOS.
|
||||
|
||||
3. Go to Xcode project settings and locate your network extension target and switch to
|
||||
"Build Phases" tab.
|
||||
|
||||
- Locate "Dependencies" section and hit "+" to add `WireGuardGoBridge<PLATFORM>` replacing
|
||||
the `<PLATFORM>` placeholder with the name of platform matching the network extension
|
||||
deployment target (i.e macOS or iOS).
|
||||
|
||||
- Locate the "Link with binary libraries" section and hit "+" to add `WireGuardKit`.
|
||||
|
||||
4. In Xcode project settings, locate your main bundle app and switch to "Build Phases" tab.
|
||||
Locate the "Link with binary libraries" section and hit "+" to add `WireGuardKit`.
|
||||
|
||||
5. iOS only: Locate Bitcode settings under your application target, Build settings -> Enable Bitcode,
|
||||
change the corresponding value to "No".
|
||||
|
||||
Note that if you ship your app for both iOS and macOS, make sure to repeat the steps 2-4 twice,
|
||||
once per platform.
|
||||
|
||||
## MIT License
|
||||
|
||||
|
@ -1,12 +1,22 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
extension FileManager {
|
||||
static var appGroupId: String? {
|
||||
#if os(iOS)
|
||||
let appGroupIdInfoDictionaryKey = "com.wireguard.ios.app_group_id"
|
||||
#elseif os(macOS)
|
||||
let appGroupIdInfoDictionaryKey = "com.wireguard.macos.app_group_id"
|
||||
#else
|
||||
#error("Unimplemented")
|
||||
#endif
|
||||
return Bundle.main.object(forInfoDictionaryKey: appGroupIdInfoDictionaryKey) as? String
|
||||
}
|
||||
private static var sharedFolderURL: URL? {
|
||||
guard let appGroupId = Bundle.main.object(forInfoDictionaryKey: "com.wireguard.ios.app_group_id") as? String else {
|
||||
guard let appGroupId = FileManager.appGroupId else {
|
||||
os_log("Cannot obtain app group ID from bundle", log: OSLog.default, type: .error)
|
||||
return nil
|
||||
}
|
||||
@ -17,7 +27,7 @@ extension FileManager {
|
||||
return sharedFolderURL
|
||||
}
|
||||
|
||||
static var networkExtensionLogFileURL: URL? {
|
||||
static var logFileURL: URL? {
|
||||
return sharedFolderURL?.appendingPathComponent("tunnel-log.bin")
|
||||
}
|
||||
|
||||
@ -25,12 +35,8 @@ extension FileManager {
|
||||
return sharedFolderURL?.appendingPathComponent("last-error.txt")
|
||||
}
|
||||
|
||||
static var appLogFileURL: URL? {
|
||||
guard let documentDirURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
|
||||
wg_log(.error, message: "Cannot obtain app documents folder URL")
|
||||
return nil
|
||||
}
|
||||
return documentDirURL.appendingPathComponent("app-log.bin")
|
||||
static var loginHelperTimestampURL: URL? {
|
||||
return sharedFolderURL?.appendingPathComponent("login-helper-timestamp.bin")
|
||||
}
|
||||
|
||||
static func deleteFile(at url: URL) -> Bool {
|
114
Sources/Shared/Keychain.swift
Normal file
@ -0,0 +1,114 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
class Keychain {
|
||||
static func openReference(called ref: Data) -> String? {
|
||||
var result: CFTypeRef?
|
||||
let ret = SecItemCopyMatching([kSecValuePersistentRef: ref,
|
||||
kSecReturnData: true] as CFDictionary,
|
||||
&result)
|
||||
if ret != errSecSuccess || result == nil {
|
||||
wg_log(.error, message: "Unable to open config from keychain: \(ret)")
|
||||
return nil
|
||||
}
|
||||
guard let data = result as? Data else { return nil }
|
||||
return String(data: data, encoding: String.Encoding.utf8)
|
||||
}
|
||||
|
||||
static func makeReference(containing value: String, called name: String, previouslyReferencedBy oldRef: Data? = nil) -> Data? {
|
||||
var ret: OSStatus
|
||||
guard var bundleIdentifier = Bundle.main.bundleIdentifier else {
|
||||
wg_log(.error, staticMessage: "Unable to determine bundle identifier")
|
||||
return nil
|
||||
}
|
||||
if bundleIdentifier.hasSuffix(".network-extension") {
|
||||
bundleIdentifier.removeLast(".network-extension".count)
|
||||
}
|
||||
let itemLabel = "WireGuard Tunnel: \(name)"
|
||||
var items: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrLabel: itemLabel,
|
||||
kSecAttrAccount: name + ": " + UUID().uuidString,
|
||||
kSecAttrDescription: "wg-quick(8) config",
|
||||
kSecAttrService: bundleIdentifier,
|
||||
kSecValueData: value.data(using: .utf8) as Any,
|
||||
kSecReturnPersistentRef: true]
|
||||
|
||||
#if os(iOS)
|
||||
items[kSecAttrAccessGroup] = FileManager.appGroupId
|
||||
items[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlock
|
||||
#elseif os(macOS)
|
||||
items[kSecAttrSynchronizable] = false
|
||||
items[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
|
||||
guard let extensionPath = Bundle.main.builtInPlugInsURL?.appendingPathComponent("WireGuardNetworkExtension.appex", isDirectory: true).path else {
|
||||
wg_log(.error, staticMessage: "Unable to determine app extension path")
|
||||
return nil
|
||||
}
|
||||
var extensionApp: SecTrustedApplication?
|
||||
var mainApp: SecTrustedApplication?
|
||||
ret = SecTrustedApplicationCreateFromPath(extensionPath, &extensionApp)
|
||||
if ret != kOSReturnSuccess || extensionApp == nil {
|
||||
wg_log(.error, message: "Unable to create keychain extension trusted application object: \(ret)")
|
||||
return nil
|
||||
}
|
||||
ret = SecTrustedApplicationCreateFromPath(nil, &mainApp)
|
||||
if ret != errSecSuccess || mainApp == nil {
|
||||
wg_log(.error, message: "Unable to create keychain local trusted application object: \(ret)")
|
||||
return nil
|
||||
}
|
||||
var access: SecAccess?
|
||||
ret = SecAccessCreate(itemLabel as CFString, [extensionApp!, mainApp!] as CFArray, &access)
|
||||
if ret != errSecSuccess || access == nil {
|
||||
wg_log(.error, message: "Unable to create keychain ACL object: \(ret)")
|
||||
return nil
|
||||
}
|
||||
items[kSecAttrAccess] = access!
|
||||
#else
|
||||
#error("Unimplemented")
|
||||
#endif
|
||||
|
||||
var ref: CFTypeRef?
|
||||
ret = SecItemAdd(items as CFDictionary, &ref)
|
||||
if ret != errSecSuccess || ref == nil {
|
||||
wg_log(.error, message: "Unable to add config to keychain: \(ret)")
|
||||
return nil
|
||||
}
|
||||
if let oldRef = oldRef {
|
||||
deleteReference(called: oldRef)
|
||||
}
|
||||
return ref as? Data
|
||||
}
|
||||
|
||||
static func deleteReference(called ref: Data) {
|
||||
let ret = SecItemDelete([kSecValuePersistentRef: ref] as CFDictionary)
|
||||
if ret != errSecSuccess {
|
||||
wg_log(.error, message: "Unable to delete config from keychain: \(ret)")
|
||||
}
|
||||
}
|
||||
|
||||
static func deleteReferences(except whitelist: Set<Data>) {
|
||||
var result: CFTypeRef?
|
||||
let ret = SecItemCopyMatching([kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: Bundle.main.bundleIdentifier as Any,
|
||||
kSecMatchLimit: kSecMatchLimitAll,
|
||||
kSecReturnPersistentRef: true] as CFDictionary,
|
||||
&result)
|
||||
if ret != errSecSuccess || result == nil {
|
||||
return
|
||||
}
|
||||
guard let items = result as? [Data] else { return }
|
||||
for item in items {
|
||||
if !whitelist.contains(item) {
|
||||
deleteReference(called: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func verifyReference(called ref: Data) -> Bool {
|
||||
return SecItemCopyMatching([kSecValuePersistentRef: ref] as CFDictionary,
|
||||
nil) != errSecItemNotFound
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
@ -12,10 +12,12 @@ public class Logger {
|
||||
static var global: Logger?
|
||||
|
||||
var log: OpaquePointer
|
||||
var tag: String
|
||||
|
||||
init(withFilePath filePath: String) throws {
|
||||
init(tagged tag: String, withFilePath filePath: String) throws {
|
||||
guard let log = open_log(filePath) else { throw LoggerError.openFailure }
|
||||
self.log = log
|
||||
self.tag = tag
|
||||
}
|
||||
|
||||
deinit {
|
||||
@ -23,17 +25,14 @@ public class Logger {
|
||||
}
|
||||
|
||||
func log(message: String) {
|
||||
write_msg_to_log(log, message.trimmingCharacters(in: .newlines))
|
||||
write_msg_to_log(log, tag, message.trimmingCharacters(in: .newlines))
|
||||
}
|
||||
|
||||
func writeLog(called ourTag: String, mergedWith otherLogFile: String, called otherTag: String, to targetFile: String) -> Bool {
|
||||
guard let other = open_log(otherLogFile) else { return false }
|
||||
let ret = write_logs_to_file(targetFile, log, ourTag, other, otherTag)
|
||||
close_log(other)
|
||||
return ret == 0
|
||||
func writeLog(to targetFile: String) -> Bool {
|
||||
return write_log_to_file(targetFile, self.log) == 0
|
||||
}
|
||||
|
||||
static func configureGlobal(withFilePath filePath: String?) {
|
||||
static func configureGlobal(tagged tag: String, withFilePath filePath: String?) {
|
||||
if Logger.global != nil {
|
||||
return
|
||||
}
|
||||
@ -41,19 +40,17 @@ public class Logger {
|
||||
os_log("Unable to determine log destination path. Log will not be saved to file.", log: OSLog.default, type: .error)
|
||||
return
|
||||
}
|
||||
do {
|
||||
try Logger.global = Logger(withFilePath: filePath)
|
||||
} catch {
|
||||
guard let logger = try? Logger(tagged: tag, withFilePath: filePath) else {
|
||||
os_log("Unable to open log file for writing. Log will not be saved to file.", log: OSLog.default, type: .error)
|
||||
return
|
||||
}
|
||||
Logger.global = logger
|
||||
var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown version"
|
||||
if let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String {
|
||||
appVersion += " (\(appBuild))"
|
||||
}
|
||||
let goBackendVersion = WIREGUARD_GO_VERSION
|
||||
Logger.global?.log(message: "App version: \(appVersion); Go backend version: \(goBackendVersion)")
|
||||
|
||||
Logger.global?.log(message: "App version: \(appVersion)")
|
||||
}
|
||||
}
|
||||
|
173
Sources/Shared/Logging/ringlogger.c
Normal file
@ -0,0 +1,173 @@
|
||||
/* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
*/
|
||||
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdbool.h>
|
||||
#include <time.h>
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/time.h>
|
||||
#include <sys/mman.h>
|
||||
#include "ringlogger.h"
|
||||
|
||||
enum {
|
||||
MAX_LOG_LINE_LENGTH = 512,
|
||||
MAX_LINES = 2048,
|
||||
MAGIC = 0xabadbeefU
|
||||
};
|
||||
|
||||
struct log_line {
|
||||
atomic_uint_fast64_t time_ns;
|
||||
char line[MAX_LOG_LINE_LENGTH];
|
||||
};
|
||||
|
||||
struct log {
|
||||
atomic_uint_fast32_t next_index;
|
||||
struct log_line lines[MAX_LINES];
|
||||
uint32_t magic;
|
||||
};
|
||||
|
||||
void write_msg_to_log(struct log *log, const char *tag, const char *msg)
|
||||
{
|
||||
uint32_t index;
|
||||
struct log_line *line;
|
||||
struct timespec ts;
|
||||
|
||||
// Race: This isn't synchronized with the fetch_add below, so items might be slightly out of order.
|
||||
clock_gettime(CLOCK_REALTIME, &ts);
|
||||
|
||||
// Race: More than MAX_LINES writers and this will clash.
|
||||
index = atomic_fetch_add(&log->next_index, 1);
|
||||
line = &log->lines[index % MAX_LINES];
|
||||
|
||||
// Race: Before this line executes, we'll display old data after new data.
|
||||
atomic_store(&line->time_ns, 0);
|
||||
memset(line->line, 0, MAX_LOG_LINE_LENGTH);
|
||||
|
||||
snprintf(line->line, MAX_LOG_LINE_LENGTH, "[%s] %s", tag, msg);
|
||||
atomic_store(&line->time_ns, ts.tv_sec * 1000000000ULL + ts.tv_nsec);
|
||||
|
||||
msync(&log->next_index, sizeof(log->next_index), MS_ASYNC);
|
||||
msync(line, sizeof(*line), MS_ASYNC);
|
||||
}
|
||||
|
||||
int write_log_to_file(const char *file_name, const struct log *input_log)
|
||||
{
|
||||
struct log *log;
|
||||
uint32_t l, i;
|
||||
FILE *file;
|
||||
int ret;
|
||||
|
||||
log = malloc(sizeof(*log));
|
||||
if (!log)
|
||||
return -errno;
|
||||
memcpy(log, input_log, sizeof(*log));
|
||||
|
||||
file = fopen(file_name, "w");
|
||||
if (!file) {
|
||||
free(log);
|
||||
return -errno;
|
||||
}
|
||||
|
||||
for (l = 0, i = log->next_index; l < MAX_LINES; ++l, ++i) {
|
||||
const struct log_line *line = &log->lines[i % MAX_LINES];
|
||||
time_t seconds = line->time_ns / 1000000000ULL;
|
||||
uint32_t useconds = (line->time_ns % 1000000000ULL) / 1000ULL;
|
||||
struct tm tm;
|
||||
|
||||
if (!line->time_ns)
|
||||
continue;
|
||||
|
||||
if (!localtime_r(&seconds, &tm))
|
||||
goto err;
|
||||
|
||||
if (fprintf(file, "%04d-%02d-%02d %02d:%02d:%02d.%06d: %s\n",
|
||||
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
|
||||
tm.tm_hour, tm.tm_min, tm.tm_sec, useconds,
|
||||
line->line) < 0)
|
||||
goto err;
|
||||
|
||||
|
||||
}
|
||||
errno = 0;
|
||||
|
||||
err:
|
||||
ret = -errno;
|
||||
fclose(file);
|
||||
free(log);
|
||||
return ret;
|
||||
}
|
||||
|
||||
uint32_t view_lines_from_cursor(const struct log *input_log, uint32_t cursor, void *ctx, void(*cb)(const char *, uint64_t, void *))
|
||||
{
|
||||
struct log *log;
|
||||
uint32_t l, i = cursor;
|
||||
|
||||
log = malloc(sizeof(*log));
|
||||
if (!log)
|
||||
return cursor;
|
||||
memcpy(log, input_log, sizeof(*log));
|
||||
|
||||
if (i == -1)
|
||||
i = log->next_index;
|
||||
|
||||
for (l = 0; l < MAX_LINES; ++l, ++i) {
|
||||
const struct log_line *line = &log->lines[i % MAX_LINES];
|
||||
|
||||
if (cursor != -1 && i % MAX_LINES == log->next_index % MAX_LINES)
|
||||
break;
|
||||
|
||||
if (!line->time_ns) {
|
||||
if (cursor == -1)
|
||||
continue;
|
||||
else
|
||||
break;
|
||||
}
|
||||
cb(line->line, line->time_ns, ctx);
|
||||
cursor = (i + 1) % MAX_LINES;
|
||||
}
|
||||
free(log);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
struct log *open_log(const char *file_name)
|
||||
{
|
||||
int fd;
|
||||
struct log *log;
|
||||
|
||||
fd = open(file_name, O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);
|
||||
if (fd < 0)
|
||||
return NULL;
|
||||
if (ftruncate(fd, sizeof(*log)))
|
||||
goto err;
|
||||
log = mmap(NULL, sizeof(*log), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||
if (log == MAP_FAILED)
|
||||
goto err;
|
||||
close(fd);
|
||||
|
||||
if (log->magic != MAGIC) {
|
||||
memset(log, 0, sizeof(*log));
|
||||
log->magic = MAGIC;
|
||||
msync(log, sizeof(*log), MS_ASYNC);
|
||||
}
|
||||
|
||||
return log;
|
||||
|
||||
err:
|
||||
close(fd);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void close_log(struct log *log)
|
||||
{
|
||||
munmap(log, sizeof(*log));
|
||||
}
|
18
Sources/Shared/Logging/ringlogger.h
Normal file
@ -0,0 +1,18 @@
|
||||
/* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef RINGLOGGER_H
|
||||
#define RINGLOGGER_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
struct log;
|
||||
void write_msg_to_log(struct log *log, const char *tag, const char *msg);
|
||||
int write_log_to_file(const char *file_name, const struct log *input_log);
|
||||
uint32_t view_lines_from_cursor(const struct log *input_log, uint32_t cursor, void *ctx, void(*)(const char *, uint64_t, void *));
|
||||
struct log *open_log(const char *file_name);
|
||||
void close_log(struct log *log);
|
||||
|
||||
#endif
|
63
Sources/Shared/Logging/test_ringlogger.c
Normal file
@ -0,0 +1,63 @@
|
||||
#include "ringlogger.h"
|
||||
#include <stdio.h>
|
||||
#include <stdbool.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <inttypes.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
static void forkwrite(void)
|
||||
{
|
||||
struct log *log = open_log("/tmp/test_log");
|
||||
char c[512];
|
||||
int i, base;
|
||||
bool in_fork = !fork();
|
||||
|
||||
base = 10000 * in_fork;
|
||||
for (i = 0; i < 1024; ++i) {
|
||||
snprintf(c, 512, "bla bla bla %d", base + i);
|
||||
write_msg_to_log(log, "HMM", c);
|
||||
}
|
||||
|
||||
|
||||
if (in_fork)
|
||||
_exit(0);
|
||||
wait(NULL);
|
||||
|
||||
write_log_to_file("/dev/stdout", log);
|
||||
close_log(log);
|
||||
}
|
||||
|
||||
static void writetext(const char *text)
|
||||
{
|
||||
struct log *log = open_log("/tmp/test_log");
|
||||
write_msg_to_log(log, "TXT", text);
|
||||
close_log(log);
|
||||
}
|
||||
|
||||
static void show_line(const char *line, uint64_t time_ns)
|
||||
{
|
||||
printf("%" PRIu64 ": %s\n", time_ns, line);
|
||||
}
|
||||
|
||||
static void follow(void)
|
||||
{
|
||||
uint32_t cursor = -1;
|
||||
struct log *log = open_log("/tmp/test_log");
|
||||
|
||||
for (;;) {
|
||||
cursor = view_lines_from_cursor(log, cursor, show_line);
|
||||
usleep(1000 * 300);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
if (!strcmp(argv[1], "fork"))
|
||||
forkwrite();
|
||||
else if (!strcmp(argv[1], "write"))
|
||||
writetext(argv[2]);
|
||||
else if (!strcmp(argv[1], "follow"))
|
||||
follow();
|
||||
return 0;
|
||||
}
|
106
Sources/Shared/Model/NETunnelProviderProtocol+Extension.swift
Normal file
@ -0,0 +1,106 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import NetworkExtension
|
||||
|
||||
enum PacketTunnelProviderError: String, Error {
|
||||
case savedProtocolConfigurationIsInvalid
|
||||
case dnsResolutionFailure
|
||||
case couldNotStartBackend
|
||||
case couldNotDetermineFileDescriptor
|
||||
case couldNotSetNetworkSettings
|
||||
}
|
||||
|
||||
extension NETunnelProviderProtocol {
|
||||
convenience init?(tunnelConfiguration: TunnelConfiguration, previouslyFrom old: NEVPNProtocol? = nil) {
|
||||
self.init()
|
||||
|
||||
guard let name = tunnelConfiguration.name else { return nil }
|
||||
guard let appId = Bundle.main.bundleIdentifier else { return nil }
|
||||
providerBundleIdentifier = "\(appId).network-extension"
|
||||
passwordReference = Keychain.makeReference(containing: tunnelConfiguration.asWgQuickConfig(), called: name, previouslyReferencedBy: old?.passwordReference)
|
||||
if passwordReference == nil {
|
||||
return nil
|
||||
}
|
||||
#if os(macOS)
|
||||
providerConfiguration = ["UID": getuid()]
|
||||
#endif
|
||||
|
||||
let endpoints = tunnelConfiguration.peers.compactMap { $0.endpoint }
|
||||
if endpoints.count == 1 {
|
||||
serverAddress = endpoints[0].stringRepresentation
|
||||
} else if endpoints.isEmpty {
|
||||
serverAddress = "Unspecified"
|
||||
} else {
|
||||
serverAddress = "Multiple endpoints"
|
||||
}
|
||||
}
|
||||
|
||||
func asTunnelConfiguration(called name: String? = nil) -> TunnelConfiguration? {
|
||||
if let passwordReference = passwordReference,
|
||||
let config = Keychain.openReference(called: passwordReference) {
|
||||
return try? TunnelConfiguration(fromWgQuickConfig: config, called: name)
|
||||
}
|
||||
if let oldConfig = providerConfiguration?["WgQuickConfig"] as? String {
|
||||
return try? TunnelConfiguration(fromWgQuickConfig: oldConfig, called: name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func destroyConfigurationReference() {
|
||||
guard let ref = passwordReference else { return }
|
||||
Keychain.deleteReference(called: ref)
|
||||
}
|
||||
|
||||
func verifyConfigurationReference() -> Bool {
|
||||
guard let ref = passwordReference else { return false }
|
||||
return Keychain.verifyReference(called: ref)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func migrateConfigurationIfNeeded(called name: String) -> Bool {
|
||||
/* This is how we did things before we switched to putting items
|
||||
* in the keychain. But it's still useful to keep the migration
|
||||
* around so that .mobileconfig files are easier.
|
||||
*/
|
||||
if let oldConfig = providerConfiguration?["WgQuickConfig"] as? String {
|
||||
#if os(macOS)
|
||||
providerConfiguration = ["UID": getuid()]
|
||||
#elseif os(iOS)
|
||||
providerConfiguration = nil
|
||||
#else
|
||||
#error("Unimplemented")
|
||||
#endif
|
||||
guard passwordReference == nil else { return true }
|
||||
wg_log(.info, message: "Migrating tunnel configuration '\(name)'")
|
||||
passwordReference = Keychain.makeReference(containing: oldConfig, called: name)
|
||||
return true
|
||||
}
|
||||
#if os(macOS)
|
||||
if passwordReference != nil && providerConfiguration?["UID"] == nil && verifyConfigurationReference() {
|
||||
providerConfiguration = ["UID": getuid()]
|
||||
return true
|
||||
}
|
||||
#elseif os(iOS)
|
||||
/* Update the stored reference from the old iOS 14 one to the canonical iOS 15 one.
|
||||
* The iOS 14 ones are 96 bits, while the iOS 15 ones are 160 bits. We do this so
|
||||
* that we can have fast set exclusion in deleteReferences safely. */
|
||||
if passwordReference != nil && passwordReference!.count == 12 {
|
||||
var result: CFTypeRef?
|
||||
let ret = SecItemCopyMatching([kSecValuePersistentRef: passwordReference!,
|
||||
kSecReturnPersistentRef: true] as CFDictionary,
|
||||
&result)
|
||||
if ret != errSecSuccess || result == nil {
|
||||
return false
|
||||
}
|
||||
guard let newReference = result as? Data else { return false }
|
||||
if !newReference.elementsEqual(passwordReference!) {
|
||||
wg_log(.info, message: "Migrating iOS 14-style keychain reference to iOS 15-style keychain reference for '\(name)'")
|
||||
passwordReference = newReference
|
||||
return true
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
@ -12,20 +12,34 @@ extension TunnelConfiguration {
|
||||
}
|
||||
|
||||
enum ParseError: Error {
|
||||
case invalidLine(_ line: String.SubSequence)
|
||||
case invalidLine(String.SubSequence)
|
||||
case noInterface
|
||||
case invalidInterface
|
||||
case multipleInterfaces
|
||||
case interfaceHasNoPrivateKey
|
||||
case interfaceHasInvalidPrivateKey(String)
|
||||
case interfaceHasInvalidListenPort(String)
|
||||
case interfaceHasInvalidAddress(String)
|
||||
case interfaceHasInvalidDNS(String)
|
||||
case interfaceHasInvalidMTU(String)
|
||||
case interfaceHasUnrecognizedKey(String)
|
||||
case peerHasNoPublicKey
|
||||
case peerHasInvalidPublicKey(String)
|
||||
case peerHasInvalidPreSharedKey(String)
|
||||
case peerHasInvalidAllowedIP(String)
|
||||
case peerHasInvalidEndpoint(String)
|
||||
case peerHasInvalidPersistentKeepAlive(String)
|
||||
case peerHasInvalidTransferBytes(String)
|
||||
case peerHasInvalidLastHandshakeTime(String)
|
||||
case peerHasUnrecognizedKey(String)
|
||||
case multiplePeersWithSamePublicKey
|
||||
case invalidPeer
|
||||
case multipleEntriesForKey(String)
|
||||
}
|
||||
|
||||
//swiftlint:disable:next function_body_length cyclomatic_complexity
|
||||
convenience init(fromWgQuickConfig wgQuickConfig: String, called name: String? = nil) throws {
|
||||
var interfaceConfiguration: InterfaceConfiguration?
|
||||
var peerConfigurations = [PeerConfiguration]()
|
||||
|
||||
let lines = wgQuickConfig.split(separator: "\n")
|
||||
let lines = wgQuickConfig.split { $0.isNewline }
|
||||
|
||||
var parserState = ParserState.notInASection
|
||||
var attributes = [String: String]()
|
||||
@ -38,23 +52,39 @@ extension TunnelConfiguration {
|
||||
trimmedLine = String(line)
|
||||
}
|
||||
|
||||
trimmedLine = trimmedLine.trimmingCharacters(in: .whitespaces)
|
||||
trimmedLine = trimmedLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lowercasedLine = trimmedLine.lowercased()
|
||||
|
||||
guard !trimmedLine.isEmpty else { continue }
|
||||
let lowercasedLine = line.lowercased()
|
||||
|
||||
if let equalsIndex = line.firstIndex(of: "=") {
|
||||
// Line contains an attribute
|
||||
let key = line[..<equalsIndex].trimmingCharacters(in: .whitespaces).lowercased()
|
||||
let value = line[line.index(equalsIndex, offsetBy: 1)...].trimmingCharacters(in: .whitespaces)
|
||||
let keysWithMultipleEntriesAllowed: Set<String> = ["address", "allowedips", "dns"]
|
||||
if let presentValue = attributes[key], keysWithMultipleEntriesAllowed.contains(key) {
|
||||
attributes[key] = presentValue + "," + value
|
||||
} else {
|
||||
attributes[key] = value
|
||||
if !trimmedLine.isEmpty {
|
||||
if let equalsIndex = trimmedLine.firstIndex(of: "=") {
|
||||
// Line contains an attribute
|
||||
let keyWithCase = trimmedLine[..<equalsIndex].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let key = keyWithCase.lowercased()
|
||||
let value = trimmedLine[trimmedLine.index(equalsIndex, offsetBy: 1)...].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let keysWithMultipleEntriesAllowed: Set<String> = ["address", "allowedips", "dns"]
|
||||
if let presentValue = attributes[key] {
|
||||
if keysWithMultipleEntriesAllowed.contains(key) {
|
||||
attributes[key] = presentValue + "," + value
|
||||
} else {
|
||||
throw ParseError.multipleEntriesForKey(keyWithCase)
|
||||
}
|
||||
} else {
|
||||
attributes[key] = value
|
||||
}
|
||||
let interfaceSectionKeys: Set<String> = ["privatekey", "listenport", "address", "dns", "mtu"]
|
||||
let peerSectionKeys: Set<String> = ["publickey", "presharedkey", "allowedips", "endpoint", "persistentkeepalive"]
|
||||
if parserState == .inInterfaceSection {
|
||||
guard interfaceSectionKeys.contains(key) else {
|
||||
throw ParseError.interfaceHasUnrecognizedKey(keyWithCase)
|
||||
}
|
||||
} else if parserState == .inPeerSection {
|
||||
guard peerSectionKeys.contains(key) else {
|
||||
throw ParseError.peerHasUnrecognizedKey(keyWithCase)
|
||||
}
|
||||
}
|
||||
} else if lowercasedLine != "[interface]" && lowercasedLine != "[peer]" {
|
||||
throw ParseError.invalidLine(line)
|
||||
}
|
||||
} else if lowercasedLine != "[interface]" && lowercasedLine != "[peer]" {
|
||||
throw ParseError.invalidLine(line)
|
||||
}
|
||||
|
||||
let isLastLine = lineIndex == lines.count - 1
|
||||
@ -62,11 +92,11 @@ extension TunnelConfiguration {
|
||||
if isLastLine || lowercasedLine == "[interface]" || lowercasedLine == "[peer]" {
|
||||
// Previous section has ended; process the attributes collected so far
|
||||
if parserState == .inInterfaceSection {
|
||||
guard let interface = TunnelConfiguration.collate(interfaceAttributes: attributes) else { throw ParseError.invalidInterface }
|
||||
let interface = try TunnelConfiguration.collate(interfaceAttributes: attributes)
|
||||
guard interfaceConfiguration == nil else { throw ParseError.multipleInterfaces }
|
||||
interfaceConfiguration = interface
|
||||
} else if parserState == .inPeerSection {
|
||||
guard let peer = TunnelConfiguration.collate(peerAttributes: attributes) else { throw ParseError.invalidPeer }
|
||||
let peer = try TunnelConfiguration.collate(peerAttributes: attributes)
|
||||
peerConfigurations.append(peer)
|
||||
}
|
||||
}
|
||||
@ -81,7 +111,7 @@ extension TunnelConfiguration {
|
||||
}
|
||||
|
||||
let peerPublicKeysArray = peerConfigurations.map { $0.publicKey }
|
||||
let peerPublicKeysSet = Set<Data>(peerPublicKeysArray)
|
||||
let peerPublicKeysSet = Set<PublicKey>(peerPublicKeysArray)
|
||||
if peerPublicKeysArray.count != peerPublicKeysSet.count {
|
||||
throw ParseError.multiplePeersWithSamePublicKey
|
||||
}
|
||||
@ -95,7 +125,7 @@ extension TunnelConfiguration {
|
||||
|
||||
func asWgQuickConfig() -> String {
|
||||
var output = "[Interface]\n"
|
||||
output.append("PrivateKey = \(interface.privateKey.base64EncodedString())\n")
|
||||
output.append("PrivateKey = \(interface.privateKey.base64Key)\n")
|
||||
if let listenPort = interface.listenPort {
|
||||
output.append("ListenPort = \(listenPort)\n")
|
||||
}
|
||||
@ -103,8 +133,10 @@ extension TunnelConfiguration {
|
||||
let addressString = interface.addresses.map { $0.stringRepresentation }.joined(separator: ", ")
|
||||
output.append("Address = \(addressString)\n")
|
||||
}
|
||||
if !interface.dns.isEmpty {
|
||||
let dnsString = interface.dns.map { $0.stringRepresentation }.joined(separator: ", ")
|
||||
if !interface.dns.isEmpty || !interface.dnsSearch.isEmpty {
|
||||
var dnsLine = interface.dns.map { $0.stringRepresentation }
|
||||
dnsLine.append(contentsOf: interface.dnsSearch)
|
||||
let dnsString = dnsLine.joined(separator: ", ")
|
||||
output.append("DNS = \(dnsString)\n")
|
||||
}
|
||||
if let mtu = interface.mtu {
|
||||
@ -113,9 +145,9 @@ extension TunnelConfiguration {
|
||||
|
||||
for peer in peers {
|
||||
output.append("\n[Peer]\n")
|
||||
output.append("PublicKey = \(peer.publicKey.base64EncodedString())\n")
|
||||
if let preSharedKey = peer.preSharedKey {
|
||||
output.append("PresharedKey = \(preSharedKey.base64EncodedString())\n")
|
||||
output.append("PublicKey = \(peer.publicKey.base64Key)\n")
|
||||
if let preSharedKey = peer.preSharedKey?.base64Key {
|
||||
output.append("PresharedKey = \(preSharedKey)\n")
|
||||
}
|
||||
if !peer.allowedIPs.isEmpty {
|
||||
let allowedIPsString = peer.allowedIPs.map { $0.stringRepresentation }.joined(separator: ", ")
|
||||
@ -132,66 +164,86 @@ extension TunnelConfiguration {
|
||||
return output
|
||||
}
|
||||
|
||||
//swiftlint:disable:next cyclomatic_complexity
|
||||
private static func collate(interfaceAttributes attributes: [String: String]) -> InterfaceConfiguration? {
|
||||
// required wg fields
|
||||
guard let privateKeyString = attributes["privatekey"] else { return nil }
|
||||
guard let privateKey = Data(base64Encoded: privateKeyString), privateKey.count == TunnelConfiguration.keyLength else { return nil }
|
||||
private static func collate(interfaceAttributes attributes: [String: String]) throws -> InterfaceConfiguration {
|
||||
guard let privateKeyString = attributes["privatekey"] else {
|
||||
throw ParseError.interfaceHasNoPrivateKey
|
||||
}
|
||||
guard let privateKey = PrivateKey(base64Key: privateKeyString) else {
|
||||
throw ParseError.interfaceHasInvalidPrivateKey(privateKeyString)
|
||||
}
|
||||
var interface = InterfaceConfiguration(privateKey: privateKey)
|
||||
// other wg fields
|
||||
if let listenPortString = attributes["listenport"] {
|
||||
guard let listenPort = UInt16(listenPortString) else { return nil }
|
||||
guard let listenPort = UInt16(listenPortString) else {
|
||||
throw ParseError.interfaceHasInvalidListenPort(listenPortString)
|
||||
}
|
||||
interface.listenPort = listenPort
|
||||
}
|
||||
// wg-quick fields
|
||||
if let addressesString = attributes["address"] {
|
||||
var addresses = [IPAddressRange]()
|
||||
for addressString in addressesString.splitToArray(trimmingCharacters: .whitespaces) {
|
||||
guard let address = IPAddressRange(from: addressString) else { return nil }
|
||||
for addressString in addressesString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) {
|
||||
guard let address = IPAddressRange(from: addressString) else {
|
||||
throw ParseError.interfaceHasInvalidAddress(addressString)
|
||||
}
|
||||
addresses.append(address)
|
||||
}
|
||||
interface.addresses = addresses
|
||||
}
|
||||
if let dnsString = attributes["dns"] {
|
||||
var dnsServers = [DNSServer]()
|
||||
for dnsServerString in dnsString.splitToArray(trimmingCharacters: .whitespaces) {
|
||||
guard let dnsServer = DNSServer(from: dnsServerString) else { return nil }
|
||||
dnsServers.append(dnsServer)
|
||||
var dnsSearch = [String]()
|
||||
for dnsServerString in dnsString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) {
|
||||
if let dnsServer = DNSServer(from: dnsServerString) {
|
||||
dnsServers.append(dnsServer)
|
||||
} else {
|
||||
dnsSearch.append(dnsServerString)
|
||||
}
|
||||
}
|
||||
interface.dns = dnsServers
|
||||
interface.dnsSearch = dnsSearch
|
||||
}
|
||||
if let mtuString = attributes["mtu"] {
|
||||
guard let mtu = UInt16(mtuString) else { return nil }
|
||||
guard let mtu = UInt16(mtuString) else {
|
||||
throw ParseError.interfaceHasInvalidMTU(mtuString)
|
||||
}
|
||||
interface.mtu = mtu
|
||||
}
|
||||
return interface
|
||||
}
|
||||
|
||||
//swiftlint:disable:next cyclomatic_complexity
|
||||
private static func collate(peerAttributes attributes: [String: String]) -> PeerConfiguration? {
|
||||
// required wg fields
|
||||
guard let publicKeyString = attributes["publickey"] else { return nil }
|
||||
guard let publicKey = Data(base64Encoded: publicKeyString), publicKey.count == TunnelConfiguration.keyLength else { return nil }
|
||||
private static func collate(peerAttributes attributes: [String: String]) throws -> PeerConfiguration {
|
||||
guard let publicKeyString = attributes["publickey"] else {
|
||||
throw ParseError.peerHasNoPublicKey
|
||||
}
|
||||
guard let publicKey = PublicKey(base64Key: publicKeyString) else {
|
||||
throw ParseError.peerHasInvalidPublicKey(publicKeyString)
|
||||
}
|
||||
var peer = PeerConfiguration(publicKey: publicKey)
|
||||
// wg fields
|
||||
if let preSharedKeyString = attributes["presharedkey"] {
|
||||
guard let preSharedKey = Data(base64Encoded: preSharedKeyString), preSharedKey.count == TunnelConfiguration.keyLength else { return nil }
|
||||
guard let preSharedKey = PreSharedKey(base64Key: preSharedKeyString) else {
|
||||
throw ParseError.peerHasInvalidPreSharedKey(preSharedKeyString)
|
||||
}
|
||||
peer.preSharedKey = preSharedKey
|
||||
}
|
||||
if let allowedIPsString = attributes["allowedips"] {
|
||||
var allowedIPs = [IPAddressRange]()
|
||||
for allowedIPString in allowedIPsString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) {
|
||||
guard let allowedIP = IPAddressRange(from: allowedIPString) else { return nil }
|
||||
guard let allowedIP = IPAddressRange(from: allowedIPString) else {
|
||||
throw ParseError.peerHasInvalidAllowedIP(allowedIPString)
|
||||
}
|
||||
allowedIPs.append(allowedIP)
|
||||
}
|
||||
peer.allowedIPs = allowedIPs
|
||||
}
|
||||
if let endpointString = attributes["endpoint"] {
|
||||
guard let endpoint = Endpoint(from: endpointString) else { return nil }
|
||||
guard let endpoint = Endpoint(from: endpointString) else {
|
||||
throw ParseError.peerHasInvalidEndpoint(endpointString)
|
||||
}
|
||||
peer.endpoint = endpoint
|
||||
}
|
||||
if let persistentKeepAliveString = attributes["persistentkeepalive"] {
|
||||
guard let persistentKeepAlive = UInt16(persistentKeepAliveString) else { return nil }
|
||||
guard let persistentKeepAlive = UInt16(persistentKeepAliveString) else {
|
||||
throw ParseError.peerHasInvalidPersistentKeepAlive(persistentKeepAliveString)
|
||||
}
|
||||
peer.persistentKeepAlive = persistentKeepAlive
|
||||
}
|
||||
return peer
|
33
Sources/Shared/NotificationToken.swift
Normal file
@ -0,0 +1,33 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// This source file contains bits of code from:
|
||||
/// https://oleb.net/blog/2018/01/notificationcenter-removeobserver/
|
||||
|
||||
/// Wraps the observer token received from
|
||||
/// `NotificationCenter.addObserver(forName:object:queue:using:)`
|
||||
/// and unregisters it in deinit.
|
||||
final class NotificationToken {
|
||||
let notificationCenter: NotificationCenter
|
||||
let token: Any
|
||||
|
||||
init(notificationCenter: NotificationCenter = .default, token: Any) {
|
||||
self.notificationCenter = notificationCenter
|
||||
self.token = token
|
||||
}
|
||||
|
||||
deinit {
|
||||
notificationCenter.removeObserver(token)
|
||||
}
|
||||
}
|
||||
|
||||
extension NotificationCenter {
|
||||
/// Convenience wrapper for addObserver(forName:object:queue:using:)
|
||||
/// that returns our custom `NotificationToken`.
|
||||
func observe(name: NSNotification.Name?, object obj: Any?, queue: OperationQueue?, using block: @escaping (Notification) -> Void) -> NotificationToken {
|
||||
let token = addObserver(forName: name, object: obj, queue: queue, using: block)
|
||||
return NotificationToken(notificationCenter: self, token: token)
|
||||
}
|
||||
}
|
7
Sources/WireGuardApp/Base.lproj/InfoPlist.strings
Normal file
@ -0,0 +1,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
// iOS permission prompts
|
||||
|
||||
NSCameraUsageDescription = "Camera is used for scanning QR codes for importing WireGuard configurations";
|
||||
NSFaceIDUsageDescription = "Face ID is used for authenticating viewing and exporting of private keys";
|
454
Sources/WireGuardApp/Base.lproj/Localizable.strings
Normal file
@ -0,0 +1,454 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
// Generic alert action names
|
||||
|
||||
"actionOK" = "OK";
|
||||
"actionCancel" = "Cancel";
|
||||
"actionSave" = "Save";
|
||||
|
||||
// Tunnels list UI
|
||||
|
||||
"tunnelsListTitle" = "WireGuard";
|
||||
"tunnelsListSettingsButtonTitle" = "Settings";
|
||||
"tunnelsListCenteredAddTunnelButtonTitle" = "Add a tunnel";
|
||||
"tunnelsListSwipeDeleteButtonTitle" = "Delete";
|
||||
"tunnelsListSelectButtonTitle" = "Select";
|
||||
"tunnelsListSelectAllButtonTitle" = "Select All";
|
||||
"tunnelsListDeleteButtonTitle" = "Delete";
|
||||
"tunnelsListSelectedTitle (%d)" = "%d selected";
|
||||
"tunnelListCaptionOnDemand" = "On-Demand";
|
||||
|
||||
// Tunnels list menu
|
||||
|
||||
"addTunnelMenuHeader" = "Add a new WireGuard tunnel";
|
||||
"addTunnelMenuImportFile" = "Create from file or archive";
|
||||
"addTunnelMenuQRCode" = "Create from QR code";
|
||||
"addTunnelMenuFromScratch" = "Create from scratch";
|
||||
|
||||
// Tunnels list alerts
|
||||
|
||||
"alertImportedFromMultipleFilesTitle (%d)" = "Created %d tunnels";
|
||||
"alertImportedFromMultipleFilesMessage (%1$d of %2$d)" = "Created %1$d of %2$d tunnels from imported files";
|
||||
|
||||
"alertImportedFromZipTitle (%d)" = "Created %d tunnels";
|
||||
"alertImportedFromZipMessage (%1$d of %2$d)" = "Created %1$d of %2$d tunnels from zip archive";
|
||||
|
||||
"alertBadConfigImportTitle" = "Unable to import tunnel";
|
||||
"alertBadConfigImportMessage (%@)" = "The file ‘%@’ does not contain a valid WireGuard configuration";
|
||||
|
||||
"deleteTunnelsConfirmationAlertButtonTitle" = "Delete";
|
||||
"deleteTunnelConfirmationAlertButtonMessage (%d)" = "Delete %d tunnel?";
|
||||
"deleteTunnelsConfirmationAlertButtonMessage (%d)" = "Delete %d tunnels?";
|
||||
|
||||
// Tunnel detail and edit UI
|
||||
|
||||
"newTunnelViewTitle" = "New configuration";
|
||||
"editTunnelViewTitle" = "Edit configuration";
|
||||
|
||||
"tunnelSectionTitleStatus" = "Status";
|
||||
|
||||
"tunnelStatusInactive" = "Inactive";
|
||||
"tunnelStatusActivating" = "Activating";
|
||||
"tunnelStatusActive" = "Active";
|
||||
"tunnelStatusDeactivating" = "Deactivating";
|
||||
"tunnelStatusReasserting" = "Reactivating";
|
||||
"tunnelStatusRestarting" = "Restarting";
|
||||
"tunnelStatusWaiting" = "Waiting";
|
||||
|
||||
"tunnelStatusAddendumOnDemand" = " (On-Demand)";
|
||||
"tunnelStatusOnDemandDisabled" = "On-Demand Disabled";
|
||||
"tunnelStatusAddendumOnDemandEnabled" = ", On-Demand Enabled";
|
||||
"tunnelStatusAddendumOnDemandDisabled" = ", On-Demand Disabled";
|
||||
|
||||
"macToggleStatusButtonActivate" = "Activate";
|
||||
"macToggleStatusButtonActivating" = "Activating…";
|
||||
"macToggleStatusButtonDeactivate" = "Deactivate";
|
||||
"macToggleStatusButtonDeactivating" = "Deactivating…";
|
||||
"macToggleStatusButtonReasserting" = "Reactivating…";
|
||||
"macToggleStatusButtonRestarting" = "Restarting…";
|
||||
"macToggleStatusButtonWaiting" = "Waiting…";
|
||||
"macToggleStatusButtonEnableOnDemand" = "Enable On-Demand";
|
||||
"macToggleStatusButtonDisableOnDemand" = "Disable On-Demand";
|
||||
"macToggleStatusButtonDisableOnDemandDeactivate" = "Disable On-Demand and Deactivate";
|
||||
|
||||
"tunnelSectionTitleInterface" = "Interface";
|
||||
|
||||
"tunnelInterfaceName" = "Name";
|
||||
"tunnelInterfacePrivateKey" = "Private key";
|
||||
"tunnelInterfacePublicKey" = "Public key";
|
||||
"tunnelInterfaceGenerateKeypair" = "Generate keypair";
|
||||
"tunnelInterfaceAddresses" = "Addresses";
|
||||
"tunnelInterfaceListenPort" = "Listen port";
|
||||
"tunnelInterfaceMTU" = "MTU";
|
||||
"tunnelInterfaceDNS" = "DNS servers";
|
||||
"tunnelInterfaceStatus" = "Status";
|
||||
|
||||
"tunnelSectionTitlePeer" = "Peer";
|
||||
|
||||
"tunnelPeerPublicKey" = "Public key";
|
||||
"tunnelPeerPreSharedKey" = "Preshared key";
|
||||
"tunnelPeerEndpoint" = "Endpoint";
|
||||
"tunnelPeerPersistentKeepalive" = "Persistent keepalive";
|
||||
"tunnelPeerAllowedIPs" = "Allowed IPs";
|
||||
"tunnelPeerRxBytes" = "Data received";
|
||||
"tunnelPeerTxBytes" = "Data sent";
|
||||
"tunnelPeerLastHandshakeTime" = "Latest handshake";
|
||||
"tunnelPeerExcludePrivateIPs" = "Exclude private IPs";
|
||||
|
||||
"tunnelSectionTitleOnDemand" = "On-Demand Activation";
|
||||
|
||||
"tunnelOnDemandCellular" = "Cellular";
|
||||
"tunnelOnDemandEthernet" = "Ethernet";
|
||||
"tunnelOnDemandWiFi" = "Wi-Fi";
|
||||
"tunnelOnDemandSSIDsKey" = "SSIDs";
|
||||
|
||||
"tunnelOnDemandAnySSID" = "Any SSID";
|
||||
"tunnelOnDemandOnlyTheseSSIDs" = "Only these SSIDs";
|
||||
"tunnelOnDemandExceptTheseSSIDs" = "Except these SSIDs";
|
||||
"tunnelOnDemandOnlySSID (%d)" = "Only %d SSID";
|
||||
"tunnelOnDemandOnlySSIDs (%d)" = "Only %d SSIDs";
|
||||
"tunnelOnDemandExceptSSID (%d)" = "Except %d SSID";
|
||||
"tunnelOnDemandExceptSSIDs (%d)" = "Except %d SSIDs";
|
||||
"tunnelOnDemandSSIDOptionDescriptionMac (%1$@: %2$@)" = "%1$@: %2$@";
|
||||
|
||||
"tunnelOnDemandSSIDViewTitle" = "SSIDs";
|
||||
"tunnelOnDemandSectionTitleSelectedSSIDs" = "SSIDs";
|
||||
"tunnelOnDemandNoSSIDs" = "No SSIDs";
|
||||
"tunnelOnDemandSectionTitleAddSSIDs" = "Add SSIDs";
|
||||
"tunnelOnDemandAddMessageAddConnectedSSID (%@)" = "Add connected: %@";
|
||||
"tunnelOnDemandAddMessageAddNewSSID" = "Add new";
|
||||
"tunnelOnDemandSSIDTextFieldPlaceholder" = "SSID";
|
||||
|
||||
"tunnelOnDemandKey" = "On-demand";
|
||||
"tunnelOnDemandOptionOff" = "Off";
|
||||
"tunnelOnDemandOptionWiFiOnly" = "Wi-Fi only";
|
||||
"tunnelOnDemandOptionWiFiOrCellular" = "Wi-Fi or cellular";
|
||||
"tunnelOnDemandOptionCellularOnly" = "Cellular only";
|
||||
"tunnelOnDemandOptionWiFiOrEthernet" = "Wi-Fi or ethernet";
|
||||
"tunnelOnDemandOptionEthernetOnly" = "Ethernet only";
|
||||
|
||||
"addPeerButtonTitle" = "Add peer";
|
||||
|
||||
"deletePeerButtonTitle" = "Delete peer";
|
||||
"deletePeerConfirmationAlertButtonTitle" = "Delete";
|
||||
"deletePeerConfirmationAlertMessage" = "Delete this peer?";
|
||||
|
||||
"deleteTunnelButtonTitle" = "Delete tunnel";
|
||||
"deleteTunnelConfirmationAlertButtonTitle" = "Delete";
|
||||
"deleteTunnelConfirmationAlertMessage" = "Delete this tunnel?";
|
||||
|
||||
"tunnelEditPlaceholderTextRequired" = "Required";
|
||||
"tunnelEditPlaceholderTextOptional" = "Optional";
|
||||
"tunnelEditPlaceholderTextAutomatic" = "Automatic";
|
||||
"tunnelEditPlaceholderTextStronglyRecommended" = "Strongly recommended";
|
||||
"tunnelEditPlaceholderTextOff" = "Off";
|
||||
|
||||
"tunnelPeerPersistentKeepaliveValue (%@)" = "every %@ seconds";
|
||||
"tunnelHandshakeTimestampNow" = "Now";
|
||||
"tunnelHandshakeTimestampSystemClockBackward" = "(System clock wound backwards)";
|
||||
"tunnelHandshakeTimestampAgo (%@)" = "%@ ago";
|
||||
"tunnelHandshakeTimestampYear (%d)" = "%d year";
|
||||
"tunnelHandshakeTimestampYears (%d)" = "%d years";
|
||||
"tunnelHandshakeTimestampDay (%d)" = "%d day";
|
||||
"tunnelHandshakeTimestampDays (%d)" = "%d days";
|
||||
"tunnelHandshakeTimestampHour (%d)" = "%d hour";
|
||||
"tunnelHandshakeTimestampHours (%d)" = "%d hours";
|
||||
"tunnelHandshakeTimestampMinute (%d)" = "%d minute";
|
||||
"tunnelHandshakeTimestampMinutes (%d)" = "%d minutes";
|
||||
"tunnelHandshakeTimestampSecond (%d)" = "%d second";
|
||||
"tunnelHandshakeTimestampSeconds (%d)" = "%d seconds";
|
||||
|
||||
"tunnelHandshakeTimestampHours hh:mm:ss (%@)" = "%@ hours";
|
||||
"tunnelHandshakeTimestampMinutes mm:ss (%@)" = "%@ minutes";
|
||||
|
||||
"tunnelPeerPresharedKeyEnabled" = "enabled";
|
||||
|
||||
// Error alerts while creating / editing a tunnel configuration
|
||||
|
||||
/* Alert title for error in the interface data */
|
||||
"alertInvalidInterfaceTitle" = "Invalid interface";
|
||||
|
||||
/* Any one of the following alert messages can go with the above title */
|
||||
"alertInvalidInterfaceMessageNameRequired" = "Interface name is required";
|
||||
"alertInvalidInterfaceMessagePrivateKeyRequired" = "Interface’s private key is required";
|
||||
"alertInvalidInterfaceMessagePrivateKeyInvalid" = "Interface’s private key must be a 32-byte key in base64 encoding";
|
||||
"alertInvalidInterfaceMessageAddressInvalid" = "Interface addresses must be a list of comma-separated IP addresses, optionally in CIDR notation";
|
||||
"alertInvalidInterfaceMessageListenPortInvalid" = "Interface’s listen port must be between 0 and 65535, or unspecified";
|
||||
"alertInvalidInterfaceMessageMTUInvalid" = "Interface’s MTU must be between 576 and 65535, or unspecified";
|
||||
"alertInvalidInterfaceMessageDNSInvalid" = "Interface’s DNS servers must be a list of comma-separated IP addresses";
|
||||
|
||||
/* Alert title for error in the peer data */
|
||||
"alertInvalidPeerTitle" = "Invalid peer";
|
||||
|
||||
/* Any one of the following alert messages can go with the above title */
|
||||
"alertInvalidPeerMessagePublicKeyRequired" = "Peer’s public key is required";
|
||||
"alertInvalidPeerMessagePublicKeyInvalid" = "Peer’s public key must be a 32-byte key in base64 encoding";
|
||||
"alertInvalidPeerMessagePreSharedKeyInvalid" = "Peer’s preshared key must be a 32-byte key in base64 encoding";
|
||||
"alertInvalidPeerMessageAllowedIPsInvalid" = "Peer’s allowed IPs must be a list of comma-separated IP addresses, optionally in CIDR notation";
|
||||
"alertInvalidPeerMessageEndpointInvalid" = "Peer’s endpoint must be of the form ‘host:port’ or ‘[host]:port’";
|
||||
"alertInvalidPeerMessagePersistentKeepaliveInvalid" = "Peer’s persistent keepalive must be between 0 to 65535, or unspecified";
|
||||
"alertInvalidPeerMessagePublicKeyDuplicated" = "Two or more peers cannot have the same public key";
|
||||
|
||||
// Scanning QR code UI
|
||||
|
||||
"scanQRCodeViewTitle" = "Scan QR code";
|
||||
"scanQRCodeTipText" = "Tip: Generate with `qrencode -t ansiutf8 < tunnel.conf`";
|
||||
|
||||
// Scanning QR code alerts
|
||||
|
||||
"alertScanQRCodeCameraUnsupportedTitle" = "Camera Unsupported";
|
||||
"alertScanQRCodeCameraUnsupportedMessage" = "This device is not able to scan QR codes";
|
||||
|
||||
"alertScanQRCodeInvalidQRCodeTitle" = "Invalid QR Code";
|
||||
"alertScanQRCodeInvalidQRCodeMessage" = "The scanned QR code is not a valid WireGuard configuration";
|
||||
|
||||
"alertScanQRCodeUnreadableQRCodeTitle" = "Invalid Code";
|
||||
"alertScanQRCodeUnreadableQRCodeMessage" = "The scanned code could not be read";
|
||||
|
||||
"alertScanQRCodeNamePromptTitle" = "Please name the scanned tunnel";
|
||||
|
||||
// Settings UI
|
||||
|
||||
"settingsViewTitle" = "Settings";
|
||||
|
||||
"settingsSectionTitleAbout" = "About";
|
||||
"settingsVersionKeyWireGuardForIOS" = "WireGuard for iOS";
|
||||
"settingsVersionKeyWireGuardGoBackend" = "WireGuard Go Backend";
|
||||
|
||||
"settingsSectionTitleExportConfigurations" = "Export configurations";
|
||||
"settingsExportZipButtonTitle" = "Export zip archive";
|
||||
|
||||
"settingsSectionTitleTunnelLog" = "Log";
|
||||
"settingsViewLogButtonTitle" = "View log";
|
||||
|
||||
// Log view
|
||||
|
||||
"logViewTitle" = "Log";
|
||||
|
||||
// Log alerts
|
||||
|
||||
"alertUnableToRemovePreviousLogTitle" = "Log export failed";
|
||||
"alertUnableToRemovePreviousLogMessage" = "The pre-existing log could not be cleared";
|
||||
|
||||
"alertUnableToWriteLogTitle" = "Log export failed";
|
||||
"alertUnableToWriteLogMessage" = "Unable to write logs to file";
|
||||
|
||||
// Zip import / export error alerts
|
||||
|
||||
"alertCantOpenInputZipFileTitle" = "Unable to read zip archive";
|
||||
"alertCantOpenInputZipFileMessage" = "The zip archive could not be read.";
|
||||
|
||||
"alertCantOpenOutputZipFileForWritingTitle" = "Unable to create zip archive";
|
||||
"alertCantOpenOutputZipFileForWritingMessage" = "Could not open zip file for writing.";
|
||||
|
||||
"alertBadArchiveTitle" = "Unable to read zip archive";
|
||||
"alertBadArchiveMessage" = "Bad or corrupt zip archive.";
|
||||
|
||||
"alertNoTunnelsToExportTitle" = "Nothing to export";
|
||||
"alertNoTunnelsToExportMessage" = "There are no tunnels to export";
|
||||
|
||||
"alertNoTunnelsInImportedZipArchiveTitle" = "No tunnels in zip archive";
|
||||
"alertNoTunnelsInImportedZipArchiveMessage" = "No .conf tunnel files were found inside the zip archive.";
|
||||
|
||||
// Conf import error alerts
|
||||
|
||||
"alertCantOpenInputConfFileTitle" = "Unable to import from file";
|
||||
"alertCantOpenInputConfFileMessage (%@)" = "The file ‘%@’ could not be read.";
|
||||
|
||||
// Tunnel management error alerts
|
||||
|
||||
"alertTunnelActivationFailureTitle" = "Activation failure";
|
||||
"alertTunnelActivationFailureMessage" = "The tunnel could not be activated. Please ensure that you are connected to the Internet.";
|
||||
"alertTunnelActivationSavedConfigFailureMessage" = "Unable to retrieve tunnel information from the saved configuration.";
|
||||
"alertTunnelActivationBackendFailureMessage" = "Unable to turn on Go backend library.";
|
||||
"alertTunnelActivationFileDescriptorFailureMessage" = "Unable to determine TUN device file descriptor.";
|
||||
"alertTunnelActivationSetNetworkSettingsMessage" = "Unable to apply network settings to tunnel object.";
|
||||
|
||||
"alertTunnelDNSFailureTitle" = "DNS resolution failure";
|
||||
"alertTunnelDNSFailureMessage" = "One or more endpoint domains could not be resolved.";
|
||||
|
||||
"alertTunnelNameEmptyTitle" = "No name provided";
|
||||
"alertTunnelNameEmptyMessage" = "Cannot create tunnel with an empty name";
|
||||
|
||||
"alertTunnelAlreadyExistsWithThatNameTitle" = "Name already exists";
|
||||
"alertTunnelAlreadyExistsWithThatNameMessage" = "A tunnel with that name already exists";
|
||||
|
||||
"alertTunnelActivationErrorTunnelIsNotInactiveTitle" = "Activation in progress";
|
||||
"alertTunnelActivationErrorTunnelIsNotInactiveMessage" = "The tunnel is already active or in the process of being activated";
|
||||
|
||||
// Tunnel management error alerts on system error
|
||||
|
||||
/* The alert message that goes with the following titles would be
|
||||
one of the alertSystemErrorMessage* listed further down */
|
||||
"alertSystemErrorOnListingTunnelsTitle" = "Unable to list tunnels";
|
||||
"alertSystemErrorOnAddTunnelTitle" = "Unable to create tunnel";
|
||||
"alertSystemErrorOnModifyTunnelTitle" = "Unable to modify tunnel";
|
||||
"alertSystemErrorOnRemoveTunnelTitle" = "Unable to remove tunnel";
|
||||
|
||||
/* The alert message for this alert shall include
|
||||
one of the alertSystemErrorMessage* listed further down */
|
||||
"alertTunnelActivationSystemErrorTitle" = "Activation failure";
|
||||
"alertTunnelActivationSystemErrorMessage (%@)" = "The tunnel could not be activated. %@";
|
||||
|
||||
/* alertSystemErrorMessage* messages */
|
||||
"alertSystemErrorMessageTunnelConfigurationInvalid" = "The configuration is invalid.";
|
||||
"alertSystemErrorMessageTunnelConfigurationDisabled" = "The configuration is disabled.";
|
||||
"alertSystemErrorMessageTunnelConnectionFailed" = "The connection failed.";
|
||||
"alertSystemErrorMessageTunnelConfigurationStale" = "The configuration is stale.";
|
||||
"alertSystemErrorMessageTunnelConfigurationReadWriteFailed" = "Reading or writing the configuration failed.";
|
||||
"alertSystemErrorMessageTunnelConfigurationUnknown" = "Unknown system error.";
|
||||
|
||||
// Mac status bar menu / pulldown menu / main menu
|
||||
|
||||
"macMenuNetworks (%@)" = "Networks: %@";
|
||||
"macMenuNetworksNone" = "Networks: None";
|
||||
|
||||
"macMenuTitle" = "WireGuard";
|
||||
"macTunnelsMenuTitle" = "Tunnels";
|
||||
"macMenuManageTunnels" = "Manage Tunnels";
|
||||
"macMenuImportTunnels" = "Import Tunnel(s) from File…";
|
||||
"macMenuAddEmptyTunnel" = "Add Empty Tunnel…";
|
||||
"macMenuViewLog" = "View Log";
|
||||
"macMenuExportTunnels" = "Export Tunnels to Zip…";
|
||||
"macMenuAbout" = "About WireGuard";
|
||||
"macMenuQuit" = "Quit WireGuard";
|
||||
|
||||
"macMenuHideApp" = "Hide WireGuard";
|
||||
"macMenuHideOtherApps" = "Hide Others";
|
||||
"macMenuShowAllApps" = "Show All";
|
||||
|
||||
"macMenuFile" = "File";
|
||||
"macMenuCloseWindow" = "Close Window";
|
||||
|
||||
"macMenuEdit" = "Edit";
|
||||
"macMenuCut" = "Cut";
|
||||
"macMenuCopy" = "Copy";
|
||||
"macMenuPaste" = "Paste";
|
||||
"macMenuSelectAll" = "Select All";
|
||||
|
||||
"macMenuTunnel" = "Tunnel";
|
||||
"macMenuToggleStatus" = "Toggle Status";
|
||||
"macMenuEditTunnel" = "Edit…";
|
||||
"macMenuDeleteSelected" = "Delete Selected";
|
||||
|
||||
"macMenuWindow" = "Window";
|
||||
"macMenuMinimize" = "Minimize";
|
||||
"macMenuZoom" = "Zoom";
|
||||
|
||||
// Mac manage tunnels window
|
||||
|
||||
"macWindowTitleManageTunnels" = "Manage WireGuard Tunnels";
|
||||
|
||||
"macDeleteTunnelConfirmationAlertMessage (%@)" = "Are you sure you want to delete ‘%@’?";
|
||||
"macDeleteMultipleTunnelsConfirmationAlertMessage (%d)" = "Are you sure you want to delete %d tunnels?";
|
||||
"macDeleteTunnelConfirmationAlertInfo" = "You cannot undo this action.";
|
||||
"macDeleteTunnelConfirmationAlertButtonTitleDelete" = "Delete";
|
||||
"macDeleteTunnelConfirmationAlertButtonTitleCancel" = "Cancel";
|
||||
"macDeleteTunnelConfirmationAlertButtonTitleDeleting" = "Deleting…";
|
||||
|
||||
"macButtonImportTunnels" = "Import tunnel(s) from file";
|
||||
"macSheetButtonImport" = "Import";
|
||||
|
||||
"macNameFieldExportLog" = "Save log to:";
|
||||
"macSheetButtonExportLog" = "Save";
|
||||
|
||||
"macNameFieldExportZip" = "Export tunnels to:";
|
||||
"macSheetButtonExportZip" = "Save";
|
||||
|
||||
"macButtonDeleteTunnels (%d)" = "Delete %d tunnels";
|
||||
|
||||
"macButtonEdit" = "Edit";
|
||||
|
||||
// Mac detail/edit view fields
|
||||
|
||||
"macFieldKey (%@)" = "%@:";
|
||||
"macFieldOnDemand" = "On-Demand:";
|
||||
"macFieldOnDemandSSIDs" = "SSIDs:";
|
||||
|
||||
// Mac status display
|
||||
|
||||
"macStatus (%@)" = "Status: %@";
|
||||
|
||||
// Mac editing config
|
||||
|
||||
"macEditDiscard" = "Discard";
|
||||
"macEditSave" = "Save";
|
||||
|
||||
"macAlertNameIsEmpty" = "Name is required";
|
||||
"macAlertDuplicateName (%@)" = "Another tunnel already exists with the name ‘%@’.";
|
||||
|
||||
"macAlertInvalidLine (%@)" = "Invalid line: ‘%@’.";
|
||||
|
||||
"macAlertNoInterface" = "Configuration must have an ‘Interface’ section.";
|
||||
"macAlertMultipleInterfaces" = "Configuration must have only one ‘Interface’ section.";
|
||||
"macAlertPrivateKeyInvalid" = "Private key is invalid.";
|
||||
"macAlertListenPortInvalid (%@)" = "Listen port ‘%@’ is invalid.";
|
||||
"macAlertAddressInvalid (%@)" = "Address ‘%@’ is invalid.";
|
||||
"macAlertDNSInvalid (%@)" = "DNS ‘%@’ is invalid.";
|
||||
"macAlertMTUInvalid (%@)" = "MTU ‘%@’ is invalid.";
|
||||
|
||||
"macAlertUnrecognizedInterfaceKey (%@)" = "Interface contains unrecognized key ‘%@’";
|
||||
"macAlertInfoUnrecognizedInterfaceKey" = "Valid keys are: ‘PrivateKey’, ‘ListenPort’, ‘Address’, ‘DNS’ and ‘MTU’.";
|
||||
|
||||
"macAlertPublicKeyInvalid" = "Public key is invalid";
|
||||
"macAlertPreSharedKeyInvalid" = "Preshared key is invalid";
|
||||
"macAlertAllowedIPInvalid (%@)" = "Allowed IP ‘%@’ is invalid";
|
||||
"macAlertEndpointInvalid (%@)" = "Endpoint ‘%@’ is invalid";
|
||||
"macAlertPersistentKeepliveInvalid (%@)" = "Persistent keepalive value ‘%@’ is invalid";
|
||||
|
||||
"macAlertUnrecognizedPeerKey (%@)" = "Peer contains unrecognized key ‘%@’";
|
||||
"macAlertInfoUnrecognizedPeerKey" = "Valid keys are: ‘PublicKey’, ‘PresharedKey’, ‘AllowedIPs’, ‘Endpoint’ and ‘PersistentKeepalive’";
|
||||
|
||||
"macAlertMultipleEntriesForKey (%@)" = "There should be only one entry per section for key ‘%@’";
|
||||
|
||||
// Mac about dialog
|
||||
|
||||
"macAppVersion (%@)" = "App version: %@";
|
||||
"macGoBackendVersion (%@)" = "Go backend version: %@";
|
||||
|
||||
// Privacy
|
||||
|
||||
"macExportPrivateData" = "export tunnel private keys";
|
||||
"macViewPrivateData" = "view tunnel private keys";
|
||||
"iosExportPrivateData" = "Authenticate to export tunnel private keys.";
|
||||
"iosViewPrivateData" = "Authenticate to view tunnel private keys.";
|
||||
|
||||
// Mac alert
|
||||
|
||||
"macConfirmAndQuitAlertMessage" = "Do you want to close the tunnels manager or quit WireGuard entirely?";
|
||||
"macConfirmAndQuitAlertInfo" = "If you close the tunnels manager, WireGuard will continue to be available from the menu bar icon.";
|
||||
"macConfirmAndQuitInfoWithActiveTunnel (%@)" = "If you close the tunnels manager, WireGuard will continue to be available from the menu bar icon.\n\nNote that if you quit WireGuard entirely the currently active tunnel ('%@') will still remain active until you deactivate it from this application or through the Network panel in System Preferences.";
|
||||
"macConfirmAndQuitAlertQuitWireGuard" = "Quit WireGuard";
|
||||
"macConfirmAndQuitAlertCloseWindow" = "Close Tunnels Manager";
|
||||
|
||||
"macAppExitingWithActiveTunnelMessage" = "WireGuard is exiting with an active tunnel";
|
||||
"macAppExitingWithActiveTunnelInfo" = "The tunnel will remain active after exiting. You may disable it by reopening this application or through the Network panel in System Preferences.";
|
||||
|
||||
// Mac tooltip
|
||||
|
||||
"macToolTipEditTunnel" = "Edit tunnel (⌘E)";
|
||||
"macToolTipToggleStatus" = "Toggle status (⌘T)";
|
||||
|
||||
// Mac log view
|
||||
|
||||
"macLogColumnTitleTime" = "Time";
|
||||
"macLogColumnTitleLogMessage" = "Log message";
|
||||
"macLogButtonTitleClose" = "Close";
|
||||
"macLogButtonTitleSave" = "Save…";
|
||||
|
||||
// Mac unusable tunnel view
|
||||
|
||||
"macUnusableTunnelMessage" = "The configuration for this tunnel cannot be found in the keychain.";
|
||||
"macUnusableTunnelInfo" = "In case this tunnel was created by another user, only that user can view, edit, or activate this tunnel.";
|
||||
"macUnusableTunnelButtonTitleDeleteTunnel" = "Delete tunnel";
|
||||
|
||||
// Mac App Store updating alert
|
||||
|
||||
"macAppStoreUpdatingAlertMessage" = "App Store would like to update WireGuard";
|
||||
"macAppStoreUpdatingAlertInfoWithOnDemand (%@)" = "Please disable on-demand for tunnel ‘%@’, deactivate it, and then continue updating in App Store.";
|
||||
"macAppStoreUpdatingAlertInfoWithoutOnDemand (%@)" = "Please deactivate tunnel ‘%@’ and then continue updating in App Store.";
|
||||
|
||||
// Donation
|
||||
|
||||
"donateLink" = "♥ Donate to the WireGuard Project";
|
@ -3,7 +3,8 @@
|
||||
// You Apple developer account's Team ID
|
||||
DEVELOPMENT_TEAM = <team_id>
|
||||
|
||||
// The bundle identifier of this app.
|
||||
// The bundle identifier of the apps.
|
||||
// Should be an app id created at developer.apple.com
|
||||
// with Network Extensions capabilty.
|
||||
APP_ID = <app_id>
|
||||
APP_ID_IOS = <app_id>
|
||||
APP_ID_MACOS = <app_id>
|
2
Sources/WireGuardApp/Config/Version.xcconfig
Normal file
@ -0,0 +1,2 @@
|
||||
VERSION_NAME = 1.0.16
|
||||
VERSION_ID = 27
|
@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
Before Width: | Height: | Size: 953 B After Width: | Height: | Size: 953 B |
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
133
Sources/WireGuardApp/Tunnel/ActivateOnDemandOption.swift
Normal file
@ -0,0 +1,133 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import NetworkExtension
|
||||
|
||||
enum ActivateOnDemandOption: Equatable {
|
||||
case off
|
||||
case wiFiInterfaceOnly(ActivateOnDemandSSIDOption)
|
||||
case nonWiFiInterfaceOnly
|
||||
case anyInterface(ActivateOnDemandSSIDOption)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private let nonWiFiInterfaceType: NEOnDemandRuleInterfaceType = .cellular
|
||||
#elseif os(macOS)
|
||||
private let nonWiFiInterfaceType: NEOnDemandRuleInterfaceType = .ethernet
|
||||
#else
|
||||
#error("Unimplemented")
|
||||
#endif
|
||||
|
||||
enum ActivateOnDemandSSIDOption: Equatable {
|
||||
case anySSID
|
||||
case onlySpecificSSIDs([String])
|
||||
case exceptSpecificSSIDs([String])
|
||||
}
|
||||
|
||||
extension ActivateOnDemandOption {
|
||||
func apply(on tunnelProviderManager: NETunnelProviderManager) {
|
||||
let rules: [NEOnDemandRule]?
|
||||
switch self {
|
||||
case .off:
|
||||
rules = nil
|
||||
case .wiFiInterfaceOnly(let ssidOption):
|
||||
rules = ssidOnDemandRules(option: ssidOption) + [NEOnDemandRuleDisconnect(interfaceType: nonWiFiInterfaceType)]
|
||||
case .nonWiFiInterfaceOnly:
|
||||
rules = [NEOnDemandRuleConnect(interfaceType: nonWiFiInterfaceType), NEOnDemandRuleDisconnect(interfaceType: .wiFi)]
|
||||
case .anyInterface(let ssidOption):
|
||||
if case .anySSID = ssidOption {
|
||||
rules = [NEOnDemandRuleConnect(interfaceType: .any)]
|
||||
} else {
|
||||
rules = ssidOnDemandRules(option: ssidOption) + [NEOnDemandRuleConnect(interfaceType: nonWiFiInterfaceType)]
|
||||
}
|
||||
}
|
||||
tunnelProviderManager.onDemandRules = rules
|
||||
tunnelProviderManager.isOnDemandEnabled = (rules != nil) && tunnelProviderManager.isOnDemandEnabled
|
||||
}
|
||||
|
||||
init(from tunnelProviderManager: NETunnelProviderManager) {
|
||||
if let onDemandRules = tunnelProviderManager.onDemandRules {
|
||||
self = ActivateOnDemandOption.create(from: onDemandRules)
|
||||
} else {
|
||||
self = .off
|
||||
}
|
||||
}
|
||||
|
||||
private static func create(from rules: [NEOnDemandRule]) -> ActivateOnDemandOption {
|
||||
switch rules.count {
|
||||
case 0:
|
||||
return .off
|
||||
case 1:
|
||||
let rule = rules[0]
|
||||
guard rule.action == .connect else { return .off }
|
||||
return .anyInterface(.anySSID)
|
||||
case 2:
|
||||
guard let connectRule = rules.first(where: { $0.action == .connect }) else {
|
||||
wg_log(.error, message: "Unexpected onDemandRules set on tunnel provider manager: \(rules.count) rules found but no connect rule.")
|
||||
return .off
|
||||
}
|
||||
guard let disconnectRule = rules.first(where: { $0.action == .disconnect }) else {
|
||||
wg_log(.error, message: "Unexpected onDemandRules set on tunnel provider manager: \(rules.count) rules found but no disconnect rule.")
|
||||
return .off
|
||||
}
|
||||
if connectRule.interfaceTypeMatch == .wiFi && disconnectRule.interfaceTypeMatch == nonWiFiInterfaceType {
|
||||
return .wiFiInterfaceOnly(.anySSID)
|
||||
} else if connectRule.interfaceTypeMatch == nonWiFiInterfaceType && disconnectRule.interfaceTypeMatch == .wiFi {
|
||||
return .nonWiFiInterfaceOnly
|
||||
} else {
|
||||
wg_log(.error, message: "Unexpected onDemandRules set on tunnel provider manager: \(rules.count) rules found but interface types are inconsistent.")
|
||||
return .off
|
||||
}
|
||||
case 3:
|
||||
guard let ssidRule = rules.first(where: { $0.interfaceTypeMatch == .wiFi && $0.ssidMatch != nil }) else { return .off }
|
||||
guard let nonWiFiRule = rules.first(where: { $0.interfaceTypeMatch == nonWiFiInterfaceType }) else { return .off }
|
||||
let ssids = ssidRule.ssidMatch!
|
||||
switch (ssidRule.action, nonWiFiRule.action) {
|
||||
case (.connect, .connect):
|
||||
return .anyInterface(.onlySpecificSSIDs(ssids))
|
||||
case (.connect, .disconnect):
|
||||
return .wiFiInterfaceOnly(.onlySpecificSSIDs(ssids))
|
||||
case (.disconnect, .connect):
|
||||
return .anyInterface(.exceptSpecificSSIDs(ssids))
|
||||
case (.disconnect, .disconnect):
|
||||
return .wiFiInterfaceOnly(.exceptSpecificSSIDs(ssids))
|
||||
default:
|
||||
wg_log(.error, message: "Unexpected onDemandRules set on tunnel provider manager: \(rules.count) rules found")
|
||||
return .off
|
||||
}
|
||||
default:
|
||||
wg_log(.error, message: "Unexpected number of onDemandRules set on tunnel provider manager: \(rules.count) rules found")
|
||||
return .off
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension NEOnDemandRuleConnect {
|
||||
convenience init(interfaceType: NEOnDemandRuleInterfaceType, ssids: [String]? = nil) {
|
||||
self.init()
|
||||
interfaceTypeMatch = interfaceType
|
||||
ssidMatch = ssids
|
||||
}
|
||||
}
|
||||
|
||||
private extension NEOnDemandRuleDisconnect {
|
||||
convenience init(interfaceType: NEOnDemandRuleInterfaceType, ssids: [String]? = nil) {
|
||||
self.init()
|
||||
interfaceTypeMatch = interfaceType
|
||||
ssidMatch = ssids
|
||||
}
|
||||
}
|
||||
|
||||
private func ssidOnDemandRules(option: ActivateOnDemandSSIDOption) -> [NEOnDemandRule] {
|
||||
switch option {
|
||||
case .anySSID:
|
||||
return [NEOnDemandRuleConnect(interfaceType: .wiFi)]
|
||||
case .onlySpecificSSIDs(let ssids):
|
||||
assert(!ssids.isEmpty)
|
||||
return [NEOnDemandRuleConnect(interfaceType: .wiFi, ssids: ssids),
|
||||
NEOnDemandRuleDisconnect(interfaceType: .wiFi)]
|
||||
case .exceptSpecificSSIDs(let ssids):
|
||||
return [NEOnDemandRuleDisconnect(interfaceType: .wiFi, ssids: ssids),
|
||||
NEOnDemandRuleConnect(interfaceType: .wiFi)]
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import NetworkExtension
|
||||
|
||||
@ -26,11 +26,11 @@ class MockTunnels {
|
||||
static func createMockTunnels() -> [NETunnelProviderManager] {
|
||||
return tunnelNames.map { tunnelName -> NETunnelProviderManager in
|
||||
|
||||
var interface = InterfaceConfiguration(privateKey: Curve25519.generatePrivateKey())
|
||||
var interface = InterfaceConfiguration(privateKey: PrivateKey())
|
||||
interface.addresses = [IPAddressRange(from: String(format: address, Int.random(in: 1 ... 10), Int.random(in: 1 ... 254)))!]
|
||||
interface.dns = dnsServers.map { DNSServer(from: $0)! }
|
||||
|
||||
var peer = PeerConfiguration(publicKey: Curve25519.generatePublicKey(fromPrivateKey: Curve25519.generatePrivateKey()))
|
||||
var peer = PeerConfiguration(publicKey: PrivateKey().publicKey)
|
||||
peer.endpoint = Endpoint(from: endpoint)
|
||||
peer.allowedIPs = [IPAddressRange(from: allowedIPs)!]
|
||||
|
185
Sources/WireGuardApp/Tunnel/TunnelConfiguration+UapiConfig.swift
Normal file
@ -0,0 +1,185 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension TunnelConfiguration {
|
||||
convenience init(fromUapiConfig uapiConfig: String, basedOn base: TunnelConfiguration? = nil) throws {
|
||||
var interfaceConfiguration: InterfaceConfiguration?
|
||||
var peerConfigurations = [PeerConfiguration]()
|
||||
|
||||
var lines = uapiConfig.split(separator: "\n")
|
||||
lines.append("")
|
||||
|
||||
var parserState = ParserState.inInterfaceSection
|
||||
var attributes = [String: String]()
|
||||
|
||||
for line in lines {
|
||||
var key = ""
|
||||
var value = ""
|
||||
|
||||
if !line.isEmpty {
|
||||
guard let equalsIndex = line.firstIndex(of: "=") else { throw ParseError.invalidLine(line) }
|
||||
key = String(line[..<equalsIndex])
|
||||
value = String(line[line.index(equalsIndex, offsetBy: 1)...])
|
||||
}
|
||||
|
||||
if line.isEmpty || key == "public_key" {
|
||||
// Previous section has ended; process the attributes collected so far
|
||||
if parserState == .inInterfaceSection {
|
||||
let interface = try TunnelConfiguration.collate(interfaceAttributes: attributes)
|
||||
guard interfaceConfiguration == nil else { throw ParseError.multipleInterfaces }
|
||||
interfaceConfiguration = interface
|
||||
parserState = .inPeerSection
|
||||
} else if parserState == .inPeerSection {
|
||||
let peer = try TunnelConfiguration.collate(peerAttributes: attributes)
|
||||
peerConfigurations.append(peer)
|
||||
}
|
||||
attributes.removeAll()
|
||||
if line.isEmpty {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if let presentValue = attributes[key] {
|
||||
if key == "allowed_ip" {
|
||||
attributes[key] = presentValue + "," + value
|
||||
} else {
|
||||
throw ParseError.multipleEntriesForKey(key)
|
||||
}
|
||||
} else {
|
||||
attributes[key] = value
|
||||
}
|
||||
|
||||
let interfaceSectionKeys: Set<String> = ["private_key", "listen_port", "fwmark"]
|
||||
let peerSectionKeys: Set<String> = ["public_key", "preshared_key", "allowed_ip", "endpoint", "persistent_keepalive_interval", "last_handshake_time_sec", "last_handshake_time_nsec", "rx_bytes", "tx_bytes", "protocol_version"]
|
||||
|
||||
if parserState == .inInterfaceSection {
|
||||
guard interfaceSectionKeys.contains(key) else {
|
||||
throw ParseError.interfaceHasUnrecognizedKey(key)
|
||||
}
|
||||
}
|
||||
if parserState == .inPeerSection {
|
||||
guard peerSectionKeys.contains(key) else {
|
||||
throw ParseError.peerHasUnrecognizedKey(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let peerPublicKeysArray = peerConfigurations.map { $0.publicKey }
|
||||
let peerPublicKeysSet = Set<PublicKey>(peerPublicKeysArray)
|
||||
if peerPublicKeysArray.count != peerPublicKeysSet.count {
|
||||
throw ParseError.multiplePeersWithSamePublicKey
|
||||
}
|
||||
|
||||
interfaceConfiguration?.addresses = base?.interface.addresses ?? []
|
||||
interfaceConfiguration?.dns = base?.interface.dns ?? []
|
||||
interfaceConfiguration?.dnsSearch = base?.interface.dnsSearch ?? []
|
||||
interfaceConfiguration?.mtu = base?.interface.mtu
|
||||
|
||||
if let interfaceConfiguration = interfaceConfiguration {
|
||||
self.init(name: base?.name, interface: interfaceConfiguration, peers: peerConfigurations)
|
||||
} else {
|
||||
throw ParseError.noInterface
|
||||
}
|
||||
}
|
||||
|
||||
private static func collate(interfaceAttributes attributes: [String: String]) throws -> InterfaceConfiguration {
|
||||
guard let privateKeyString = attributes["private_key"] else {
|
||||
throw ParseError.interfaceHasNoPrivateKey
|
||||
}
|
||||
guard let privateKey = PrivateKey(hexKey: privateKeyString) else {
|
||||
throw ParseError.interfaceHasInvalidPrivateKey(privateKeyString)
|
||||
}
|
||||
var interface = InterfaceConfiguration(privateKey: privateKey)
|
||||
if let listenPortString = attributes["listen_port"] {
|
||||
guard let listenPort = UInt16(listenPortString) else {
|
||||
throw ParseError.interfaceHasInvalidListenPort(listenPortString)
|
||||
}
|
||||
if listenPort != 0 {
|
||||
interface.listenPort = listenPort
|
||||
}
|
||||
}
|
||||
return interface
|
||||
}
|
||||
|
||||
private static func collate(peerAttributes attributes: [String: String]) throws -> PeerConfiguration {
|
||||
guard let publicKeyString = attributes["public_key"] else {
|
||||
throw ParseError.peerHasNoPublicKey
|
||||
}
|
||||
guard let publicKey = PublicKey(hexKey: publicKeyString) else {
|
||||
throw ParseError.peerHasInvalidPublicKey(publicKeyString)
|
||||
}
|
||||
var peer = PeerConfiguration(publicKey: publicKey)
|
||||
if let preSharedKeyString = attributes["preshared_key"] {
|
||||
guard let preSharedKey = PreSharedKey(hexKey: preSharedKeyString) else {
|
||||
throw ParseError.peerHasInvalidPreSharedKey(preSharedKeyString)
|
||||
}
|
||||
// TODO(zx2c4): does the compiler optimize this away?
|
||||
var accumulator: UInt8 = 0
|
||||
for index in 0..<preSharedKey.rawValue.count {
|
||||
accumulator |= preSharedKey.rawValue[index]
|
||||
}
|
||||
if accumulator != 0 {
|
||||
peer.preSharedKey = preSharedKey
|
||||
}
|
||||
}
|
||||
if let allowedIPsString = attributes["allowed_ip"] {
|
||||
var allowedIPs = [IPAddressRange]()
|
||||
for allowedIPString in allowedIPsString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) {
|
||||
guard let allowedIP = IPAddressRange(from: allowedIPString) else {
|
||||
throw ParseError.peerHasInvalidAllowedIP(allowedIPString)
|
||||
}
|
||||
allowedIPs.append(allowedIP)
|
||||
}
|
||||
peer.allowedIPs = allowedIPs
|
||||
}
|
||||
if let endpointString = attributes["endpoint"] {
|
||||
guard let endpoint = Endpoint(from: endpointString) else {
|
||||
throw ParseError.peerHasInvalidEndpoint(endpointString)
|
||||
}
|
||||
peer.endpoint = endpoint
|
||||
}
|
||||
if let persistentKeepAliveString = attributes["persistent_keepalive_interval"] {
|
||||
guard let persistentKeepAlive = UInt16(persistentKeepAliveString) else {
|
||||
throw ParseError.peerHasInvalidPersistentKeepAlive(persistentKeepAliveString)
|
||||
}
|
||||
if persistentKeepAlive != 0 {
|
||||
peer.persistentKeepAlive = persistentKeepAlive
|
||||
}
|
||||
}
|
||||
if let rxBytesString = attributes["rx_bytes"] {
|
||||
guard let rxBytes = UInt64(rxBytesString) else {
|
||||
throw ParseError.peerHasInvalidTransferBytes(rxBytesString)
|
||||
}
|
||||
if rxBytes != 0 {
|
||||
peer.rxBytes = rxBytes
|
||||
}
|
||||
}
|
||||
if let txBytesString = attributes["tx_bytes"] {
|
||||
guard let txBytes = UInt64(txBytesString) else {
|
||||
throw ParseError.peerHasInvalidTransferBytes(txBytesString)
|
||||
}
|
||||
if txBytes != 0 {
|
||||
peer.txBytes = txBytes
|
||||
}
|
||||
}
|
||||
if let lastHandshakeTimeSecString = attributes["last_handshake_time_sec"] {
|
||||
var lastHandshakeTimeSince1970: TimeInterval = 0
|
||||
guard let lastHandshakeTimeSec = UInt64(lastHandshakeTimeSecString) else {
|
||||
throw ParseError.peerHasInvalidLastHandshakeTime(lastHandshakeTimeSecString)
|
||||
}
|
||||
if lastHandshakeTimeSec != 0 {
|
||||
lastHandshakeTimeSince1970 += Double(lastHandshakeTimeSec)
|
||||
if let lastHandshakeTimeNsecString = attributes["last_handshake_time_nsec"] {
|
||||
guard let lastHandshakeTimeNsec = UInt64(lastHandshakeTimeNsecString) else {
|
||||
throw ParseError.peerHasInvalidLastHandshakeTime(lastHandshakeTimeNsecString)
|
||||
}
|
||||
lastHandshakeTimeSince1970 += Double(lastHandshakeTimeNsec) / 1000000000.0
|
||||
}
|
||||
peer.lastHandshakeTime = Date(timeIntervalSince1970: lastHandshakeTimeSince1970)
|
||||
}
|
||||
}
|
||||
return peer
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import NetworkExtension
|
||||
|
||||
@ -56,10 +56,10 @@ enum TunnelsManagerActivationError: WireGuardAppError {
|
||||
|
||||
var alertText: AlertText {
|
||||
switch self {
|
||||
case .activationFailed(let wasOnDemandEnabled):
|
||||
return (tr("alertTunnelActivationFailureTitle"), tr("alertTunnelActivationFailureMessage") + (wasOnDemandEnabled ? tr("alertTunnelActivationFailureOnDemandAddendum") : ""))
|
||||
case .activationFailedWithExtensionError(let title, let message, let wasOnDemandEnabled):
|
||||
return (title, message + (wasOnDemandEnabled ? tr("alertTunnelActivationFailureOnDemandAddendum") : ""))
|
||||
case .activationFailed:
|
||||
return (tr("alertTunnelActivationFailureTitle"), tr("alertTunnelActivationFailureMessage"))
|
||||
case .activationFailedWithExtensionError(let title, let message, _):
|
||||
return (title, message)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import NetworkExtension
|
||||
@ -27,6 +27,8 @@ import NetworkExtension
|
||||
self = .reasserting
|
||||
case .invalid:
|
||||
self = .inactive
|
||||
@unknown default:
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -54,6 +56,8 @@ extension NEVPNStatus: CustomDebugStringConvertible {
|
||||
case .disconnecting: return "disconnecting"
|
||||
case .reasserting: return "reasserting"
|
||||
case .invalid: return "invalid"
|
||||
@unknown default:
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
}
|
750
Sources/WireGuardApp/Tunnel/TunnelsManager.swift
Normal file
@ -0,0 +1,750 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import NetworkExtension
|
||||
import os.log
|
||||
|
||||
protocol TunnelsManagerListDelegate: AnyObject {
|
||||
func tunnelAdded(at index: Int)
|
||||
func tunnelModified(at index: Int)
|
||||
func tunnelMoved(from oldIndex: Int, to newIndex: Int)
|
||||
func tunnelRemoved(at index: Int, tunnel: TunnelContainer)
|
||||
}
|
||||
|
||||
protocol TunnelsManagerActivationDelegate: AnyObject {
|
||||
func tunnelActivationAttemptFailed(tunnel: TunnelContainer, error: TunnelsManagerActivationAttemptError) // startTunnel wasn't called or failed
|
||||
func tunnelActivationAttemptSucceeded(tunnel: TunnelContainer) // startTunnel succeeded
|
||||
func tunnelActivationFailed(tunnel: TunnelContainer, error: TunnelsManagerActivationError) // status didn't change to connected
|
||||
func tunnelActivationSucceeded(tunnel: TunnelContainer) // status changed to connected
|
||||
}
|
||||
|
||||
class TunnelsManager {
|
||||
private var tunnels: [TunnelContainer]
|
||||
weak var tunnelsListDelegate: TunnelsManagerListDelegate?
|
||||
weak var activationDelegate: TunnelsManagerActivationDelegate?
|
||||
private var statusObservationToken: NotificationToken?
|
||||
private var waiteeObservationToken: NSKeyValueObservation?
|
||||
private var configurationsObservationToken: NotificationToken?
|
||||
|
||||
init(tunnelProviders: [NETunnelProviderManager]) {
|
||||
tunnels = tunnelProviders.map { TunnelContainer(tunnel: $0) }.sorted { TunnelsManager.tunnelNameIsLessThan($0.name, $1.name) }
|
||||
startObservingTunnelStatuses()
|
||||
startObservingTunnelConfigurations()
|
||||
}
|
||||
|
||||
static func create(completionHandler: @escaping (Result<TunnelsManager, TunnelsManagerError>) -> Void) {
|
||||
#if targetEnvironment(simulator)
|
||||
completionHandler(.success(TunnelsManager(tunnelProviders: MockTunnels.createMockTunnels())))
|
||||
#else
|
||||
NETunnelProviderManager.loadAllFromPreferences { managers, error in
|
||||
if let error = error {
|
||||
wg_log(.error, message: "Failed to load tunnel provider managers: \(error)")
|
||||
completionHandler(.failure(TunnelsManagerError.systemErrorOnListingTunnels(systemError: error)))
|
||||
return
|
||||
}
|
||||
|
||||
var tunnelManagers = managers ?? []
|
||||
var refs: Set<Data> = []
|
||||
var tunnelNames: Set<String> = []
|
||||
for (index, tunnelManager) in tunnelManagers.enumerated().reversed() {
|
||||
if let tunnelName = tunnelManager.localizedDescription {
|
||||
tunnelNames.insert(tunnelName)
|
||||
}
|
||||
guard let proto = tunnelManager.protocolConfiguration as? NETunnelProviderProtocol else { continue }
|
||||
if proto.migrateConfigurationIfNeeded(called: tunnelManager.localizedDescription ?? "unknown") {
|
||||
tunnelManager.saveToPreferences { _ in }
|
||||
}
|
||||
#if os(iOS)
|
||||
let passwordRef = proto.verifyConfigurationReference() ? proto.passwordReference : nil
|
||||
#elseif os(macOS)
|
||||
let passwordRef: Data?
|
||||
if proto.providerConfiguration?["UID"] as? uid_t == getuid() {
|
||||
passwordRef = proto.verifyConfigurationReference() ? proto.passwordReference : nil
|
||||
} else {
|
||||
passwordRef = proto.passwordReference // To handle multiple users in macOS, we skip verifying
|
||||
}
|
||||
#else
|
||||
#error("Unimplemented")
|
||||
#endif
|
||||
if let ref = passwordRef {
|
||||
refs.insert(ref)
|
||||
} else {
|
||||
wg_log(.info, message: "Removing orphaned tunnel with non-verifying keychain entry: \(tunnelManager.localizedDescription ?? "<unknown>")")
|
||||
tunnelManager.removeFromPreferences { _ in }
|
||||
tunnelManagers.remove(at: index)
|
||||
}
|
||||
}
|
||||
Keychain.deleteReferences(except: refs)
|
||||
#if os(iOS)
|
||||
RecentTunnelsTracker.cleanupTunnels(except: tunnelNames)
|
||||
#endif
|
||||
completionHandler(.success(TunnelsManager(tunnelProviders: tunnelManagers)))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func reload() {
|
||||
NETunnelProviderManager.loadAllFromPreferences { [weak self] managers, _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
let loadedTunnelProviders = managers ?? []
|
||||
|
||||
for (index, currentTunnel) in self.tunnels.enumerated().reversed() {
|
||||
if !loadedTunnelProviders.contains(where: { $0.isEquivalentTo(currentTunnel) }) {
|
||||
// Tunnel was deleted outside the app
|
||||
self.tunnels.remove(at: index)
|
||||
self.tunnelsListDelegate?.tunnelRemoved(at: index, tunnel: currentTunnel)
|
||||
}
|
||||
}
|
||||
for loadedTunnelProvider in loadedTunnelProviders {
|
||||
if let matchingTunnel = self.tunnels.first(where: { loadedTunnelProvider.isEquivalentTo($0) }) {
|
||||
matchingTunnel.tunnelProvider = loadedTunnelProvider
|
||||
matchingTunnel.refreshStatus()
|
||||
} else {
|
||||
// Tunnel was added outside the app
|
||||
if let proto = loadedTunnelProvider.protocolConfiguration as? NETunnelProviderProtocol {
|
||||
if proto.migrateConfigurationIfNeeded(called: loadedTunnelProvider.localizedDescription ?? "unknown") {
|
||||
loadedTunnelProvider.saveToPreferences { _ in }
|
||||
}
|
||||
}
|
||||
let tunnel = TunnelContainer(tunnel: loadedTunnelProvider)
|
||||
self.tunnels.append(tunnel)
|
||||
self.tunnels.sort { TunnelsManager.tunnelNameIsLessThan($0.name, $1.name) }
|
||||
self.tunnelsListDelegate?.tunnelAdded(at: self.tunnels.firstIndex(of: tunnel)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func add(tunnelConfiguration: TunnelConfiguration, onDemandOption: ActivateOnDemandOption = .off, completionHandler: @escaping (Result<TunnelContainer, TunnelsManagerError>) -> Void) {
|
||||
let tunnelName = tunnelConfiguration.name ?? ""
|
||||
if tunnelName.isEmpty {
|
||||
completionHandler(.failure(TunnelsManagerError.tunnelNameEmpty))
|
||||
return
|
||||
}
|
||||
|
||||
if tunnels.contains(where: { $0.name == tunnelName }) {
|
||||
completionHandler(.failure(TunnelsManagerError.tunnelAlreadyExistsWithThatName))
|
||||
return
|
||||
}
|
||||
|
||||
let tunnelProviderManager = NETunnelProviderManager()
|
||||
tunnelProviderManager.setTunnelConfiguration(tunnelConfiguration)
|
||||
tunnelProviderManager.isEnabled = true
|
||||
|
||||
onDemandOption.apply(on: tunnelProviderManager)
|
||||
|
||||
let activeTunnel = tunnels.first { $0.status == .active || $0.status == .activating }
|
||||
|
||||
tunnelProviderManager.saveToPreferences { [weak self] error in
|
||||
if let error = error {
|
||||
wg_log(.error, message: "Add: Saving configuration failed: \(error)")
|
||||
(tunnelProviderManager.protocolConfiguration as? NETunnelProviderProtocol)?.destroyConfigurationReference()
|
||||
completionHandler(.failure(TunnelsManagerError.systemErrorOnAddTunnel(systemError: error)))
|
||||
return
|
||||
}
|
||||
|
||||
guard let self = self else { return }
|
||||
|
||||
#if os(iOS)
|
||||
// HACK: In iOS, adding a tunnel causes deactivation of any currently active tunnel.
|
||||
// This is an ugly hack to reactivate the tunnel that has been deactivated like that.
|
||||
if let activeTunnel = activeTunnel {
|
||||
if activeTunnel.status == .inactive || activeTunnel.status == .deactivating {
|
||||
self.startActivation(of: activeTunnel)
|
||||
}
|
||||
if activeTunnel.status == .active || activeTunnel.status == .activating {
|
||||
activeTunnel.status = .restarting
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
let tunnel = TunnelContainer(tunnel: tunnelProviderManager)
|
||||
self.tunnels.append(tunnel)
|
||||
self.tunnels.sort { TunnelsManager.tunnelNameIsLessThan($0.name, $1.name) }
|
||||
self.tunnelsListDelegate?.tunnelAdded(at: self.tunnels.firstIndex(of: tunnel)!)
|
||||
completionHandler(.success(tunnel))
|
||||
}
|
||||
}
|
||||
|
||||
func addMultiple(tunnelConfigurations: [TunnelConfiguration], completionHandler: @escaping (UInt, TunnelsManagerError?) -> Void) {
|
||||
// Temporarily pause observation of changes to VPN configurations to prevent the feedback
|
||||
// loop that causes `reload()` to be called on each newly added tunnel, which significantly
|
||||
// impacts performance.
|
||||
configurationsObservationToken = nil
|
||||
|
||||
self.addMultiple(tunnelConfigurations: ArraySlice(tunnelConfigurations), numberSuccessful: 0, lastError: nil) { [weak self] numSucceeded, error in
|
||||
completionHandler(numSucceeded, error)
|
||||
|
||||
// Restart observation of changes to VPN configrations.
|
||||
self?.startObservingTunnelConfigurations()
|
||||
|
||||
// Force reload all configurations to make sure that all tunnels are up to date.
|
||||
self?.reload()
|
||||
}
|
||||
}
|
||||
|
||||
private func addMultiple(tunnelConfigurations: ArraySlice<TunnelConfiguration>, numberSuccessful: UInt, lastError: TunnelsManagerError?, completionHandler: @escaping (UInt, TunnelsManagerError?) -> Void) {
|
||||
guard let head = tunnelConfigurations.first else {
|
||||
completionHandler(numberSuccessful, lastError)
|
||||
return
|
||||
}
|
||||
let tail = tunnelConfigurations.dropFirst()
|
||||
add(tunnelConfiguration: head) { [weak self, tail] result in
|
||||
DispatchQueue.main.async {
|
||||
var numberSuccessfulCount = numberSuccessful
|
||||
var lastError: TunnelsManagerError?
|
||||
switch result {
|
||||
case .failure(let error):
|
||||
lastError = error
|
||||
case .success:
|
||||
numberSuccessfulCount = numberSuccessful + 1
|
||||
}
|
||||
self?.addMultiple(tunnelConfigurations: tail, numberSuccessful: numberSuccessfulCount, lastError: lastError, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func modify(tunnel: TunnelContainer, tunnelConfiguration: TunnelConfiguration,
|
||||
onDemandOption: ActivateOnDemandOption,
|
||||
shouldEnsureOnDemandEnabled: Bool = false,
|
||||
completionHandler: @escaping (TunnelsManagerError?) -> Void) {
|
||||
let tunnelName = tunnelConfiguration.name ?? ""
|
||||
if tunnelName.isEmpty {
|
||||
completionHandler(TunnelsManagerError.tunnelNameEmpty)
|
||||
return
|
||||
}
|
||||
|
||||
let tunnelProviderManager = tunnel.tunnelProvider
|
||||
|
||||
let isIntroducingOnDemandRules = (tunnelProviderManager.onDemandRules ?? []).isEmpty && onDemandOption != .off
|
||||
if isIntroducingOnDemandRules && tunnel.status != .inactive && tunnel.status != .deactivating {
|
||||
tunnel.onDeactivated = { [weak self] in
|
||||
self?.modify(tunnel: tunnel, tunnelConfiguration: tunnelConfiguration,
|
||||
onDemandOption: onDemandOption, shouldEnsureOnDemandEnabled: true,
|
||||
completionHandler: completionHandler)
|
||||
}
|
||||
self.startDeactivation(of: tunnel)
|
||||
return
|
||||
} else {
|
||||
tunnel.onDeactivated = nil
|
||||
}
|
||||
|
||||
let oldName = tunnelProviderManager.localizedDescription ?? ""
|
||||
let isNameChanged = tunnelName != oldName
|
||||
if isNameChanged {
|
||||
guard !tunnels.contains(where: { $0.name == tunnelName }) else {
|
||||
completionHandler(TunnelsManagerError.tunnelAlreadyExistsWithThatName)
|
||||
return
|
||||
}
|
||||
tunnel.name = tunnelName
|
||||
}
|
||||
|
||||
var isTunnelConfigurationChanged = false
|
||||
if tunnelProviderManager.tunnelConfiguration != tunnelConfiguration {
|
||||
tunnelProviderManager.setTunnelConfiguration(tunnelConfiguration)
|
||||
isTunnelConfigurationChanged = true
|
||||
}
|
||||
tunnelProviderManager.isEnabled = true
|
||||
|
||||
let isActivatingOnDemand = !tunnelProviderManager.isOnDemandEnabled && shouldEnsureOnDemandEnabled
|
||||
onDemandOption.apply(on: tunnelProviderManager)
|
||||
if shouldEnsureOnDemandEnabled {
|
||||
tunnelProviderManager.isOnDemandEnabled = true
|
||||
}
|
||||
|
||||
tunnelProviderManager.saveToPreferences { [weak self] error in
|
||||
if let error = error {
|
||||
// TODO: the passwordReference for the old one has already been removed at this point and we can't easily roll back!
|
||||
wg_log(.error, message: "Modify: Saving configuration failed: \(error)")
|
||||
completionHandler(TunnelsManagerError.systemErrorOnModifyTunnel(systemError: error))
|
||||
return
|
||||
}
|
||||
guard let self = self else { return }
|
||||
if isNameChanged {
|
||||
let oldIndex = self.tunnels.firstIndex(of: tunnel)!
|
||||
self.tunnels.sort { TunnelsManager.tunnelNameIsLessThan($0.name, $1.name) }
|
||||
let newIndex = self.tunnels.firstIndex(of: tunnel)!
|
||||
self.tunnelsListDelegate?.tunnelMoved(from: oldIndex, to: newIndex)
|
||||
#if os(iOS)
|
||||
RecentTunnelsTracker.handleTunnelRenamed(oldName: oldName, newName: tunnelName)
|
||||
#endif
|
||||
}
|
||||
self.tunnelsListDelegate?.tunnelModified(at: self.tunnels.firstIndex(of: tunnel)!)
|
||||
|
||||
if isTunnelConfigurationChanged {
|
||||
if tunnel.status == .active || tunnel.status == .activating || tunnel.status == .reasserting {
|
||||
// Turn off the tunnel, and then turn it back on, so the changes are made effective
|
||||
tunnel.status = .restarting
|
||||
(tunnel.tunnelProvider.connection as? NETunnelProviderSession)?.stopTunnel()
|
||||
}
|
||||
}
|
||||
|
||||
if isActivatingOnDemand {
|
||||
// Reload tunnel after saving.
|
||||
// Without this, the tunnel stopes getting updates on the tunnel status from iOS.
|
||||
tunnelProviderManager.loadFromPreferences { error in
|
||||
tunnel.isActivateOnDemandEnabled = tunnelProviderManager.isOnDemandEnabled
|
||||
if let error = error {
|
||||
wg_log(.error, message: "Modify: Re-loading after saving configuration failed: \(error)")
|
||||
completionHandler(TunnelsManagerError.systemErrorOnModifyTunnel(systemError: error))
|
||||
} else {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func remove(tunnel: TunnelContainer, completionHandler: @escaping (TunnelsManagerError?) -> Void) {
|
||||
let tunnelProviderManager = tunnel.tunnelProvider
|
||||
#if os(macOS)
|
||||
if tunnel.isTunnelAvailableToUser {
|
||||
(tunnelProviderManager.protocolConfiguration as? NETunnelProviderProtocol)?.destroyConfigurationReference()
|
||||
}
|
||||
#elseif os(iOS)
|
||||
(tunnelProviderManager.protocolConfiguration as? NETunnelProviderProtocol)?.destroyConfigurationReference()
|
||||
#else
|
||||
#error("Unimplemented")
|
||||
#endif
|
||||
tunnelProviderManager.removeFromPreferences { [weak self] error in
|
||||
if let error = error {
|
||||
wg_log(.error, message: "Remove: Saving configuration failed: \(error)")
|
||||
completionHandler(TunnelsManagerError.systemErrorOnRemoveTunnel(systemError: error))
|
||||
return
|
||||
}
|
||||
if let self = self, let index = self.tunnels.firstIndex(of: tunnel) {
|
||||
self.tunnels.remove(at: index)
|
||||
self.tunnelsListDelegate?.tunnelRemoved(at: index, tunnel: tunnel)
|
||||
}
|
||||
completionHandler(nil)
|
||||
|
||||
#if os(iOS)
|
||||
RecentTunnelsTracker.handleTunnelRemoved(tunnelName: tunnel.name)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func removeMultiple(tunnels: [TunnelContainer], completionHandler: @escaping (TunnelsManagerError?) -> Void) {
|
||||
// Temporarily pause observation of changes to VPN configurations to prevent the feedback
|
||||
// loop that causes `reload()` to be called for each removed tunnel, which significantly
|
||||
// impacts performance.
|
||||
configurationsObservationToken = nil
|
||||
|
||||
removeMultiple(tunnels: ArraySlice(tunnels)) { [weak self] error in
|
||||
completionHandler(error)
|
||||
|
||||
// Restart observation of changes to VPN configrations.
|
||||
self?.startObservingTunnelConfigurations()
|
||||
|
||||
// Force reload all configurations to make sure that all tunnels are up to date.
|
||||
self?.reload()
|
||||
}
|
||||
}
|
||||
|
||||
private func removeMultiple(tunnels: ArraySlice<TunnelContainer>, completionHandler: @escaping (TunnelsManagerError?) -> Void) {
|
||||
guard let head = tunnels.first else {
|
||||
completionHandler(nil)
|
||||
return
|
||||
}
|
||||
let tail = tunnels.dropFirst()
|
||||
remove(tunnel: head) { [weak self, tail] error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
completionHandler(error)
|
||||
} else {
|
||||
self?.removeMultiple(tunnels: tail, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setOnDemandEnabled(_ isOnDemandEnabled: Bool, on tunnel: TunnelContainer, completionHandler: @escaping (TunnelsManagerError?) -> Void) {
|
||||
let tunnelProviderManager = tunnel.tunnelProvider
|
||||
let isCurrentlyEnabled = (tunnelProviderManager.isOnDemandEnabled && tunnelProviderManager.isEnabled)
|
||||
guard isCurrentlyEnabled != isOnDemandEnabled else {
|
||||
completionHandler(nil)
|
||||
return
|
||||
}
|
||||
let isActivatingOnDemand = !tunnelProviderManager.isOnDemandEnabled && isOnDemandEnabled
|
||||
tunnelProviderManager.isOnDemandEnabled = isOnDemandEnabled
|
||||
tunnelProviderManager.isEnabled = true
|
||||
tunnelProviderManager.saveToPreferences { error in
|
||||
if let error = error {
|
||||
wg_log(.error, message: "Modify On-Demand: Saving configuration failed: \(error)")
|
||||
completionHandler(TunnelsManagerError.systemErrorOnModifyTunnel(systemError: error))
|
||||
return
|
||||
}
|
||||
if isActivatingOnDemand {
|
||||
// If we're enabling on-demand, we want to make sure the tunnel is enabled.
|
||||
// If not enabled, the OS will not turn the tunnel on/off based on our rules.
|
||||
tunnelProviderManager.loadFromPreferences { error in
|
||||
// isActivateOnDemandEnabled will get changed in reload(), but no harm in setting it here too
|
||||
tunnel.isActivateOnDemandEnabled = tunnelProviderManager.isOnDemandEnabled
|
||||
if let error = error {
|
||||
wg_log(.error, message: "Modify On-Demand: Re-loading after saving configuration failed: \(error)")
|
||||
completionHandler(TunnelsManagerError.systemErrorOnModifyTunnel(systemError: error))
|
||||
return
|
||||
}
|
||||
completionHandler(nil)
|
||||
}
|
||||
} else {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func numberOfTunnels() -> Int {
|
||||
return tunnels.count
|
||||
}
|
||||
|
||||
func tunnel(at index: Int) -> TunnelContainer {
|
||||
return tunnels[index]
|
||||
}
|
||||
|
||||
func mapTunnels<T>(transform: (TunnelContainer) throws -> T) rethrows -> [T] {
|
||||
return try tunnels.map(transform)
|
||||
}
|
||||
|
||||
func index(of tunnel: TunnelContainer) -> Int? {
|
||||
return tunnels.firstIndex(of: tunnel)
|
||||
}
|
||||
|
||||
func tunnel(named tunnelName: String) -> TunnelContainer? {
|
||||
return tunnels.first { $0.name == tunnelName }
|
||||
}
|
||||
|
||||
func waitingTunnel() -> TunnelContainer? {
|
||||
return tunnels.first { $0.status == .waiting }
|
||||
}
|
||||
|
||||
func tunnelInOperation() -> TunnelContainer? {
|
||||
if let waitingTunnelObject = waitingTunnel() {
|
||||
return waitingTunnelObject
|
||||
}
|
||||
return tunnels.first { $0.status != .inactive }
|
||||
}
|
||||
|
||||
func startActivation(of tunnel: TunnelContainer) {
|
||||
guard tunnels.contains(tunnel) else { return } // Ensure it's not deleted
|
||||
guard tunnel.status == .inactive else {
|
||||
activationDelegate?.tunnelActivationAttemptFailed(tunnel: tunnel, error: .tunnelIsNotInactive)
|
||||
return
|
||||
}
|
||||
|
||||
if let alreadyWaitingTunnel = tunnels.first(where: { $0.status == .waiting }) {
|
||||
alreadyWaitingTunnel.status = .inactive
|
||||
}
|
||||
|
||||
if let tunnelInOperation = tunnels.first(where: { $0.status != .inactive }) {
|
||||
wg_log(.info, message: "Tunnel '\(tunnel.name)' waiting for deactivation of '\(tunnelInOperation.name)'")
|
||||
tunnel.status = .waiting
|
||||
activateWaitingTunnelOnDeactivation(of: tunnelInOperation)
|
||||
if tunnelInOperation.status != .deactivating {
|
||||
if tunnelInOperation.isActivateOnDemandEnabled {
|
||||
setOnDemandEnabled(false, on: tunnelInOperation) { [weak self] error in
|
||||
guard error == nil else {
|
||||
wg_log(.error, message: "Unable to activate tunnel '\(tunnel.name)' because on-demand could not be disabled on active tunnel '\(tunnel.name)'")
|
||||
return
|
||||
}
|
||||
self?.startDeactivation(of: tunnelInOperation)
|
||||
}
|
||||
} else {
|
||||
startDeactivation(of: tunnelInOperation)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
tunnel.status = .active
|
||||
#else
|
||||
tunnel.startActivation(activationDelegate: activationDelegate)
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
RecentTunnelsTracker.handleTunnelActivated(tunnelName: tunnel.name)
|
||||
#endif
|
||||
}
|
||||
|
||||
func startDeactivation(of tunnel: TunnelContainer) {
|
||||
tunnel.isAttemptingActivation = false
|
||||
guard tunnel.status != .inactive && tunnel.status != .deactivating else { return }
|
||||
#if targetEnvironment(simulator)
|
||||
tunnel.status = .inactive
|
||||
#else
|
||||
tunnel.startDeactivation()
|
||||
#endif
|
||||
}
|
||||
|
||||
func refreshStatuses() {
|
||||
tunnels.forEach { $0.refreshStatus() }
|
||||
}
|
||||
|
||||
private func activateWaitingTunnelOnDeactivation(of tunnel: TunnelContainer) {
|
||||
waiteeObservationToken = tunnel.observe(\.status) { [weak self] tunnel, _ in
|
||||
guard let self = self else { return }
|
||||
if tunnel.status == .inactive {
|
||||
if let waitingTunnel = self.tunnels.first(where: { $0.status == .waiting }) {
|
||||
waitingTunnel.startActivation(activationDelegate: self.activationDelegate)
|
||||
}
|
||||
self.waiteeObservationToken = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startObservingTunnelStatuses() {
|
||||
statusObservationToken = NotificationCenter.default.observe(name: .NEVPNStatusDidChange, object: nil, queue: OperationQueue.main) { [weak self] statusChangeNotification in
|
||||
guard let self = self,
|
||||
let session = statusChangeNotification.object as? NETunnelProviderSession,
|
||||
let tunnelProvider = session.manager as? NETunnelProviderManager,
|
||||
let tunnel = self.tunnels.first(where: { $0.tunnelProvider == tunnelProvider }) else { return }
|
||||
|
||||
wg_log(.debug, message: "Tunnel '\(tunnel.name)' connection status changed to '\(tunnel.tunnelProvider.connection.status)'")
|
||||
|
||||
if tunnel.isAttemptingActivation {
|
||||
if session.status == .connected {
|
||||
tunnel.isAttemptingActivation = false
|
||||
self.activationDelegate?.tunnelActivationSucceeded(tunnel: tunnel)
|
||||
} else if session.status == .disconnected {
|
||||
tunnel.isAttemptingActivation = false
|
||||
if let (title, message) = lastErrorTextFromNetworkExtension(for: tunnel) {
|
||||
self.activationDelegate?.tunnelActivationFailed(tunnel: tunnel, error: .activationFailedWithExtensionError(title: title, message: message, wasOnDemandEnabled: tunnelProvider.isOnDemandEnabled))
|
||||
} else {
|
||||
self.activationDelegate?.tunnelActivationFailed(tunnel: tunnel, error: .activationFailed(wasOnDemandEnabled: tunnelProvider.isOnDemandEnabled))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if session.status == .disconnected {
|
||||
tunnel.onDeactivated?()
|
||||
tunnel.onDeactivated = nil
|
||||
}
|
||||
|
||||
if tunnel.status == .restarting && session.status == .disconnected {
|
||||
tunnel.startActivation(activationDelegate: self.activationDelegate)
|
||||
return
|
||||
}
|
||||
|
||||
tunnel.refreshStatus()
|
||||
}
|
||||
}
|
||||
|
||||
func startObservingTunnelConfigurations() {
|
||||
configurationsObservationToken = NotificationCenter.default.observe(name: .NEVPNConfigurationChange, object: nil, queue: OperationQueue.main) { [weak self] _ in
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
// We schedule reload() in a subsequent runloop to ensure that the completion handler of loadAllFromPreferences
|
||||
// (reload() calls loadAllFromPreferences) is called after the completion handler of the saveToPreferences or
|
||||
// removeFromPreferences call, if any, that caused this notification to fire. This notification can also fire
|
||||
// as a result of a tunnel getting added or removed outside of the app.
|
||||
self?.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func tunnelNameIsLessThan(_ lhs: String, _ rhs: String) -> Bool {
|
||||
return lhs.compare(rhs, options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive, .numeric]) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private func lastErrorTextFromNetworkExtension(for tunnel: TunnelContainer) -> (title: String, message: String)? {
|
||||
guard let lastErrorFileURL = FileManager.networkExtensionLastErrorFileURL else { return nil }
|
||||
guard let lastErrorData = try? Data(contentsOf: lastErrorFileURL) else { return nil }
|
||||
guard let lastErrorStrings = String(data: lastErrorData, encoding: .utf8)?.splitToArray(separator: "\n") else { return nil }
|
||||
guard lastErrorStrings.count == 2 && tunnel.activationAttemptId == lastErrorStrings[0] else { return nil }
|
||||
|
||||
if let extensionError = PacketTunnelProviderError(rawValue: lastErrorStrings[1]) {
|
||||
return extensionError.alertText
|
||||
}
|
||||
|
||||
return (tr("alertTunnelActivationFailureTitle"), tr("alertTunnelActivationFailureMessage"))
|
||||
}
|
||||
|
||||
class TunnelContainer: NSObject {
|
||||
@objc dynamic var name: String
|
||||
@objc dynamic var status: TunnelStatus
|
||||
|
||||
@objc dynamic var isActivateOnDemandEnabled: Bool
|
||||
@objc dynamic var hasOnDemandRules: Bool
|
||||
|
||||
var isAttemptingActivation = false {
|
||||
didSet {
|
||||
if isAttemptingActivation {
|
||||
self.activationTimer?.invalidate()
|
||||
let activationTimer = Timer(timeInterval: 5 /* seconds */, repeats: true) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
wg_log(.debug, message: "Status update notification timeout for tunnel '\(self.name)'. Tunnel status is now '\(self.tunnelProvider.connection.status)'.")
|
||||
switch self.tunnelProvider.connection.status {
|
||||
case .connected, .disconnected, .invalid:
|
||||
self.activationTimer?.invalidate()
|
||||
self.activationTimer = nil
|
||||
default:
|
||||
break
|
||||
}
|
||||
self.refreshStatus()
|
||||
}
|
||||
self.activationTimer = activationTimer
|
||||
RunLoop.main.add(activationTimer, forMode: .common)
|
||||
}
|
||||
}
|
||||
}
|
||||
var activationAttemptId: String?
|
||||
var activationTimer: Timer?
|
||||
var deactivationTimer: Timer?
|
||||
var onDeactivated: (() -> Void)?
|
||||
|
||||
fileprivate var tunnelProvider: NETunnelProviderManager {
|
||||
didSet {
|
||||
isActivateOnDemandEnabled = tunnelProvider.isOnDemandEnabled && tunnelProvider.isEnabled
|
||||
hasOnDemandRules = !(tunnelProvider.onDemandRules ?? []).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
var tunnelConfiguration: TunnelConfiguration? {
|
||||
return tunnelProvider.tunnelConfiguration
|
||||
}
|
||||
|
||||
var onDemandOption: ActivateOnDemandOption {
|
||||
return ActivateOnDemandOption(from: tunnelProvider)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
var isTunnelAvailableToUser: Bool {
|
||||
return (tunnelProvider.protocolConfiguration as? NETunnelProviderProtocol)?.providerConfiguration?["UID"] as? uid_t == getuid()
|
||||
}
|
||||
#endif
|
||||
|
||||
init(tunnel: NETunnelProviderManager) {
|
||||
name = tunnel.localizedDescription ?? "Unnamed"
|
||||
let status = TunnelStatus(from: tunnel.connection.status)
|
||||
self.status = status
|
||||
isActivateOnDemandEnabled = tunnel.isOnDemandEnabled && tunnel.isEnabled
|
||||
hasOnDemandRules = !(tunnel.onDemandRules ?? []).isEmpty
|
||||
tunnelProvider = tunnel
|
||||
super.init()
|
||||
}
|
||||
|
||||
func getRuntimeTunnelConfiguration(completionHandler: @escaping ((TunnelConfiguration?) -> Void)) {
|
||||
guard status != .inactive, let session = tunnelProvider.connection as? NETunnelProviderSession else {
|
||||
completionHandler(tunnelConfiguration)
|
||||
return
|
||||
}
|
||||
guard nil != (try? session.sendProviderMessage(Data([ UInt8(0) ]), responseHandler: {
|
||||
guard self.status != .inactive, let data = $0, let base = self.tunnelConfiguration, let settings = String(data: data, encoding: .utf8) else {
|
||||
completionHandler(self.tunnelConfiguration)
|
||||
return
|
||||
}
|
||||
completionHandler((try? TunnelConfiguration(fromUapiConfig: settings, basedOn: base)) ?? self.tunnelConfiguration)
|
||||
})) else {
|
||||
completionHandler(tunnelConfiguration)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func refreshStatus() {
|
||||
if (status == .restarting) || (status == .waiting && tunnelProvider.connection.status == .disconnected) {
|
||||
return
|
||||
}
|
||||
status = TunnelStatus(from: tunnelProvider.connection.status)
|
||||
}
|
||||
|
||||
fileprivate func startActivation(recursionCount: UInt = 0, lastError: Error? = nil, activationDelegate: TunnelsManagerActivationDelegate?) {
|
||||
if recursionCount >= 8 {
|
||||
wg_log(.error, message: "startActivation: Failed after 8 attempts. Giving up with \(lastError!)")
|
||||
activationDelegate?.tunnelActivationAttemptFailed(tunnel: self, error: .failedBecauseOfTooManyErrors(lastSystemError: lastError!))
|
||||
return
|
||||
}
|
||||
|
||||
wg_log(.debug, message: "startActivation: Entering (tunnel: \(name))")
|
||||
|
||||
status = .activating // Ensure that no other tunnel can attempt activation until this tunnel is done trying
|
||||
|
||||
guard tunnelProvider.isEnabled else {
|
||||
// In case the tunnel had gotten disabled, re-enable and save it,
|
||||
// then call this function again.
|
||||
wg_log(.debug, staticMessage: "startActivation: Tunnel is disabled. Re-enabling and saving")
|
||||
tunnelProvider.isEnabled = true
|
||||
tunnelProvider.saveToPreferences { [weak self] error in
|
||||
guard let self = self else { return }
|
||||
if error != nil {
|
||||
wg_log(.error, message: "Error saving tunnel after re-enabling: \(error!)")
|
||||
activationDelegate?.tunnelActivationAttemptFailed(tunnel: self, error: .failedWhileSaving(systemError: error!))
|
||||
return
|
||||
}
|
||||
wg_log(.debug, staticMessage: "startActivation: Tunnel saved after re-enabling, invoking startActivation")
|
||||
self.startActivation(recursionCount: recursionCount + 1, lastError: NEVPNError(NEVPNError.configurationUnknown), activationDelegate: activationDelegate)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Start the tunnel
|
||||
do {
|
||||
wg_log(.debug, staticMessage: "startActivation: Starting tunnel")
|
||||
isAttemptingActivation = true
|
||||
let activationAttemptId = UUID().uuidString
|
||||
self.activationAttemptId = activationAttemptId
|
||||
try (tunnelProvider.connection as? NETunnelProviderSession)?.startTunnel(options: ["activationAttemptId": activationAttemptId])
|
||||
wg_log(.debug, staticMessage: "startActivation: Success")
|
||||
activationDelegate?.tunnelActivationAttemptSucceeded(tunnel: self)
|
||||
} catch let error {
|
||||
isAttemptingActivation = false
|
||||
guard let systemError = error as? NEVPNError else {
|
||||
wg_log(.error, message: "Failed to activate tunnel: Error: \(error)")
|
||||
status = .inactive
|
||||
activationDelegate?.tunnelActivationAttemptFailed(tunnel: self, error: .failedWhileStarting(systemError: error))
|
||||
return
|
||||
}
|
||||
guard systemError.code == NEVPNError.configurationInvalid || systemError.code == NEVPNError.configurationStale else {
|
||||
wg_log(.error, message: "Failed to activate tunnel: VPN Error: \(error)")
|
||||
status = .inactive
|
||||
activationDelegate?.tunnelActivationAttemptFailed(tunnel: self, error: .failedWhileStarting(systemError: systemError))
|
||||
return
|
||||
}
|
||||
wg_log(.debug, staticMessage: "startActivation: Will reload tunnel and then try to start it.")
|
||||
tunnelProvider.loadFromPreferences { [weak self] error in
|
||||
guard let self = self else { return }
|
||||
if error != nil {
|
||||
wg_log(.error, message: "startActivation: Error reloading tunnel: \(error!)")
|
||||
self.status = .inactive
|
||||
activationDelegate?.tunnelActivationAttemptFailed(tunnel: self, error: .failedWhileLoading(systemError: systemError))
|
||||
return
|
||||
}
|
||||
wg_log(.debug, staticMessage: "startActivation: Tunnel reloaded, invoking startActivation")
|
||||
self.startActivation(recursionCount: recursionCount + 1, lastError: systemError, activationDelegate: activationDelegate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func startDeactivation() {
|
||||
wg_log(.debug, message: "startDeactivation: Tunnel: \(name)")
|
||||
(tunnelProvider.connection as? NETunnelProviderSession)?.stopTunnel()
|
||||
}
|
||||
}
|
||||
|
||||
extension NETunnelProviderManager {
|
||||
private static var cachedConfigKey: UInt8 = 0
|
||||
|
||||
var tunnelConfiguration: TunnelConfiguration? {
|
||||
if let cached = objc_getAssociatedObject(self, &NETunnelProviderManager.cachedConfigKey) as? TunnelConfiguration {
|
||||
return cached
|
||||
}
|
||||
let config = (protocolConfiguration as? NETunnelProviderProtocol)?.asTunnelConfiguration(called: localizedDescription)
|
||||
if config != nil {
|
||||
objc_setAssociatedObject(self, &NETunnelProviderManager.cachedConfigKey, config, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func setTunnelConfiguration(_ tunnelConfiguration: TunnelConfiguration) {
|
||||
protocolConfiguration = NETunnelProviderProtocol(tunnelConfiguration: tunnelConfiguration, previouslyFrom: protocolConfiguration)
|
||||
localizedDescription = tunnelConfiguration.name
|
||||
objc_setAssociatedObject(self, &NETunnelProviderManager.cachedConfigKey, tunnelConfiguration, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
}
|
||||
|
||||
func isEquivalentTo(_ tunnel: TunnelContainer) -> Bool {
|
||||
return localizedDescription == tunnel.name && tunnelConfiguration == tunnel.tunnelConfiguration
|
||||
}
|
||||
}
|
196
Sources/WireGuardApp/UI/ActivateOnDemandViewModel.swift
Normal file
@ -0,0 +1,196 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
class ActivateOnDemandViewModel {
|
||||
enum OnDemandField {
|
||||
case onDemand
|
||||
case nonWiFiInterface
|
||||
case wiFiInterface
|
||||
case ssid
|
||||
|
||||
var localizedUIString: String {
|
||||
switch self {
|
||||
case .onDemand:
|
||||
return tr("tunnelOnDemandKey")
|
||||
case .nonWiFiInterface:
|
||||
#if os(iOS)
|
||||
return tr("tunnelOnDemandCellular")
|
||||
#elseif os(macOS)
|
||||
return tr("tunnelOnDemandEthernet")
|
||||
#else
|
||||
#error("Unimplemented")
|
||||
#endif
|
||||
case .wiFiInterface: return tr("tunnelOnDemandWiFi")
|
||||
case .ssid: return tr("tunnelOnDemandSSIDsKey")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum OnDemandSSIDOption {
|
||||
case anySSID
|
||||
case onlySpecificSSIDs
|
||||
case exceptSpecificSSIDs
|
||||
|
||||
var localizedUIString: String {
|
||||
switch self {
|
||||
case .anySSID: return tr("tunnelOnDemandAnySSID")
|
||||
case .onlySpecificSSIDs: return tr("tunnelOnDemandOnlyTheseSSIDs")
|
||||
case .exceptSpecificSSIDs: return tr("tunnelOnDemandExceptTheseSSIDs")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isNonWiFiInterfaceEnabled = false
|
||||
var isWiFiInterfaceEnabled = false
|
||||
var selectedSSIDs = [String]()
|
||||
var ssidOption: OnDemandSSIDOption = .anySSID
|
||||
}
|
||||
|
||||
extension ActivateOnDemandViewModel {
|
||||
convenience init(tunnel: TunnelContainer) {
|
||||
self.init()
|
||||
switch tunnel.onDemandOption {
|
||||
case .off:
|
||||
break
|
||||
case .wiFiInterfaceOnly(let onDemandSSIDOption):
|
||||
isWiFiInterfaceEnabled = true
|
||||
(ssidOption, selectedSSIDs) = ssidViewModel(from: onDemandSSIDOption)
|
||||
case .nonWiFiInterfaceOnly:
|
||||
isNonWiFiInterfaceEnabled = true
|
||||
case .anyInterface(let onDemandSSIDOption):
|
||||
isWiFiInterfaceEnabled = true
|
||||
isNonWiFiInterfaceEnabled = true
|
||||
(ssidOption, selectedSSIDs) = ssidViewModel(from: onDemandSSIDOption)
|
||||
}
|
||||
}
|
||||
|
||||
func toOnDemandOption() -> ActivateOnDemandOption {
|
||||
switch (isWiFiInterfaceEnabled, isNonWiFiInterfaceEnabled) {
|
||||
case (false, false):
|
||||
return .off
|
||||
case (false, true):
|
||||
return .nonWiFiInterfaceOnly
|
||||
case (true, false):
|
||||
return .wiFiInterfaceOnly(toSSIDOption())
|
||||
case (true, true):
|
||||
return .anyInterface(toSSIDOption())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ActivateOnDemandViewModel {
|
||||
func isEnabled(field: OnDemandField) -> Bool {
|
||||
switch field {
|
||||
case .nonWiFiInterface:
|
||||
return isNonWiFiInterfaceEnabled
|
||||
case .wiFiInterface:
|
||||
return isWiFiInterfaceEnabled
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func setEnabled(field: OnDemandField, isEnabled: Bool) {
|
||||
switch field {
|
||||
case .nonWiFiInterface:
|
||||
isNonWiFiInterfaceEnabled = isEnabled
|
||||
case .wiFiInterface:
|
||||
isWiFiInterfaceEnabled = isEnabled
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ActivateOnDemandViewModel {
|
||||
var localizedInterfaceDescription: String {
|
||||
switch (isWiFiInterfaceEnabled, isNonWiFiInterfaceEnabled) {
|
||||
case (false, false):
|
||||
return tr("tunnelOnDemandOptionOff")
|
||||
case (true, false):
|
||||
return tr("tunnelOnDemandOptionWiFiOnly")
|
||||
case (false, true):
|
||||
#if os(iOS)
|
||||
return tr("tunnelOnDemandOptionCellularOnly")
|
||||
#elseif os(macOS)
|
||||
return tr("tunnelOnDemandOptionEthernetOnly")
|
||||
#else
|
||||
#error("Unimplemented")
|
||||
#endif
|
||||
case (true, true):
|
||||
#if os(iOS)
|
||||
return tr("tunnelOnDemandOptionWiFiOrCellular")
|
||||
#elseif os(macOS)
|
||||
return tr("tunnelOnDemandOptionWiFiOrEthernet")
|
||||
#else
|
||||
#error("Unimplemented")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
var localizedSSIDDescription: String {
|
||||
guard isWiFiInterfaceEnabled else { return "" }
|
||||
switch ssidOption {
|
||||
case .anySSID: return tr("tunnelOnDemandAnySSID")
|
||||
case .onlySpecificSSIDs:
|
||||
if selectedSSIDs.count == 1 {
|
||||
return tr(format: "tunnelOnDemandOnlySSID (%d)", selectedSSIDs.count)
|
||||
} else {
|
||||
return tr(format: "tunnelOnDemandOnlySSIDs (%d)", selectedSSIDs.count)
|
||||
}
|
||||
case .exceptSpecificSSIDs:
|
||||
if selectedSSIDs.count == 1 {
|
||||
return tr(format: "tunnelOnDemandExceptSSID (%d)", selectedSSIDs.count)
|
||||
} else {
|
||||
return tr(format: "tunnelOnDemandExceptSSIDs (%d)", selectedSSIDs.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fixSSIDOption() {
|
||||
selectedSSIDs = uniquifiedNonEmptySelectedSSIDs()
|
||||
if selectedSSIDs.isEmpty {
|
||||
ssidOption = .anySSID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ActivateOnDemandViewModel {
|
||||
func ssidViewModel(from ssidOption: ActivateOnDemandSSIDOption) -> (OnDemandSSIDOption, [String]) {
|
||||
switch ssidOption {
|
||||
case .anySSID:
|
||||
return (.anySSID, [])
|
||||
case .onlySpecificSSIDs(let ssids):
|
||||
return (.onlySpecificSSIDs, ssids)
|
||||
case .exceptSpecificSSIDs(let ssids):
|
||||
return (.exceptSpecificSSIDs, ssids)
|
||||
}
|
||||
}
|
||||
|
||||
func toSSIDOption() -> ActivateOnDemandSSIDOption {
|
||||
switch ssidOption {
|
||||
case .anySSID:
|
||||
return .anySSID
|
||||
case .onlySpecificSSIDs:
|
||||
let ssids = uniquifiedNonEmptySelectedSSIDs()
|
||||
return ssids.isEmpty ? .anySSID : .onlySpecificSSIDs(selectedSSIDs)
|
||||
case .exceptSpecificSSIDs:
|
||||
let ssids = uniquifiedNonEmptySelectedSSIDs()
|
||||
return ssids.isEmpty ? .anySSID : .exceptSpecificSSIDs(selectedSSIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func uniquifiedNonEmptySelectedSSIDs() -> [String] {
|
||||
let nonEmptySSIDs = selectedSSIDs.filter { !$0.isEmpty }
|
||||
var seenSSIDs = Set<String>()
|
||||
var uniquified = [String]()
|
||||
for ssid in nonEmptySSIDs {
|
||||
guard !seenSSIDs.contains(ssid) else { continue }
|
||||
uniquified.append(ssid)
|
||||
seenSSIDs.insert(ssid)
|
||||
}
|
||||
return uniquified
|
||||
}
|
||||
}
|
25
Sources/WireGuardApp/UI/ErrorPresenterProtocol.swift
Normal file
@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
protocol ErrorPresenterProtocol {
|
||||
static func showErrorAlert(title: String, message: String, from sourceVC: AnyObject?, onPresented: (() -> Void)?, onDismissal: (() -> Void)?)
|
||||
}
|
||||
|
||||
extension ErrorPresenterProtocol {
|
||||
static func showErrorAlert(title: String, message: String, from sourceVC: AnyObject?, onPresented: (() -> Void)?) {
|
||||
showErrorAlert(title: title, message: message, from: sourceVC, onPresented: onPresented, onDismissal: nil)
|
||||
}
|
||||
|
||||
static func showErrorAlert(title: String, message: String, from sourceVC: AnyObject?, onDismissal: (() -> Void)?) {
|
||||
showErrorAlert(title: title, message: message, from: sourceVC, onPresented: nil, onDismissal: onDismissal)
|
||||
}
|
||||
|
||||
static func showErrorAlert(title: String, message: String, from sourceVC: AnyObject?) {
|
||||
showErrorAlert(title: title, message: message, from: sourceVC, onPresented: nil, onDismissal: nil)
|
||||
}
|
||||
|
||||
static func showErrorAlert(error: WireGuardAppError, from sourceVC: AnyObject?, onPresented: (() -> Void)? = nil, onDismissal: (() -> Void)? = nil) {
|
||||
let (title, message) = error.alertText
|
||||
showErrorAlert(title: title, message: message, from: sourceVC, onPresented: onPresented, onDismissal: onDismissal)
|
||||
}
|
||||
}
|
57
Sources/WireGuardApp/UI/LogViewHelper.swift
Normal file
@ -0,0 +1,57 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
public class LogViewHelper {
|
||||
var log: OpaquePointer
|
||||
var cursor: UInt32 = UINT32_MAX
|
||||
static let formatOptions: ISO8601DateFormatter.Options = [
|
||||
.withYear, .withMonth, .withDay, .withTime,
|
||||
.withDashSeparatorInDate, .withColonSeparatorInTime, .withSpaceBetweenDateAndTime,
|
||||
.withFractionalSeconds
|
||||
]
|
||||
|
||||
struct LogEntry {
|
||||
let timestamp: String
|
||||
let message: String
|
||||
|
||||
func text() -> String {
|
||||
return timestamp + " " + message
|
||||
}
|
||||
}
|
||||
|
||||
class LogEntries {
|
||||
var entries: [LogEntry] = []
|
||||
}
|
||||
|
||||
init?(logFilePath: String?) {
|
||||
guard let logFilePath = logFilePath else { return nil }
|
||||
guard let log = open_log(logFilePath) else { return nil }
|
||||
self.log = log
|
||||
}
|
||||
|
||||
deinit {
|
||||
close_log(self.log)
|
||||
}
|
||||
|
||||
func fetchLogEntriesSinceLastFetch(completion: @escaping ([LogViewHelper.LogEntry]) -> Void) {
|
||||
var logEntries = LogEntries()
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let newCursor = view_lines_from_cursor(self.log, self.cursor, &logEntries) { cStr, timestamp, ctx in
|
||||
let message = cStr != nil ? String(cString: cStr!) : ""
|
||||
let date = Date(timeIntervalSince1970: Double(timestamp) / 1000000000)
|
||||
let dateString = ISO8601DateFormatter.string(from: date, timeZone: TimeZone.current, formatOptions: LogViewHelper.formatOptions)
|
||||
if let logEntries = ctx?.bindMemory(to: LogEntries.self, capacity: 1) {
|
||||
logEntries.pointee.entries.append(LogEntry(timestamp: dateString, message: message))
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.cursor = newCursor
|
||||
completion(logEntries.entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
37
Sources/WireGuardApp/UI/PrivateDataConfirmation.swift
Normal file
@ -0,0 +1,37 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import LocalAuthentication
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
class PrivateDataConfirmation {
|
||||
static func confirmAccess(to reason: String, _ after: @escaping () -> Void) {
|
||||
let context = LAContext()
|
||||
|
||||
var error: NSError?
|
||||
if !context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) {
|
||||
guard let error = error as? LAError else { return }
|
||||
if error.code == .passcodeNotSet {
|
||||
// We give no protection to folks who just don't set a passcode.
|
||||
after()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in
|
||||
DispatchQueue.main.async {
|
||||
#if os(macOS)
|
||||
if !NSApp.isActive {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
#endif
|
||||
if success {
|
||||
after()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
94
Sources/WireGuardApp/UI/TunnelImporter.swift
Normal file
@ -0,0 +1,94 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
class TunnelImporter {
|
||||
static func importFromFile(urls: [URL], into tunnelsManager: TunnelsManager, sourceVC: AnyObject?, errorPresenterType: ErrorPresenterProtocol.Type, completionHandler: (() -> Void)? = nil) {
|
||||
guard !urls.isEmpty else {
|
||||
completionHandler?()
|
||||
return
|
||||
}
|
||||
let dispatchGroup = DispatchGroup()
|
||||
var configs = [TunnelConfiguration?]()
|
||||
var lastFileImportErrorText: (title: String, message: String)?
|
||||
for url in urls {
|
||||
if url.pathExtension.lowercased() == "zip" {
|
||||
dispatchGroup.enter()
|
||||
ZipImporter.importConfigFiles(from: url) { result in
|
||||
switch result {
|
||||
case .failure(let error):
|
||||
lastFileImportErrorText = error.alertText
|
||||
case .success(let configsInZip):
|
||||
configs.append(contentsOf: configsInZip)
|
||||
}
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
} else { /* if it is not a zip, we assume it is a conf */
|
||||
let fileName = url.lastPathComponent
|
||||
let fileBaseName = url.deletingPathExtension().lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
dispatchGroup.enter()
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let fileContents: String
|
||||
do {
|
||||
fileContents = try String(contentsOf: url)
|
||||
} catch let error {
|
||||
DispatchQueue.main.async {
|
||||
if let cocoaError = error as? CocoaError, cocoaError.isFileError {
|
||||
lastFileImportErrorText = (title: tr("alertCantOpenInputConfFileTitle"), message: error.localizedDescription)
|
||||
} else {
|
||||
lastFileImportErrorText = (title: tr("alertCantOpenInputConfFileTitle"), message: tr(format: "alertCantOpenInputConfFileMessage (%@)", fileName))
|
||||
}
|
||||
configs.append(nil)
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
return
|
||||
}
|
||||
var parseError: Error?
|
||||
var tunnelConfiguration: TunnelConfiguration?
|
||||
do {
|
||||
tunnelConfiguration = try TunnelConfiguration(fromWgQuickConfig: fileContents, called: fileBaseName)
|
||||
} catch let error {
|
||||
parseError = error
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
if parseError != nil {
|
||||
if let parseError = parseError as? WireGuardAppError {
|
||||
lastFileImportErrorText = parseError.alertText
|
||||
} else {
|
||||
lastFileImportErrorText = (title: tr("alertBadConfigImportTitle"), message: tr(format: "alertBadConfigImportMessage (%@)", fileName))
|
||||
}
|
||||
}
|
||||
configs.append(tunnelConfiguration)
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dispatchGroup.notify(queue: .main) {
|
||||
tunnelsManager.addMultiple(tunnelConfigurations: configs.compactMap { $0 }) { numberSuccessful, lastAddError in
|
||||
if !configs.isEmpty && numberSuccessful == configs.count {
|
||||
completionHandler?()
|
||||
return
|
||||
}
|
||||
let alertText: (title: String, message: String)?
|
||||
if urls.count == 1 {
|
||||
if urls.first!.pathExtension.lowercased() == "zip" && !configs.isEmpty {
|
||||
alertText = (title: tr(format: "alertImportedFromZipTitle (%d)", numberSuccessful),
|
||||
message: tr(format: "alertImportedFromZipMessage (%1$d of %2$d)", numberSuccessful, configs.count))
|
||||
} else {
|
||||
alertText = lastFileImportErrorText ?? lastAddError?.alertText
|
||||
}
|
||||
} else {
|
||||
alertText = (title: tr(format: "alertImportedFromMultipleFilesTitle (%d)", numberSuccessful),
|
||||
message: tr(format: "alertImportedFromMultipleFilesMessage (%1$d of %2$d)", numberSuccessful, configs.count))
|
||||
}
|
||||
if let alertText = alertText {
|
||||
errorPresenterType.showErrorAlert(title: alertText.title, message: alertText.message, from: sourceVC, onPresented: completionHandler)
|
||||
} else {
|
||||
completionHandler?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,12 +1,11 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
import Foundation
|
||||
|
||||
//swiftlint:disable:next type_body_length
|
||||
class TunnelViewModel {
|
||||
|
||||
enum InterfaceField {
|
||||
enum InterfaceField: CaseIterable {
|
||||
case name
|
||||
case privateKey
|
||||
case publicKey
|
||||
@ -15,6 +14,8 @@ class TunnelViewModel {
|
||||
case listenPort
|
||||
case mtu
|
||||
case dns
|
||||
case status
|
||||
case toggleStatus
|
||||
|
||||
var localizedUIString: String {
|
||||
switch self {
|
||||
@ -26,6 +27,8 @@ class TunnelViewModel {
|
||||
case .listenPort: return tr("tunnelInterfaceListenPort")
|
||||
case .mtu: return tr("tunnelInterfaceMTU")
|
||||
case .dns: return tr("tunnelInterfaceDNS")
|
||||
case .status: return tr("tunnelInterfaceStatus")
|
||||
case .toggleStatus: return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -34,12 +37,15 @@ class TunnelViewModel {
|
||||
.generateKeyPair
|
||||
]
|
||||
|
||||
enum PeerField {
|
||||
enum PeerField: CaseIterable {
|
||||
case publicKey
|
||||
case preSharedKey
|
||||
case endpoint
|
||||
case persistentKeepAlive
|
||||
case allowedIPs
|
||||
case rxBytes
|
||||
case txBytes
|
||||
case lastHandshakeTime
|
||||
case excludePrivateIPs
|
||||
case deletePeer
|
||||
|
||||
@ -50,6 +56,9 @@ class TunnelViewModel {
|
||||
case .endpoint: return tr("tunnelPeerEndpoint")
|
||||
case .persistentKeepAlive: return tr("tunnelPeerPersistentKeepalive")
|
||||
case .allowedIPs: return tr("tunnelPeerAllowedIPs")
|
||||
case .rxBytes: return tr("tunnelPeerRxBytes")
|
||||
case .txBytes: return tr("tunnelPeerTxBytes")
|
||||
case .lastHandshakeTime: return tr("tunnelPeerLastHandshakeTime")
|
||||
case .excludePrivateIPs: return tr("tunnelPeerExcludePrivateIPs")
|
||||
case .deletePeer: return tr("deletePeerButtonTitle")
|
||||
}
|
||||
@ -62,6 +71,19 @@ class TunnelViewModel {
|
||||
|
||||
static let keyLengthInBase64 = 44
|
||||
|
||||
struct Changes {
|
||||
enum FieldChange: Equatable {
|
||||
case added
|
||||
case removed
|
||||
case modified(newValue: String)
|
||||
}
|
||||
|
||||
var interfaceChanges: [InterfaceField: FieldChange]
|
||||
var peerChanges: [(peerIndex: Int, changes: [PeerField: FieldChange])]
|
||||
var peersRemovedIndices: [Int]
|
||||
var peersInsertedIndices: [Int]
|
||||
}
|
||||
|
||||
class InterfaceData {
|
||||
var scratchpad = [InterfaceField: String]()
|
||||
var fieldsWithError = Set<InterfaceField>()
|
||||
@ -87,9 +109,9 @@ class TunnelViewModel {
|
||||
scratchpad[field] = stringValue
|
||||
}
|
||||
if field == .privateKey {
|
||||
if stringValue.count == TunnelViewModel.keyLengthInBase64, let privateKey = Data(base64Encoded: stringValue), privateKey.count == TunnelConfiguration.keyLength {
|
||||
let publicKey = Curve25519.generatePublicKey(fromPrivateKey: privateKey)
|
||||
scratchpad[.publicKey] = publicKey.base64EncodedString()
|
||||
if stringValue.count == TunnelViewModel.keyLengthInBase64,
|
||||
let privateKey = PrivateKey(base64Key: stringValue) {
|
||||
scratchpad[.publicKey] = privateKey.publicKey.base64Key
|
||||
} else {
|
||||
scratchpad.removeValue(forKey: .publicKey)
|
||||
}
|
||||
@ -100,9 +122,14 @@ class TunnelViewModel {
|
||||
func populateScratchpad() {
|
||||
guard let config = validatedConfiguration else { return }
|
||||
guard let name = validatedName else { return }
|
||||
scratchpad = TunnelViewModel.InterfaceData.createScratchPad(from: config, name: name)
|
||||
}
|
||||
|
||||
private static func createScratchPad(from config: InterfaceConfiguration, name: String) -> [InterfaceField: String] {
|
||||
var scratchpad = [InterfaceField: String]()
|
||||
scratchpad[.name] = name
|
||||
scratchpad[.privateKey] = config.privateKey.base64EncodedString()
|
||||
scratchpad[.publicKey] = config.publicKey.base64EncodedString()
|
||||
scratchpad[.privateKey] = config.privateKey.base64Key
|
||||
scratchpad[.publicKey] = config.privateKey.publicKey.base64Key
|
||||
if !config.addresses.isEmpty {
|
||||
scratchpad[.addresses] = config.addresses.map { $0.stringRepresentation }.joined(separator: ", ")
|
||||
}
|
||||
@ -112,12 +139,14 @@ class TunnelViewModel {
|
||||
if let mtu = config.mtu {
|
||||
scratchpad[.mtu] = String(mtu)
|
||||
}
|
||||
if !config.dns.isEmpty {
|
||||
scratchpad[.dns] = config.dns.map { $0.stringRepresentation }.joined(separator: ", ")
|
||||
if !config.dns.isEmpty || !config.dnsSearch.isEmpty {
|
||||
var dns = config.dns.map { $0.stringRepresentation }
|
||||
dns.append(contentsOf: config.dnsSearch)
|
||||
scratchpad[.dns] = dns.joined(separator: ", ")
|
||||
}
|
||||
return scratchpad
|
||||
}
|
||||
|
||||
//swiftlint:disable:next cyclomatic_complexity function_body_length
|
||||
func save() -> SaveResult<(String, InterfaceConfiguration)> {
|
||||
if let config = validatedConfiguration, let name = validatedName {
|
||||
return .saved((name, config))
|
||||
@ -131,7 +160,7 @@ class TunnelViewModel {
|
||||
fieldsWithError.insert(.privateKey)
|
||||
return .error(tr("alertInvalidInterfaceMessagePrivateKeyRequired"))
|
||||
}
|
||||
guard let privateKey = Data(base64Encoded: privateKeyString), privateKey.count == TunnelConfiguration.keyLength else {
|
||||
guard let privateKey = PrivateKey(base64Key: privateKeyString) else {
|
||||
fieldsWithError.insert(.privateKey)
|
||||
return .error(tr("alertInvalidInterfaceMessagePrivateKeyInvalid"))
|
||||
}
|
||||
@ -167,15 +196,16 @@ class TunnelViewModel {
|
||||
}
|
||||
if let dnsString = scratchpad[.dns] {
|
||||
var dnsServers = [DNSServer]()
|
||||
var dnsSearch = [String]()
|
||||
for dnsServerString in dnsString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) {
|
||||
if let dnsServer = DNSServer(from: dnsServerString) {
|
||||
dnsServers.append(dnsServer)
|
||||
} else {
|
||||
fieldsWithError.insert(.dns)
|
||||
errorMessages.append(tr("alertInvalidInterfaceMessageDNSInvalid"))
|
||||
dnsSearch.append(dnsServerString)
|
||||
}
|
||||
}
|
||||
config.dns = dnsServers
|
||||
config.dnsSearch = dnsSearch
|
||||
}
|
||||
|
||||
guard errorMessages.isEmpty else { return .error(errorMessages.first!) }
|
||||
@ -193,6 +223,30 @@ class TunnelViewModel {
|
||||
return !self[field].isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
func applyConfiguration(other: InterfaceConfiguration, otherName: String) -> [InterfaceField: Changes.FieldChange] {
|
||||
if scratchpad.isEmpty {
|
||||
populateScratchpad()
|
||||
}
|
||||
let otherScratchPad = InterfaceData.createScratchPad(from: other, name: otherName)
|
||||
var changes = [InterfaceField: Changes.FieldChange]()
|
||||
for field in InterfaceField.allCases {
|
||||
switch (scratchpad[field] ?? "", otherScratchPad[field] ?? "") {
|
||||
case ("", ""):
|
||||
break
|
||||
case ("", _):
|
||||
changes[field] = .added
|
||||
case (_, ""):
|
||||
changes[field] = .removed
|
||||
case (let this, let other):
|
||||
if this != other {
|
||||
changes[field] = .modified(newValue: other)
|
||||
}
|
||||
}
|
||||
}
|
||||
scratchpad = otherScratchPad
|
||||
return changes
|
||||
}
|
||||
}
|
||||
|
||||
class PeerData {
|
||||
@ -200,6 +254,15 @@ class TunnelViewModel {
|
||||
var scratchpad = [PeerField: String]()
|
||||
var fieldsWithError = Set<PeerField>()
|
||||
var validatedConfiguration: PeerConfiguration?
|
||||
var publicKey: PublicKey? {
|
||||
if let validatedConfiguration = validatedConfiguration {
|
||||
return validatedConfiguration.publicKey
|
||||
}
|
||||
if let scratchPadPublicKey = scratchpad[.publicKey] {
|
||||
return PublicKey(base64Key: scratchPadPublicKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private(set) var shouldAllowExcludePrivateIPsControl = false
|
||||
private(set) var shouldStronglyRecommendDNS = false
|
||||
@ -235,9 +298,15 @@ class TunnelViewModel {
|
||||
|
||||
func populateScratchpad() {
|
||||
guard let config = validatedConfiguration else { return }
|
||||
scratchpad[.publicKey] = config.publicKey.base64EncodedString()
|
||||
if let preSharedKey = config.preSharedKey {
|
||||
scratchpad[.preSharedKey] = preSharedKey.base64EncodedString()
|
||||
scratchpad = TunnelViewModel.PeerData.createScratchPad(from: config)
|
||||
updateExcludePrivateIPsFieldState()
|
||||
}
|
||||
|
||||
private static func createScratchPad(from config: PeerConfiguration) -> [PeerField: String] {
|
||||
var scratchpad = [PeerField: String]()
|
||||
scratchpad[.publicKey] = config.publicKey.base64Key
|
||||
if let preSharedKey = config.preSharedKey?.base64Key {
|
||||
scratchpad[.preSharedKey] = preSharedKey
|
||||
}
|
||||
if !config.allowedIPs.isEmpty {
|
||||
scratchpad[.allowedIPs] = config.allowedIPs.map { $0.stringRepresentation }.joined(separator: ", ")
|
||||
@ -248,10 +317,18 @@ class TunnelViewModel {
|
||||
if let persistentKeepAlive = config.persistentKeepAlive {
|
||||
scratchpad[.persistentKeepAlive] = String(persistentKeepAlive)
|
||||
}
|
||||
updateExcludePrivateIPsFieldState()
|
||||
if let rxBytes = config.rxBytes {
|
||||
scratchpad[.rxBytes] = prettyBytes(rxBytes)
|
||||
}
|
||||
if let txBytes = config.txBytes {
|
||||
scratchpad[.txBytes] = prettyBytes(txBytes)
|
||||
}
|
||||
if let lastHandshakeTime = config.lastHandshakeTime {
|
||||
scratchpad[.lastHandshakeTime] = prettyTimeAgo(timestamp: lastHandshakeTime)
|
||||
}
|
||||
return scratchpad
|
||||
}
|
||||
|
||||
//swiftlint:disable:next cyclomatic_complexity
|
||||
func save() -> SaveResult<PeerConfiguration> {
|
||||
if let validatedConfiguration = validatedConfiguration {
|
||||
return .saved(validatedConfiguration)
|
||||
@ -261,14 +338,14 @@ class TunnelViewModel {
|
||||
fieldsWithError.insert(.publicKey)
|
||||
return .error(tr("alertInvalidPeerMessagePublicKeyRequired"))
|
||||
}
|
||||
guard let publicKey = Data(base64Encoded: publicKeyString), publicKey.count == TunnelConfiguration.keyLength else {
|
||||
guard let publicKey = PublicKey(base64Key: publicKeyString) else {
|
||||
fieldsWithError.insert(.publicKey)
|
||||
return .error(tr("alertInvalidPeerMessagePublicKeyInvalid"))
|
||||
}
|
||||
var config = PeerConfiguration(publicKey: publicKey)
|
||||
var errorMessages = [String]()
|
||||
if let preSharedKeyString = scratchpad[.preSharedKey] {
|
||||
if let preSharedKey = Data(base64Encoded: preSharedKeyString), preSharedKey.count == TunnelConfiguration.keyLength {
|
||||
if let preSharedKey = PreSharedKey(base64Key: preSharedKeyString) {
|
||||
config.preSharedKey = preSharedKey
|
||||
} else {
|
||||
fieldsWithError.insert(.preSharedKey)
|
||||
@ -321,51 +398,86 @@ class TunnelViewModel {
|
||||
|
||||
static let ipv4DefaultRouteString = "0.0.0.0/0"
|
||||
static let ipv4DefaultRouteModRFC1918String = [ // Set of all non-private IPv4 IPs
|
||||
"0.0.0.0/5", "8.0.0.0/7", "11.0.0.0/8", "12.0.0.0/6", "16.0.0.0/4", "32.0.0.0/3",
|
||||
"64.0.0.0/2", "128.0.0.0/3", "160.0.0.0/5", "168.0.0.0/6", "172.0.0.0/12",
|
||||
"172.32.0.0/11", "172.64.0.0/10", "172.128.0.0/9", "173.0.0.0/8", "174.0.0.0/7",
|
||||
"176.0.0.0/4", "192.0.0.0/9", "192.128.0.0/11", "192.160.0.0/13", "192.169.0.0/16",
|
||||
"192.170.0.0/15", "192.172.0.0/14", "192.176.0.0/12", "192.192.0.0/10",
|
||||
"193.0.0.0/8", "194.0.0.0/7", "196.0.0.0/6", "200.0.0.0/5", "208.0.0.0/4"
|
||||
"1.0.0.0/8", "2.0.0.0/8", "3.0.0.0/8", "4.0.0.0/6", "8.0.0.0/7", "11.0.0.0/8",
|
||||
"12.0.0.0/6", "16.0.0.0/4", "32.0.0.0/3", "64.0.0.0/2", "128.0.0.0/3",
|
||||
"160.0.0.0/5", "168.0.0.0/6", "172.0.0.0/12", "172.32.0.0/11", "172.64.0.0/10",
|
||||
"172.128.0.0/9", "173.0.0.0/8", "174.0.0.0/7", "176.0.0.0/4", "192.0.0.0/9",
|
||||
"192.128.0.0/11", "192.160.0.0/13", "192.169.0.0/16", "192.170.0.0/15",
|
||||
"192.172.0.0/14", "192.176.0.0/12", "192.192.0.0/10", "193.0.0.0/8",
|
||||
"194.0.0.0/7", "196.0.0.0/6", "200.0.0.0/5", "208.0.0.0/4"
|
||||
]
|
||||
|
||||
static func excludePrivateIPsFieldStates(isSinglePeer: Bool, allowedIPs: Set<String>) -> (shouldAllowExcludePrivateIPsControl: Bool, excludePrivateIPsValue: Bool) {
|
||||
guard isSinglePeer else {
|
||||
return (shouldAllowExcludePrivateIPsControl: false, excludePrivateIPsValue: false)
|
||||
}
|
||||
let allowedIPStrings = Set<String>(allowedIPs)
|
||||
if allowedIPStrings.contains(TunnelViewModel.PeerData.ipv4DefaultRouteString) {
|
||||
return (shouldAllowExcludePrivateIPsControl: true, excludePrivateIPsValue: false)
|
||||
} else if allowedIPStrings.isSuperset(of: TunnelViewModel.PeerData.ipv4DefaultRouteModRFC1918String) {
|
||||
return (shouldAllowExcludePrivateIPsControl: true, excludePrivateIPsValue: true)
|
||||
} else {
|
||||
return (shouldAllowExcludePrivateIPsControl: false, excludePrivateIPsValue: false)
|
||||
}
|
||||
}
|
||||
|
||||
func updateExcludePrivateIPsFieldState() {
|
||||
if scratchpad.isEmpty {
|
||||
populateScratchpad()
|
||||
}
|
||||
let allowedIPStrings = Set<String>(scratchpad[.allowedIPs].splitToArray(trimmingCharacters: .whitespacesAndNewlines))
|
||||
(shouldAllowExcludePrivateIPsControl, excludePrivateIPsValue) = TunnelViewModel.PeerData.excludePrivateIPsFieldStates(isSinglePeer: numberOfPeers == 1, allowedIPs: allowedIPStrings)
|
||||
shouldStronglyRecommendDNS = allowedIPStrings.contains(TunnelViewModel.PeerData.ipv4DefaultRouteString) || allowedIPStrings.isSuperset(of: TunnelViewModel.PeerData.ipv4DefaultRouteModRFC1918String)
|
||||
guard numberOfPeers == 1 else {
|
||||
shouldAllowExcludePrivateIPsControl = false
|
||||
excludePrivateIPsValue = false
|
||||
return
|
||||
}
|
||||
if allowedIPStrings.contains(TunnelViewModel.PeerData.ipv4DefaultRouteString) {
|
||||
shouldAllowExcludePrivateIPsControl = true
|
||||
excludePrivateIPsValue = false
|
||||
} else if allowedIPStrings.isSuperset(of: TunnelViewModel.PeerData.ipv4DefaultRouteModRFC1918String) {
|
||||
shouldAllowExcludePrivateIPsControl = true
|
||||
excludePrivateIPsValue = true
|
||||
}
|
||||
|
||||
static func normalizedIPAddressRangeStrings(_ list: [String]) -> [String] {
|
||||
return list.compactMap { IPAddressRange(from: $0) }.map { $0.stringRepresentation }
|
||||
}
|
||||
|
||||
static func modifiedAllowedIPs(currentAllowedIPs: [String], excludePrivateIPs: Bool, dnsServers: [String], oldDNSServers: [String]?) -> [String] {
|
||||
let normalizedDNSServers = normalizedIPAddressRangeStrings(dnsServers)
|
||||
let normalizedOldDNSServers = oldDNSServers == nil ? normalizedDNSServers : normalizedIPAddressRangeStrings(oldDNSServers!)
|
||||
let ipv6Addresses = normalizedIPAddressRangeStrings(currentAllowedIPs.filter { $0.contains(":") })
|
||||
if excludePrivateIPs {
|
||||
return ipv6Addresses + TunnelViewModel.PeerData.ipv4DefaultRouteModRFC1918String + normalizedDNSServers
|
||||
} else {
|
||||
shouldAllowExcludePrivateIPsControl = false
|
||||
excludePrivateIPsValue = false
|
||||
return ipv6Addresses.filter { !normalizedOldDNSServers.contains($0) } + [TunnelViewModel.PeerData.ipv4DefaultRouteString]
|
||||
}
|
||||
}
|
||||
|
||||
func excludePrivateIPsValueChanged(isOn: Bool, dnsServers: String) {
|
||||
func excludePrivateIPsValueChanged(isOn: Bool, dnsServers: String, oldDNSServers: String? = nil) {
|
||||
let allowedIPStrings = scratchpad[.allowedIPs].splitToArray(trimmingCharacters: .whitespacesAndNewlines)
|
||||
let dnsServerStrings = dnsServers.splitToArray(trimmingCharacters: .whitespacesAndNewlines)
|
||||
let ipv6Addresses = allowedIPStrings.filter { $0.contains(":") }
|
||||
let modifiedAllowedIPStrings: [String]
|
||||
if isOn {
|
||||
modifiedAllowedIPStrings = ipv6Addresses + TunnelViewModel.PeerData.ipv4DefaultRouteModRFC1918String + dnsServerStrings
|
||||
} else {
|
||||
modifiedAllowedIPStrings = ipv6Addresses + [TunnelViewModel.PeerData.ipv4DefaultRouteString]
|
||||
}
|
||||
let oldDNSServerStrings = oldDNSServers?.splitToArray(trimmingCharacters: .whitespacesAndNewlines)
|
||||
let modifiedAllowedIPStrings = TunnelViewModel.PeerData.modifiedAllowedIPs(currentAllowedIPs: allowedIPStrings, excludePrivateIPs: isOn, dnsServers: dnsServerStrings, oldDNSServers: oldDNSServerStrings)
|
||||
scratchpad[.allowedIPs] = modifiedAllowedIPStrings.joined(separator: ", ")
|
||||
validatedConfiguration = nil
|
||||
excludePrivateIPsValue = isOn
|
||||
}
|
||||
|
||||
func applyConfiguration(other: PeerConfiguration) -> [PeerField: Changes.FieldChange] {
|
||||
if scratchpad.isEmpty {
|
||||
populateScratchpad()
|
||||
}
|
||||
let otherScratchPad = PeerData.createScratchPad(from: other)
|
||||
var changes = [PeerField: Changes.FieldChange]()
|
||||
for field in PeerField.allCases {
|
||||
switch (scratchpad[field] ?? "", otherScratchPad[field] ?? "") {
|
||||
case ("", ""):
|
||||
break
|
||||
case ("", _):
|
||||
changes[field] = .added
|
||||
case (_, ""):
|
||||
changes[field] = .removed
|
||||
case (let this, let other):
|
||||
if this != other {
|
||||
changes[field] = .modified(newValue: other)
|
||||
}
|
||||
}
|
||||
}
|
||||
scratchpad = otherScratchPad
|
||||
return changes
|
||||
}
|
||||
}
|
||||
|
||||
enum SaveResult<Configuration> {
|
||||
@ -373,8 +485,8 @@ class TunnelViewModel {
|
||||
case error(String)
|
||||
}
|
||||
|
||||
var interfaceData: InterfaceData
|
||||
var peersData: [PeerData]
|
||||
private(set) var interfaceData: InterfaceData
|
||||
private(set) var peersData: [PeerData]
|
||||
|
||||
init(tunnelConfiguration: TunnelConfiguration?) {
|
||||
let interfaceData = InterfaceData()
|
||||
@ -419,6 +531,17 @@ class TunnelViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func updateDNSServersInAllowedIPsIfRequired(oldDNSServers: String, newDNSServers: String) -> Bool {
|
||||
guard peersData.count == 1, let firstPeer = peersData.first else { return false }
|
||||
guard firstPeer.shouldAllowExcludePrivateIPsControl && firstPeer.excludePrivateIPsValue else { return false }
|
||||
let allowedIPStrings = firstPeer[.allowedIPs].splitToArray(trimmingCharacters: .whitespacesAndNewlines)
|
||||
let oldDNSServerStrings = TunnelViewModel.PeerData.normalizedIPAddressRangeStrings(oldDNSServers.splitToArray(trimmingCharacters: .whitespacesAndNewlines))
|
||||
let newDNSServerStrings = TunnelViewModel.PeerData.normalizedIPAddressRangeStrings(newDNSServers.splitToArray(trimmingCharacters: .whitespacesAndNewlines))
|
||||
let updatedAllowedIPStrings = allowedIPStrings.filter { !oldDNSServerStrings.contains($0) } + newDNSServerStrings
|
||||
firstPeer[.allowedIPs] = updatedAllowedIPStrings.joined(separator: ", ")
|
||||
return true
|
||||
}
|
||||
|
||||
func save() -> SaveResult<TunnelConfiguration> {
|
||||
let interfaceSaveResult = interfaceData.save()
|
||||
let peerSaveResults = peersData.map { $0.save() } // Save all, to help mark erroring fields in red
|
||||
@ -438,7 +561,7 @@ class TunnelViewModel {
|
||||
}
|
||||
|
||||
let peerPublicKeysArray = peerConfigurations.map { $0.publicKey }
|
||||
let peerPublicKeysSet = Set<Data>(peerPublicKeysArray)
|
||||
let peerPublicKeysSet = Set<PublicKey>(peerPublicKeysArray)
|
||||
if peerPublicKeysArray.count != peerPublicKeysSet.count {
|
||||
return .error(tr("alertInvalidPeerMessagePublicKeyDuplicated"))
|
||||
}
|
||||
@ -447,35 +570,130 @@ class TunnelViewModel {
|
||||
return .saved(tunnelConfiguration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TunnelViewModel {
|
||||
static func activateOnDemandOptionText(for activateOnDemandOption: ActivateOnDemandOption) -> String {
|
||||
switch activateOnDemandOption {
|
||||
case .none:
|
||||
return tr("tunnelOnDemandOptionOff")
|
||||
case .useOnDemandOverWiFiOrCellular:
|
||||
return tr("tunnelOnDemandOptionWiFiOrCellular")
|
||||
case .useOnDemandOverWiFiOnly:
|
||||
return tr("tunnelOnDemandOptionWiFiOnly")
|
||||
case .useOnDemandOverCellularOnly:
|
||||
return tr("tunnelOnDemandOptionCellularOnly")
|
||||
func asWgQuickConfig() -> String? {
|
||||
let saveResult = save()
|
||||
if case .saved(let tunnelConfiguration) = saveResult {
|
||||
return tunnelConfiguration.asWgQuickConfig()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func activateOnDemandDetailText(for activateOnDemandSetting: ActivateOnDemandSetting?) -> String {
|
||||
if let activateOnDemandSetting = activateOnDemandSetting {
|
||||
if activateOnDemandSetting.isActivateOnDemandEnabled {
|
||||
return TunnelViewModel.activateOnDemandOptionText(for: activateOnDemandSetting.activateOnDemandOption)
|
||||
} else {
|
||||
return TunnelViewModel.activateOnDemandOptionText(for: .none)
|
||||
@discardableResult
|
||||
func applyConfiguration(other: TunnelConfiguration) -> Changes {
|
||||
// Replaces current data with data from other TunnelConfiguration, ignoring any changes in peer ordering.
|
||||
|
||||
let interfaceChanges = interfaceData.applyConfiguration(other: other.interface, otherName: other.name ?? "")
|
||||
|
||||
var peerChanges = [(peerIndex: Int, changes: [PeerField: Changes.FieldChange])]()
|
||||
for otherPeer in other.peers {
|
||||
if let peersDataIndex = peersData.firstIndex(where: { $0.publicKey == otherPeer.publicKey }) {
|
||||
let peerData = peersData[peersDataIndex]
|
||||
let changes = peerData.applyConfiguration(other: otherPeer)
|
||||
if !changes.isEmpty {
|
||||
peerChanges.append((peerIndex: peersDataIndex, changes: changes))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return TunnelViewModel.activateOnDemandOptionText(for: .none)
|
||||
}
|
||||
}
|
||||
|
||||
static func defaultActivateOnDemandOption() -> ActivateOnDemandOption {
|
||||
return .useOnDemandOverWiFiOrCellular
|
||||
var removedPeerIndices = [Int]()
|
||||
for (index, peerData) in peersData.enumerated().reversed() {
|
||||
if let peerPublicKey = peerData.publicKey, !other.peers.contains(where: { $0.publicKey == peerPublicKey}) {
|
||||
removedPeerIndices.append(index)
|
||||
peersData.remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
var addedPeerIndices = [Int]()
|
||||
for otherPeer in other.peers {
|
||||
if !peersData.contains(where: { $0.publicKey == otherPeer.publicKey }) {
|
||||
addedPeerIndices.append(peersData.count)
|
||||
let peerData = PeerData(index: peersData.count)
|
||||
peerData.validatedConfiguration = otherPeer
|
||||
peersData.append(peerData)
|
||||
}
|
||||
}
|
||||
|
||||
for (index, peer) in peersData.enumerated() {
|
||||
peer.index = index
|
||||
peer.numberOfPeers = peersData.count
|
||||
peer.updateExcludePrivateIPsFieldState()
|
||||
}
|
||||
|
||||
return Changes(interfaceChanges: interfaceChanges, peerChanges: peerChanges, peersRemovedIndices: removedPeerIndices, peersInsertedIndices: addedPeerIndices)
|
||||
}
|
||||
}
|
||||
|
||||
private func prettyBytes(_ bytes: UInt64) -> String {
|
||||
switch bytes {
|
||||
case 0..<1024:
|
||||
return "\(bytes) B"
|
||||
case 1024 ..< (1024 * 1024):
|
||||
return String(format: "%.2f", Double(bytes) / 1024) + " KiB"
|
||||
case 1024 ..< (1024 * 1024 * 1024):
|
||||
return String(format: "%.2f", Double(bytes) / (1024 * 1024)) + " MiB"
|
||||
case 1024 ..< (1024 * 1024 * 1024 * 1024):
|
||||
return String(format: "%.2f", Double(bytes) / (1024 * 1024 * 1024)) + " GiB"
|
||||
default:
|
||||
return String(format: "%.2f", Double(bytes) / (1024 * 1024 * 1024 * 1024)) + " TiB"
|
||||
}
|
||||
}
|
||||
|
||||
private func prettyTimeAgo(timestamp: Date) -> String {
|
||||
let now = Date()
|
||||
let timeInterval = Int64(now.timeIntervalSince(timestamp))
|
||||
switch timeInterval {
|
||||
case ..<0: return tr("tunnelHandshakeTimestampSystemClockBackward")
|
||||
case 0: return tr("tunnelHandshakeTimestampNow")
|
||||
default:
|
||||
return tr(format: "tunnelHandshakeTimestampAgo (%@)", prettyTime(secondsLeft: timeInterval))
|
||||
}
|
||||
}
|
||||
|
||||
private func prettyTime(secondsLeft: Int64) -> String {
|
||||
var left = secondsLeft
|
||||
var timeStrings = [String]()
|
||||
let years = left / (365 * 24 * 60 * 60)
|
||||
left = left % (365 * 24 * 60 * 60)
|
||||
let days = left / (24 * 60 * 60)
|
||||
left = left % (24 * 60 * 60)
|
||||
let hours = left / (60 * 60)
|
||||
left = left % (60 * 60)
|
||||
let minutes = left / 60
|
||||
let seconds = left % 60
|
||||
|
||||
#if os(iOS)
|
||||
if years > 0 {
|
||||
return years == 1 ? tr(format: "tunnelHandshakeTimestampYear (%d)", years) : tr(format: "tunnelHandshakeTimestampYears (%d)", years)
|
||||
}
|
||||
if days > 0 {
|
||||
return days == 1 ? tr(format: "tunnelHandshakeTimestampDay (%d)", days) : tr(format: "tunnelHandshakeTimestampDays (%d)", days)
|
||||
}
|
||||
if hours > 0 {
|
||||
let hhmmss = String(format: "%02d:%02d:%02d", hours, minutes, seconds)
|
||||
return tr(format: "tunnelHandshakeTimestampHours hh:mm:ss (%@)", hhmmss)
|
||||
}
|
||||
if minutes > 0 {
|
||||
let mmss = String(format: "%02d:%02d", minutes, seconds)
|
||||
return tr(format: "tunnelHandshakeTimestampMinutes mm:ss (%@)", mmss)
|
||||
}
|
||||
return seconds == 1 ? tr(format: "tunnelHandshakeTimestampSecond (%d)", seconds) : tr(format: "tunnelHandshakeTimestampSeconds (%d)", seconds)
|
||||
#elseif os(macOS)
|
||||
if years > 0 {
|
||||
timeStrings.append(years == 1 ? tr(format: "tunnelHandshakeTimestampYear (%d)", years) : tr(format: "tunnelHandshakeTimestampYears (%d)", years))
|
||||
}
|
||||
if days > 0 {
|
||||
timeStrings.append(days == 1 ? tr(format: "tunnelHandshakeTimestampDay (%d)", days) : tr(format: "tunnelHandshakeTimestampDays (%d)", days))
|
||||
}
|
||||
if hours > 0 {
|
||||
timeStrings.append(hours == 1 ? tr(format: "tunnelHandshakeTimestampHour (%d)", hours) : tr(format: "tunnelHandshakeTimestampHours (%d)", hours))
|
||||
}
|
||||
if minutes > 0 {
|
||||
timeStrings.append(minutes == 1 ? tr(format: "tunnelHandshakeTimestampMinute (%d)", minutes) : tr(format: "tunnelHandshakeTimestampMinutes (%d)", minutes))
|
||||
}
|
||||
if seconds > 0 {
|
||||
timeStrings.append(seconds == 1 ? tr(format: "tunnelHandshakeTimestampSecond (%d)", seconds) : tr(format: "tunnelHandshakeTimestampSeconds (%d)", seconds))
|
||||
}
|
||||
return timeStrings.joined(separator: ", ")
|
||||
#endif
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
import os.log
|
||||
@ -9,12 +9,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
var mainVC: MainViewController?
|
||||
var isLaunchedForSpecificAction = false
|
||||
|
||||
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
Logger.configureGlobal(withFilePath: FileManager.appLogFileURL?.path)
|
||||
Logger.configureGlobal(tagged: "APP", withFilePath: FileManager.logFileURL?.path)
|
||||
|
||||
if let launchOptions = launchOptions {
|
||||
if launchOptions[.url] != nil || launchOptions[.shortcutItem] != nil {
|
||||
isLaunchedForSpecificAction = true
|
||||
}
|
||||
}
|
||||
|
||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||
window.backgroundColor = .white
|
||||
self.window = window
|
||||
|
||||
let mainVC = MainViewController()
|
||||
@ -27,15 +33,28 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
mainVC?.tunnelsListVC?.importFromFile(url: url) {
|
||||
_ = FileManager.deleteFile(at: url)
|
||||
}
|
||||
mainVC?.importFromDisposableFile(url: url)
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
mainVC?.refreshTunnelConnectionStatuses()
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {
|
||||
guard let allTunnelNames = mainVC?.allTunnelNames() else { return }
|
||||
application.shortcutItems = QuickActionItem.createItems(allTunnelNames: allTunnelNames)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
|
||||
guard shortcutItem.type == QuickActionItem.type else {
|
||||
completionHandler(false)
|
||||
return
|
||||
}
|
||||
let tunnelName = shortcutItem.localizedTitle
|
||||
mainVC?.showTunnelDetailForTunnel(named: tunnelName, animated: false, shouldToggleStatus: true)
|
||||
completionHandler(true)
|
||||
}
|
||||
}
|
||||
|
||||
extension AppDelegate {
|
||||
@ -44,7 +63,7 @@ extension AppDelegate {
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool {
|
||||
return true
|
||||
return !self.isLaunchedForSpecificAction
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, viewControllerWithRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? {
|
||||
@ -57,7 +76,7 @@ extension AppDelegate {
|
||||
}
|
||||
} else {
|
||||
// Show it when tunnelsManager is available
|
||||
mainVC?.showTunnelDetailForTunnel(named: tunnelName, animated: false)
|
||||
mainVC?.showTunnelDetailForTunnel(named: tunnelName, animated: false, shouldToggleStatus: false)
|
||||
}
|
||||
}
|
||||
return nil
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
Before Width: | Height: | Size: 865 B After Width: | Height: | Size: 865 B |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.8 KiB |
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB |
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
25
Sources/WireGuardApp/UI/iOS/ConfirmationAlertPresenter.swift
Normal file
@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
class ConfirmationAlertPresenter {
|
||||
static func showConfirmationAlert(message: String, buttonTitle: String, from sourceObject: AnyObject, presentingVC: UIViewController, onConfirmed: @escaping (() -> Void)) {
|
||||
let destroyAction = UIAlertAction(title: buttonTitle, style: .destructive) { _ in
|
||||
onConfirmed()
|
||||
}
|
||||
let cancelAction = UIAlertAction(title: tr("actionCancel"), style: .cancel)
|
||||
let alert = UIAlertController(title: "", message: message, preferredStyle: .actionSheet)
|
||||
alert.addAction(destroyAction)
|
||||
alert.addAction(cancelAction)
|
||||
|
||||
if let sourceView = sourceObject as? UIView {
|
||||
alert.popoverPresentationController?.sourceView = sourceView
|
||||
alert.popoverPresentationController?.sourceRect = sourceView.bounds
|
||||
} else if let sourceBarButtonItem = sourceObject as? UIBarButtonItem {
|
||||
alert.popoverPresentationController?.barButtonItem = sourceBarButtonItem
|
||||
}
|
||||
|
||||
presentingVC.present(alert, animated: true, completion: nil)
|
||||
}
|
||||
}
|
19
Sources/WireGuardApp/UI/iOS/ErrorPresenter.swift
Normal file
@ -0,0 +1,19 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
import os.log
|
||||
|
||||
class ErrorPresenter: ErrorPresenterProtocol {
|
||||
static func showErrorAlert(title: String, message: String, from sourceVC: AnyObject?, onPresented: (() -> Void)?, onDismissal: (() -> Void)?) {
|
||||
guard let sourceVC = sourceVC as? UIViewController else { return }
|
||||
|
||||
let okAction = UIAlertAction(title: "OK", style: .default) { _ in
|
||||
onDismissal?()
|
||||
}
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(okAction)
|
||||
|
||||
sourceVC.present(alert, animated: true, completion: onPresented)
|
||||
}
|
||||
}
|
@ -64,6 +64,8 @@
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
@ -77,12 +79,11 @@
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<false/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Camera is used for scanning QR codes for importing WireGuard configurations</string>
|
||||
<string>Localized</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
@ -122,7 +123,9 @@
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Localized</string>
|
||||
<key>com.wireguard.ios.app_group_id</key>
|
||||
<string>group.$(APP_ID)</string>
|
||||
<string>group.$(APP_ID_IOS)</string>
|
||||
</dict>
|
||||
</plist>
|
25
Sources/WireGuardApp/UI/iOS/QuickActionItem.swift
Normal file
@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
class QuickActionItem: UIApplicationShortcutItem {
|
||||
static let type = "WireGuardTunnelActivateAndShow"
|
||||
|
||||
init(tunnelName: String) {
|
||||
super.init(type: QuickActionItem.type, localizedTitle: tunnelName, localizedSubtitle: nil, icon: nil, userInfo: nil)
|
||||
}
|
||||
|
||||
static func createItems(allTunnelNames: [String]) -> [QuickActionItem] {
|
||||
let numberOfItems = 10
|
||||
// Currently, only 4 items shown by iOS, but that can increase in the future.
|
||||
// iOS will discard additional items we give it.
|
||||
var tunnelNames = RecentTunnelsTracker.recentlyActivatedTunnelNames(limit: numberOfItems)
|
||||
let numberOfSlotsRemaining = numberOfItems - tunnelNames.count
|
||||
if numberOfSlotsRemaining > 0 {
|
||||
let moreTunnels = allTunnelNames.filter { !tunnelNames.contains($0) }.prefix(numberOfSlotsRemaining)
|
||||
tunnelNames.append(contentsOf: moreTunnels)
|
||||
}
|
||||
return tunnelNames.map { QuickActionItem(tunnelName: $0) }
|
||||
}
|
||||
}
|
72
Sources/WireGuardApp/UI/iOS/RecentTunnelsTracker.swift
Normal file
@ -0,0 +1,72 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
class RecentTunnelsTracker {
|
||||
|
||||
private static let keyRecentlyActivatedTunnelNames = "recentlyActivatedTunnelNames"
|
||||
private static let maxNumberOfTunnels = 10
|
||||
|
||||
private static var userDefaults: UserDefaults? {
|
||||
guard let appGroupId = FileManager.appGroupId else {
|
||||
wg_log(.error, staticMessage: "Cannot obtain app group ID from bundle for tracking recently used tunnels")
|
||||
return nil
|
||||
}
|
||||
guard let userDefaults = UserDefaults(suiteName: appGroupId) else {
|
||||
wg_log(.error, staticMessage: "Cannot obtain shared user defaults for tracking recently used tunnels")
|
||||
return nil
|
||||
}
|
||||
return userDefaults
|
||||
}
|
||||
|
||||
static func handleTunnelActivated(tunnelName: String) {
|
||||
guard let userDefaults = RecentTunnelsTracker.userDefaults else { return }
|
||||
var recentTunnels = userDefaults.stringArray(forKey: keyRecentlyActivatedTunnelNames) ?? []
|
||||
if let existingIndex = recentTunnels.firstIndex(of: tunnelName) {
|
||||
recentTunnels.remove(at: existingIndex)
|
||||
}
|
||||
recentTunnels.insert(tunnelName, at: 0)
|
||||
if recentTunnels.count > maxNumberOfTunnels {
|
||||
recentTunnels.removeLast(recentTunnels.count - maxNumberOfTunnels)
|
||||
}
|
||||
userDefaults.set(recentTunnels, forKey: keyRecentlyActivatedTunnelNames)
|
||||
}
|
||||
|
||||
static func handleTunnelRemoved(tunnelName: String) {
|
||||
guard let userDefaults = RecentTunnelsTracker.userDefaults else { return }
|
||||
var recentTunnels = userDefaults.stringArray(forKey: keyRecentlyActivatedTunnelNames) ?? []
|
||||
if let existingIndex = recentTunnels.firstIndex(of: tunnelName) {
|
||||
recentTunnels.remove(at: existingIndex)
|
||||
userDefaults.set(recentTunnels, forKey: keyRecentlyActivatedTunnelNames)
|
||||
}
|
||||
}
|
||||
|
||||
static func handleTunnelRenamed(oldName: String, newName: String) {
|
||||
guard let userDefaults = RecentTunnelsTracker.userDefaults else { return }
|
||||
var recentTunnels = userDefaults.stringArray(forKey: keyRecentlyActivatedTunnelNames) ?? []
|
||||
if let existingIndex = recentTunnels.firstIndex(of: oldName) {
|
||||
recentTunnels[existingIndex] = newName
|
||||
userDefaults.set(recentTunnels, forKey: keyRecentlyActivatedTunnelNames)
|
||||
}
|
||||
}
|
||||
|
||||
static func cleanupTunnels(except tunnelNamesToKeep: Set<String>) {
|
||||
guard let userDefaults = RecentTunnelsTracker.userDefaults else { return }
|
||||
var recentTunnels = userDefaults.stringArray(forKey: keyRecentlyActivatedTunnelNames) ?? []
|
||||
let oldCount = recentTunnels.count
|
||||
recentTunnels.removeAll { !tunnelNamesToKeep.contains($0) }
|
||||
if oldCount != recentTunnels.count {
|
||||
userDefaults.set(recentTunnels, forKey: keyRecentlyActivatedTunnelNames)
|
||||
}
|
||||
}
|
||||
|
||||
static func recentlyActivatedTunnelNames(limit: Int) -> [String] {
|
||||
guard let userDefaults = RecentTunnelsTracker.userDefaults else { return [] }
|
||||
var recentTunnels = userDefaults.stringArray(forKey: keyRecentlyActivatedTunnelNames) ?? []
|
||||
if limit < recentTunnels.count {
|
||||
recentTunnels.removeLast(recentTunnels.count - limit)
|
||||
}
|
||||
return recentTunnels
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
@ -15,7 +15,7 @@ extension UITableView {
|
||||
}
|
||||
|
||||
func dequeueReusableCell<T: UITableViewCell>(for indexPath: IndexPath) -> T {
|
||||
//swiftlint:disable:next force_cast
|
||||
// swiftlint:disable:next force_cast
|
||||
return dequeueReusableCell(withIdentifier: T.reuseIdentifier, for: indexPath) as! T
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
@ -9,8 +9,8 @@ class ButtonCell: UITableViewCell {
|
||||
set(value) { button.setTitle(value, for: .normal) }
|
||||
}
|
||||
var hasDestructiveAction: Bool {
|
||||
get { return button.tintColor == .red }
|
||||
set(value) { button.tintColor = value ? .red : buttonStandardTintColor }
|
||||
get { return button.tintColor == .systemRed }
|
||||
set(value) { button.tintColor = value ? .systemRed : buttonStandardTintColor }
|
||||
}
|
||||
var onTapped: (() -> Void)?
|
||||
|
@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
30
Sources/WireGuardApp/UI/iOS/View/ChevronCell.swift
Normal file
@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
class ChevronCell: UITableViewCell {
|
||||
var message: String {
|
||||
get { return textLabel?.text ?? "" }
|
||||
set(value) { textLabel?.text = value }
|
||||
}
|
||||
|
||||
var detailMessage: String {
|
||||
get { return detailTextLabel?.text ?? "" }
|
||||
set(value) { detailTextLabel?.text = value }
|
||||
}
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: .value1, reuseIdentifier: reuseIdentifier)
|
||||
accessoryType = .disclosureIndicator
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
message = ""
|
||||
}
|
||||
}
|
71
Sources/WireGuardApp/UI/iOS/View/EditableTextCell.swift
Normal file
@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
class EditableTextCell: UITableViewCell {
|
||||
var message: String {
|
||||
get { return valueTextField.text ?? "" }
|
||||
set(value) { valueTextField.text = value }
|
||||
}
|
||||
|
||||
var placeholder: String? {
|
||||
get { return valueTextField.placeholder }
|
||||
set(value) { valueTextField.placeholder = value }
|
||||
}
|
||||
|
||||
let valueTextField: UITextField = {
|
||||
let valueTextField = UITextField()
|
||||
valueTextField.textAlignment = .left
|
||||
valueTextField.isEnabled = true
|
||||
valueTextField.font = UIFont.preferredFont(forTextStyle: .body)
|
||||
valueTextField.adjustsFontForContentSizeCategory = true
|
||||
valueTextField.autocapitalizationType = .none
|
||||
valueTextField.autocorrectionType = .no
|
||||
valueTextField.spellCheckingType = .no
|
||||
return valueTextField
|
||||
}()
|
||||
|
||||
var onValueBeingEdited: ((String) -> Void)?
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
valueTextField.delegate = self
|
||||
contentView.addSubview(valueTextField)
|
||||
valueTextField.translatesAutoresizingMaskIntoConstraints = false
|
||||
// Reduce the bottom margin by 0.5pt to maintain the default cell height (44pt)
|
||||
let bottomAnchorConstraint = contentView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: valueTextField.bottomAnchor, constant: -0.5)
|
||||
bottomAnchorConstraint.priority = .defaultLow
|
||||
NSLayoutConstraint.activate([
|
||||
valueTextField.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
|
||||
contentView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: valueTextField.trailingAnchor),
|
||||
contentView.layoutMarginsGuide.topAnchor.constraint(equalTo: valueTextField.topAnchor),
|
||||
bottomAnchorConstraint
|
||||
])
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func beginEditing() {
|
||||
valueTextField.becomeFirstResponder()
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
message = ""
|
||||
placeholder = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension EditableTextCell: UITextFieldDelegate {
|
||||
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
||||
if let onValueBeingEdited = onValueBeingEdited {
|
||||
let modifiedText = ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string)
|
||||
onValueBeingEdited(modifiedText)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
@ -9,7 +9,7 @@ class KeyValueCell: UITableViewCell {
|
||||
let keyLabel = UILabel()
|
||||
keyLabel.font = UIFont.preferredFont(forTextStyle: .body)
|
||||
keyLabel.adjustsFontForContentSizeCategory = true
|
||||
keyLabel.textColor = .black
|
||||
keyLabel.textColor = .label
|
||||
keyLabel.textAlignment = .left
|
||||
return keyLabel
|
||||
}()
|
||||
@ -23,7 +23,7 @@ class KeyValueCell: UITableViewCell {
|
||||
}()
|
||||
|
||||
let valueTextField: UITextField = {
|
||||
let valueTextField = UITextField()
|
||||
let valueTextField = KeyValueCellTextField()
|
||||
valueTextField.textAlignment = .right
|
||||
valueTextField.isEnabled = false
|
||||
valueTextField.font = UIFont.preferredFont(forTextStyle: .body)
|
||||
@ -31,7 +31,7 @@ class KeyValueCell: UITableViewCell {
|
||||
valueTextField.autocapitalizationType = .none
|
||||
valueTextField.autocorrectionType = .no
|
||||
valueTextField.spellCheckingType = .no
|
||||
valueTextField.textColor = .gray
|
||||
valueTextField.textColor = .secondaryLabel
|
||||
return valueTextField
|
||||
}()
|
||||
|
||||
@ -57,9 +57,9 @@ class KeyValueCell: UITableViewCell {
|
||||
var isValueValid = true {
|
||||
didSet {
|
||||
if isValueValid {
|
||||
keyLabel.textColor = .black
|
||||
keyLabel.textColor = .label
|
||||
} else {
|
||||
keyLabel.textColor = .red
|
||||
keyLabel.textColor = .systemRed
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -68,9 +68,11 @@ class KeyValueCell: UITableViewCell {
|
||||
var isStackedVertically = false
|
||||
var contentSizeBasedConstraints = [NSLayoutConstraint]()
|
||||
|
||||
var onValueChanged: ((String) -> Void)?
|
||||
var onValueChanged: ((String, String) -> Void)?
|
||||
var onValueBeingEdited: ((String) -> Void)?
|
||||
|
||||
var observationToken: AnyObject?
|
||||
|
||||
private var textFieldValueOnBeginEditing: String = ""
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
@ -97,8 +99,6 @@ class KeyValueCell: UITableViewCell {
|
||||
expandToFitValueLabelConstraint.priority = .defaultLow + 1
|
||||
expandToFitValueLabelConstraint.isActive = true
|
||||
|
||||
contentView.addSubview(valueLabelScrollView)
|
||||
|
||||
contentView.addSubview(valueLabelScrollView)
|
||||
valueLabelScrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
@ -187,6 +187,7 @@ class KeyValueCell: UITableViewCell {
|
||||
keyboardType = .default
|
||||
onValueChanged = nil
|
||||
onValueBeingEdited = nil
|
||||
observationToken = nil
|
||||
key = ""
|
||||
value = ""
|
||||
configureForContentSize()
|
||||
@ -203,7 +204,7 @@ extension KeyValueCell: UITextFieldDelegate {
|
||||
func textFieldDidEndEditing(_ textField: UITextField) {
|
||||
let isModified = textField.text ?? "" != textFieldValueOnBeginEditing
|
||||
guard isModified else { return }
|
||||
onValueChanged?(textField.text ?? "")
|
||||
onValueChanged?(textFieldValueOnBeginEditing, textField.text ?? "")
|
||||
}
|
||||
|
||||
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
||||
@ -215,3 +216,10 @@ extension KeyValueCell: UITextFieldDelegate {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class KeyValueCellTextField: UITextField {
|
||||
override func placeholderRect(forBounds bounds: CGRect) -> CGRect {
|
||||
// UIKit renders the placeholder label 0.5pt higher
|
||||
return super.placeholderRect(forBounds: bounds).integral.offsetBy(dx: 0, dy: -0.5)
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
@ -16,12 +16,16 @@ class SwitchCell: UITableViewCell {
|
||||
get { return switchView.isEnabled }
|
||||
set(value) {
|
||||
switchView.isEnabled = value
|
||||
textLabel?.textColor = value ? .black : .gray
|
||||
textLabel?.textColor = value ? .label : .secondaryLabel
|
||||
}
|
||||
}
|
||||
|
||||
var onSwitchToggled: ((Bool) -> Void)?
|
||||
|
||||
var statusObservationToken: AnyObject?
|
||||
var isOnDemandEnabledObservationToken: AnyObject?
|
||||
var hasOnDemandRulesObservationToken: AnyObject?
|
||||
|
||||
let switchView = UISwitch()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
@ -45,5 +49,8 @@ class SwitchCell: UITableViewCell {
|
||||
isEnabled = true
|
||||
message = ""
|
||||
isOn = false
|
||||
statusObservationToken = nil
|
||||
isOnDemandEnabledObservationToken = nil
|
||||
hasOnDemandRulesObservationToken = nil
|
||||
}
|
||||
}
|
34
Sources/WireGuardApp/UI/iOS/View/TextCell.swift
Normal file
@ -0,0 +1,34 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
class TextCell: UITableViewCell {
|
||||
var message: String {
|
||||
get { return textLabel?.text ?? "" }
|
||||
set(value) { textLabel!.text = value }
|
||||
}
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: .default, reuseIdentifier: reuseIdentifier)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func setTextColor(_ color: UIColor) {
|
||||
textLabel?.textColor = color
|
||||
}
|
||||
|
||||
func setTextAlignment(_ alignment: NSTextAlignment) {
|
||||
textLabel?.textAlignment = alignment
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
message = ""
|
||||
setTextColor(.label)
|
||||
setTextAlignment(.left)
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
@ -30,7 +30,7 @@ class TunnelEditEditableKeyValueCell: TunnelEditKeyValueCell {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
copyableGesture = false
|
||||
valueTextField.textColor = .black
|
||||
valueTextField.textColor = .label
|
||||
valueTextField.isEnabled = true
|
||||
valueLabelScrollView.isScrollEnabled = false
|
||||
valueTextField.widthAnchor.constraint(equalTo: valueLabelScrollView.widthAnchor).isActive = true
|
162
Sources/WireGuardApp/UI/iOS/View/TunnelListCell.swift
Normal file
@ -0,0 +1,162 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
class TunnelListCell: UITableViewCell {
|
||||
var tunnel: TunnelContainer? {
|
||||
didSet {
|
||||
// Bind to the tunnel's name
|
||||
nameLabel.text = tunnel?.name ?? ""
|
||||
nameObservationToken = tunnel?.observe(\.name) { [weak self] tunnel, _ in
|
||||
self?.nameLabel.text = tunnel.name
|
||||
}
|
||||
// Bind to the tunnel's status
|
||||
update(from: tunnel, animated: false)
|
||||
statusObservationToken = tunnel?.observe(\.status) { [weak self] tunnel, _ in
|
||||
self?.update(from: tunnel, animated: true)
|
||||
}
|
||||
// Bind to tunnel's on-demand settings
|
||||
isOnDemandEnabledObservationToken = tunnel?.observe(\.isActivateOnDemandEnabled) { [weak self] tunnel, _ in
|
||||
self?.update(from: tunnel, animated: true)
|
||||
}
|
||||
hasOnDemandRulesObservationToken = tunnel?.observe(\.hasOnDemandRules) { [weak self] tunnel, _ in
|
||||
self?.update(from: tunnel, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
var onSwitchToggled: ((Bool) -> Void)?
|
||||
|
||||
let nameLabel: UILabel = {
|
||||
let nameLabel = UILabel()
|
||||
nameLabel.font = UIFont.preferredFont(forTextStyle: .body)
|
||||
nameLabel.adjustsFontForContentSizeCategory = true
|
||||
nameLabel.numberOfLines = 0
|
||||
return nameLabel
|
||||
}()
|
||||
|
||||
let onDemandLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.text = ""
|
||||
label.font = UIFont.preferredFont(forTextStyle: .caption2)
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
label.numberOfLines = 1
|
||||
label.textColor = .secondaryLabel
|
||||
return label
|
||||
}()
|
||||
|
||||
let busyIndicator: UIActivityIndicatorView = {
|
||||
let busyIndicator: UIActivityIndicatorView
|
||||
busyIndicator = UIActivityIndicatorView(style: .medium)
|
||||
busyIndicator.hidesWhenStopped = true
|
||||
return busyIndicator
|
||||
}()
|
||||
|
||||
let statusSwitch = UISwitch()
|
||||
|
||||
private var nameObservationToken: NSKeyValueObservation?
|
||||
private var statusObservationToken: NSKeyValueObservation?
|
||||
private var isOnDemandEnabledObservationToken: NSKeyValueObservation?
|
||||
private var hasOnDemandRulesObservationToken: NSKeyValueObservation?
|
||||
|
||||
private var subTitleLabelBottomConstraint: NSLayoutConstraint?
|
||||
private var nameLabelBottomConstraint: NSLayoutConstraint?
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
accessoryType = .disclosureIndicator
|
||||
|
||||
for subview in [statusSwitch, busyIndicator, onDemandLabel, nameLabel] {
|
||||
subview.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(subview)
|
||||
}
|
||||
|
||||
nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
onDemandLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||
|
||||
let nameLabelBottomConstraint =
|
||||
contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: nameLabel.bottomAnchor, multiplier: 1)
|
||||
nameLabelBottomConstraint.priority = .defaultLow
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
statusSwitch.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
statusSwitch.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
|
||||
statusSwitch.leadingAnchor.constraint(equalToSystemSpacingAfter: busyIndicator.trailingAnchor, multiplier: 1),
|
||||
statusSwitch.leadingAnchor.constraint(equalToSystemSpacingAfter: onDemandLabel.trailingAnchor, multiplier: 1),
|
||||
|
||||
nameLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 1),
|
||||
nameLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: contentView.layoutMarginsGuide.leadingAnchor, multiplier: 1),
|
||||
nameLabel.trailingAnchor.constraint(lessThanOrEqualTo: statusSwitch.leadingAnchor),
|
||||
nameLabelBottomConstraint,
|
||||
|
||||
onDemandLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
onDemandLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: nameLabel.trailingAnchor, multiplier: 1),
|
||||
|
||||
busyIndicator.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
busyIndicator.leadingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: nameLabel.trailingAnchor, multiplier: 1)
|
||||
])
|
||||
|
||||
statusSwitch.addTarget(self, action: #selector(switchToggled), for: .valueChanged)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
reset(animated: false)
|
||||
}
|
||||
|
||||
override func setEditing(_ editing: Bool, animated: Bool) {
|
||||
super.setEditing(editing, animated: animated)
|
||||
statusSwitch.isEnabled = !editing
|
||||
}
|
||||
|
||||
@objc private func switchToggled() {
|
||||
onSwitchToggled?(statusSwitch.isOn)
|
||||
}
|
||||
|
||||
private func update(from tunnel: TunnelContainer?, animated: Bool) {
|
||||
guard let tunnel = tunnel else {
|
||||
reset(animated: animated)
|
||||
return
|
||||
}
|
||||
let status = tunnel.status
|
||||
let isOnDemandEngaged = tunnel.isActivateOnDemandEnabled
|
||||
|
||||
let shouldSwitchBeOn = ((status != .deactivating && status != .inactive) || isOnDemandEngaged)
|
||||
statusSwitch.setOn(shouldSwitchBeOn, animated: true)
|
||||
|
||||
if isOnDemandEngaged && !(status == .activating || status == .active) {
|
||||
statusSwitch.onTintColor = UIColor.systemYellow
|
||||
} else {
|
||||
statusSwitch.onTintColor = UIColor.systemGreen
|
||||
}
|
||||
|
||||
statusSwitch.isUserInteractionEnabled = (status == .inactive || status == .active)
|
||||
|
||||
if tunnel.hasOnDemandRules {
|
||||
onDemandLabel.text = isOnDemandEngaged ? tr("tunnelListCaptionOnDemand") : ""
|
||||
busyIndicator.stopAnimating()
|
||||
statusSwitch.isUserInteractionEnabled = true
|
||||
} else {
|
||||
onDemandLabel.text = ""
|
||||
if status == .inactive || status == .active {
|
||||
busyIndicator.stopAnimating()
|
||||
} else {
|
||||
busyIndicator.startAnimating()
|
||||
}
|
||||
statusSwitch.isUserInteractionEnabled = (status == .inactive || status == .active)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func reset(animated: Bool) {
|
||||
statusSwitch.thumbTintColor = nil
|
||||
statusSwitch.setOn(false, animated: animated)
|
||||
statusSwitch.isUserInteractionEnabled = false
|
||||
busyIndicator.stopAnimating()
|
||||
}
|
||||
}
|
@ -0,0 +1,147 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
class LogViewController: UIViewController {
|
||||
|
||||
let textView: UITextView = {
|
||||
let textView = UITextView()
|
||||
textView.isEditable = false
|
||||
textView.isSelectable = true
|
||||
textView.font = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body)
|
||||
textView.adjustsFontForContentSizeCategory = true
|
||||
return textView
|
||||
}()
|
||||
|
||||
let busyIndicator: UIActivityIndicatorView = {
|
||||
let busyIndicator = UIActivityIndicatorView(style: .medium)
|
||||
busyIndicator.hidesWhenStopped = true
|
||||
return busyIndicator
|
||||
}()
|
||||
|
||||
let paragraphStyle: NSParagraphStyle = {
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.setParagraphStyle(NSParagraphStyle.default)
|
||||
paragraphStyle.lineHeightMultiple = 1.2
|
||||
return paragraphStyle
|
||||
}()
|
||||
|
||||
var isNextLineHighlighted = false
|
||||
|
||||
var logViewHelper: LogViewHelper?
|
||||
var isFetchingLogEntries = false
|
||||
private var updateLogEntriesTimer: Timer?
|
||||
|
||||
override func loadView() {
|
||||
view = UIView()
|
||||
view.backgroundColor = .systemBackground
|
||||
view.addSubview(textView)
|
||||
textView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
textView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
textView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
textView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
])
|
||||
|
||||
view.addSubview(busyIndicator)
|
||||
busyIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
busyIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
busyIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
|
||||
])
|
||||
|
||||
busyIndicator.startAnimating()
|
||||
|
||||
logViewHelper = LogViewHelper(logFilePath: FileManager.logFileURL?.path)
|
||||
startUpdatingLogEntries()
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = tr("logViewTitle")
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(saveTapped(sender:)))
|
||||
}
|
||||
|
||||
func updateLogEntries() {
|
||||
guard !isFetchingLogEntries else { return }
|
||||
isFetchingLogEntries = true
|
||||
logViewHelper?.fetchLogEntriesSinceLastFetch { [weak self] fetchedLogEntries in
|
||||
guard let self = self else { return }
|
||||
defer {
|
||||
self.isFetchingLogEntries = false
|
||||
}
|
||||
if self.busyIndicator.isAnimating {
|
||||
self.busyIndicator.stopAnimating()
|
||||
}
|
||||
guard !fetchedLogEntries.isEmpty else { return }
|
||||
let isScrolledToEnd = self.textView.contentSize.height - self.textView.bounds.height - self.textView.contentOffset.y < 1
|
||||
|
||||
let richText = NSMutableAttributedString()
|
||||
let bodyFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body)
|
||||
let captionFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.caption1)
|
||||
for logEntry in fetchedLogEntries {
|
||||
let bgColor: UIColor = self.isNextLineHighlighted ? .systemGray3 : .systemBackground
|
||||
let fgColor: UIColor = .label
|
||||
let timestampText = NSAttributedString(string: logEntry.timestamp + "\n", attributes: [.font: captionFont, .backgroundColor: bgColor, .foregroundColor: fgColor, .paragraphStyle: self.paragraphStyle])
|
||||
let messageText = NSAttributedString(string: logEntry.message + "\n", attributes: [.font: bodyFont, .backgroundColor: bgColor, .foregroundColor: fgColor, .paragraphStyle: self.paragraphStyle])
|
||||
richText.append(timestampText)
|
||||
richText.append(messageText)
|
||||
self.isNextLineHighlighted.toggle()
|
||||
}
|
||||
self.textView.textStorage.append(richText)
|
||||
if isScrolledToEnd {
|
||||
let endOfCurrentText = NSRange(location: (self.textView.text as NSString).length, length: 0)
|
||||
self.textView.scrollRangeToVisible(endOfCurrentText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startUpdatingLogEntries() {
|
||||
updateLogEntries()
|
||||
updateLogEntriesTimer?.invalidate()
|
||||
let timer = Timer(timeInterval: 1 /* second */, repeats: true) { [weak self] _ in
|
||||
self?.updateLogEntries()
|
||||
}
|
||||
updateLogEntriesTimer = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
|
||||
@objc func saveTapped(sender: AnyObject) {
|
||||
guard let destinationDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
|
||||
|
||||
let dateFormatter = ISO8601DateFormatter()
|
||||
dateFormatter.formatOptions = [.withFullDate, .withTime, .withTimeZone] // Avoid ':' in the filename
|
||||
let timeStampString = dateFormatter.string(from: Date())
|
||||
let destinationURL = destinationDir.appendingPathComponent("wireguard-log-\(timeStampString).txt")
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
|
||||
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
||||
let isDeleted = FileManager.deleteFile(at: destinationURL)
|
||||
if !isDeleted {
|
||||
ErrorPresenter.showErrorAlert(title: tr("alertUnableToRemovePreviousLogTitle"), message: tr("alertUnableToRemovePreviousLogMessage"), from: self)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let isWritten = Logger.global?.writeLog(to: destinationURL.path) ?? false
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard isWritten else {
|
||||
ErrorPresenter.showErrorAlert(title: tr("alertUnableToWriteLogTitle"), message: tr("alertUnableToWriteLogMessage"), from: self)
|
||||
return
|
||||
}
|
||||
let activityVC = UIActivityViewController(activityItems: [destinationURL], applicationActivities: nil)
|
||||
if let sender = sender as? UIBarButtonItem {
|
||||
activityVC.popoverPresentationController?.barButtonItem = sender
|
||||
}
|
||||
activityVC.completionWithItemsHandler = { _, _, _, _ in
|
||||
// Remove the exported log file after the activity has completed
|
||||
_ = FileManager.deleteFile(at: destinationURL)
|
||||
}
|
||||
self.present(activityVC, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
@ -8,11 +8,10 @@ class MainViewController: UISplitViewController {
|
||||
var tunnelsManager: TunnelsManager?
|
||||
var onTunnelsManagerReady: ((TunnelsManager) -> Void)?
|
||||
var tunnelsListVC: TunnelsListTableViewController?
|
||||
private var foregroundObservationToken: AnyObject?
|
||||
|
||||
init() {
|
||||
let detailVC = UIViewController()
|
||||
detailVC.view.backgroundColor = .white
|
||||
detailVC.view.backgroundColor = .systemBackground
|
||||
let detailNC = UINavigationController(rootViewController: detailVC)
|
||||
|
||||
let masterVC = TunnelsListTableViewController()
|
||||
@ -43,45 +42,25 @@ class MainViewController: UISplitViewController {
|
||||
TunnelsManager.create { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
|
||||
if let error = result.error {
|
||||
switch result {
|
||||
case .failure(let error):
|
||||
ErrorPresenter.showErrorAlert(error: error, from: self)
|
||||
return
|
||||
}
|
||||
let tunnelsManager: TunnelsManager = result.value!
|
||||
|
||||
self.tunnelsManager = tunnelsManager
|
||||
self.tunnelsListVC?.setTunnelsManager(tunnelsManager: tunnelsManager)
|
||||
|
||||
tunnelsManager.activationDelegate = self
|
||||
|
||||
self.onTunnelsManagerReady?(tunnelsManager)
|
||||
self.onTunnelsManagerReady = nil
|
||||
}
|
||||
|
||||
foregroundObservationToken = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: OperationQueue.main) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.tunnelsManager?.reload { [weak self] hasChanges in
|
||||
guard let self = self, let tunnelsManager = self.tunnelsManager, hasChanges else { return }
|
||||
|
||||
case .success(let tunnelsManager):
|
||||
self.tunnelsManager = tunnelsManager
|
||||
self.tunnelsListVC?.setTunnelsManager(tunnelsManager: tunnelsManager)
|
||||
|
||||
if self.isCollapsed {
|
||||
(self.viewControllers[0] as? UINavigationController)?.popViewController(animated: false)
|
||||
} else {
|
||||
let detailVC = UIViewController()
|
||||
detailVC.view.backgroundColor = .white
|
||||
let detailNC = UINavigationController(rootViewController: detailVC)
|
||||
self.showDetailViewController(detailNC, sender: self)
|
||||
}
|
||||
tunnelsManager.activationDelegate = self
|
||||
|
||||
if let presentedNavController = self.presentedViewController as? UINavigationController, presentedNavController.viewControllers.first is TunnelEditTableViewController {
|
||||
self.presentedViewController?.dismiss(animated: false, completion: nil)
|
||||
}
|
||||
self.onTunnelsManagerReady?(tunnelsManager)
|
||||
self.onTunnelsManagerReady = nil
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func allTunnelNames() -> [String]? {
|
||||
guard let tunnelsManager = self.tunnelsManager else { return nil }
|
||||
return tunnelsManager.mapTunnels { $0.name }
|
||||
}
|
||||
}
|
||||
|
||||
extension MainViewController: TunnelsManagerActivationDelegate {
|
||||
@ -109,19 +88,17 @@ extension MainViewController {
|
||||
}
|
||||
}
|
||||
|
||||
func showTunnelDetailForTunnel(named tunnelName: String, animated: Bool) {
|
||||
func showTunnelDetailForTunnel(named tunnelName: String, animated: Bool, shouldToggleStatus: Bool) {
|
||||
let showTunnelDetailBlock: (TunnelsManager) -> Void = { [weak self] tunnelsManager in
|
||||
guard let self = self else { return }
|
||||
guard let tunnelsListVC = self.tunnelsListVC else { return }
|
||||
if let tunnel = tunnelsManager.tunnel(named: tunnelName) {
|
||||
let tunnelDetailVC = TunnelDetailTableViewController(tunnelsManager: tunnelsManager, tunnel: tunnel)
|
||||
let tunnelDetailNC = UINavigationController(rootViewController: tunnelDetailVC)
|
||||
tunnelDetailNC.restorationIdentifier = "DetailNC"
|
||||
if let self = self {
|
||||
if animated {
|
||||
self.showDetailViewController(tunnelDetailNC, sender: self)
|
||||
} else {
|
||||
UIView.performWithoutAnimation {
|
||||
self.showDetailViewController(tunnelDetailNC, sender: self)
|
||||
}
|
||||
tunnelsListVC.showTunnelDetail(for: tunnel, animated: false)
|
||||
if shouldToggleStatus {
|
||||
if tunnel.status == .inactive {
|
||||
tunnelsManager.startActivation(of: tunnel)
|
||||
} else if tunnel.status == .active {
|
||||
tunnelsManager.startDeactivation(of: tunnel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -132,6 +109,19 @@ extension MainViewController {
|
||||
onTunnelsManagerReady = showTunnelDetailBlock
|
||||
}
|
||||
}
|
||||
|
||||
func importFromDisposableFile(url: URL) {
|
||||
let importFromFileBlock: (TunnelsManager) -> Void = { [weak self] tunnelsManager in
|
||||
TunnelImporter.importFromFile(urls: [url], into: tunnelsManager, sourceVC: self, errorPresenterType: ErrorPresenter.self) {
|
||||
_ = FileManager.deleteFile(at: url)
|
||||
}
|
||||
}
|
||||
if let tunnelsManager = tunnelsManager {
|
||||
importFromFileBlock(tunnelsManager)
|
||||
} else {
|
||||
onTunnelsManagerReady = importFromFileBlock
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MainViewController: UISplitViewControllerDelegate {
|
@ -1,10 +1,10 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
|
||||
protocol QRScanViewControllerDelegate: class {
|
||||
protocol QRScanViewControllerDelegate: AnyObject {
|
||||
func addScannedQRCode(tunnelConfiguration: TunnelConfiguration, qrScanViewController: QRScanViewController, completionHandler: (() -> Void)?)
|
||||
}
|
||||
|
@ -0,0 +1,49 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
class SSIDOptionDetailTableViewController: UITableViewController {
|
||||
|
||||
let selectedSSIDs: [String]
|
||||
|
||||
init(title: String, ssids: [String]) {
|
||||
selectedSSIDs = ssids
|
||||
super.init(style: .grouped)
|
||||
self.title = title
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tableView.estimatedRowHeight = 44
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.allowsSelection = false
|
||||
|
||||
tableView.register(TextCell.self)
|
||||
}
|
||||
}
|
||||
|
||||
extension SSIDOptionDetailTableViewController {
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return 1
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return selectedSSIDs.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
return tr("tunnelOnDemandSectionTitleSelectedSSIDs")
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell: TextCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
cell.message = selectedSSIDs[indexPath.row]
|
||||
return cell
|
||||
}
|
||||
}
|
@ -0,0 +1,307 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
import SystemConfiguration.CaptiveNetwork
|
||||
import NetworkExtension
|
||||
|
||||
protocol SSIDOptionEditTableViewControllerDelegate: AnyObject {
|
||||
func ssidOptionSaved(option: ActivateOnDemandViewModel.OnDemandSSIDOption, ssids: [String])
|
||||
}
|
||||
|
||||
class SSIDOptionEditTableViewController: UITableViewController {
|
||||
private enum Section {
|
||||
case ssidOption
|
||||
case selectedSSIDs
|
||||
case addSSIDs
|
||||
}
|
||||
|
||||
private enum AddSSIDRow {
|
||||
case addConnectedSSID(connectedSSID: String)
|
||||
case addNewSSID
|
||||
}
|
||||
|
||||
weak var delegate: SSIDOptionEditTableViewControllerDelegate?
|
||||
|
||||
private var sections = [Section]()
|
||||
private var addSSIDRows = [AddSSIDRow]()
|
||||
|
||||
let ssidOptionFields: [ActivateOnDemandViewModel.OnDemandSSIDOption] = [
|
||||
.anySSID,
|
||||
.onlySpecificSSIDs,
|
||||
.exceptSpecificSSIDs
|
||||
]
|
||||
|
||||
var selectedOption: ActivateOnDemandViewModel.OnDemandSSIDOption
|
||||
var selectedSSIDs: [String]
|
||||
var connectedSSID: String?
|
||||
|
||||
init(option: ActivateOnDemandViewModel.OnDemandSSIDOption, ssids: [String]) {
|
||||
selectedOption = option
|
||||
selectedSSIDs = ssids
|
||||
super.init(style: .grouped)
|
||||
loadSections()
|
||||
addSSIDRows.removeAll()
|
||||
addSSIDRows.append(.addNewSSID)
|
||||
|
||||
getConnectedSSID { [weak self] ssid in
|
||||
guard let self = self else { return }
|
||||
self.connectedSSID = ssid
|
||||
self.updateCurrentSSIDEntry()
|
||||
self.updateTableViewAddSSIDRows()
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = tr("tunnelOnDemandSSIDViewTitle")
|
||||
|
||||
tableView.estimatedRowHeight = 44
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
|
||||
tableView.register(CheckmarkCell.self)
|
||||
tableView.register(EditableTextCell.self)
|
||||
tableView.register(TextCell.self)
|
||||
tableView.isEditing = true
|
||||
tableView.allowsSelectionDuringEditing = true
|
||||
tableView.keyboardDismissMode = .onDrag
|
||||
}
|
||||
|
||||
func loadSections() {
|
||||
sections.removeAll()
|
||||
sections.append(.ssidOption)
|
||||
if selectedOption != .anySSID {
|
||||
sections.append(.selectedSSIDs)
|
||||
sections.append(.addSSIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func updateCurrentSSIDEntry() {
|
||||
if let connectedSSID = connectedSSID, !selectedSSIDs.contains(connectedSSID) {
|
||||
if let first = addSSIDRows.first, case .addNewSSID = first {
|
||||
addSSIDRows.insert(.addConnectedSSID(connectedSSID: connectedSSID), at: 0)
|
||||
}
|
||||
} else if let first = addSSIDRows.first, case .addConnectedSSID = first {
|
||||
addSSIDRows.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
func updateTableViewAddSSIDRows() {
|
||||
guard let addSSIDSection = sections.firstIndex(of: .addSSIDs) else { return }
|
||||
let numberOfAddSSIDRows = addSSIDRows.count
|
||||
let numberOfAddSSIDRowsInTableView = tableView.numberOfRows(inSection: addSSIDSection)
|
||||
switch (numberOfAddSSIDRowsInTableView, numberOfAddSSIDRows) {
|
||||
case (1, 2):
|
||||
tableView.insertRows(at: [IndexPath(row: 0, section: addSSIDSection)], with: .automatic)
|
||||
case (2, 1):
|
||||
tableView.deleteRows(at: [IndexPath(row: 0, section: addSSIDSection)], with: .automatic)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
delegate?.ssidOptionSaved(option: selectedOption, ssids: selectedSSIDs)
|
||||
}
|
||||
}
|
||||
|
||||
extension SSIDOptionEditTableViewController {
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return sections.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
switch sections[section] {
|
||||
case .ssidOption:
|
||||
return ssidOptionFields.count
|
||||
case .selectedSSIDs:
|
||||
return selectedSSIDs.isEmpty ? 1 : selectedSSIDs.count
|
||||
case .addSSIDs:
|
||||
return addSSIDRows.count
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
switch sections[indexPath.section] {
|
||||
case .ssidOption:
|
||||
return ssidOptionCell(for: tableView, at: indexPath)
|
||||
case .selectedSSIDs:
|
||||
if !selectedSSIDs.isEmpty {
|
||||
return selectedSSIDCell(for: tableView, at: indexPath)
|
||||
} else {
|
||||
return noSSIDsCell(for: tableView, at: indexPath)
|
||||
}
|
||||
case .addSSIDs:
|
||||
return addSSIDCell(for: tableView, at: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||
switch sections[indexPath.section] {
|
||||
case .ssidOption:
|
||||
return false
|
||||
case .selectedSSIDs:
|
||||
return !selectedSSIDs.isEmpty
|
||||
case .addSSIDs:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
|
||||
switch sections[indexPath.section] {
|
||||
case .ssidOption:
|
||||
return .none
|
||||
case .selectedSSIDs:
|
||||
return .delete
|
||||
case .addSSIDs:
|
||||
return .insert
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
switch sections[section] {
|
||||
case .ssidOption:
|
||||
return nil
|
||||
case .selectedSSIDs:
|
||||
return tr("tunnelOnDemandSectionTitleSelectedSSIDs")
|
||||
case .addSSIDs:
|
||||
return tr("tunnelOnDemandSectionTitleAddSSIDs")
|
||||
}
|
||||
}
|
||||
|
||||
private func ssidOptionCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
|
||||
let field = ssidOptionFields[indexPath.row]
|
||||
let cell: CheckmarkCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
cell.message = field.localizedUIString
|
||||
cell.isChecked = selectedOption == field
|
||||
cell.isEditing = false
|
||||
return cell
|
||||
}
|
||||
|
||||
private func noSSIDsCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell: TextCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
cell.message = tr("tunnelOnDemandNoSSIDs")
|
||||
cell.setTextColor(.secondaryLabel)
|
||||
cell.setTextAlignment(.center)
|
||||
return cell
|
||||
}
|
||||
|
||||
private func selectedSSIDCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell: EditableTextCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
cell.message = selectedSSIDs[indexPath.row]
|
||||
cell.placeholder = tr("tunnelOnDemandSSIDTextFieldPlaceholder")
|
||||
cell.isEditing = true
|
||||
cell.onValueBeingEdited = { [weak self, weak cell] text in
|
||||
guard let self = self, let cell = cell else { return }
|
||||
if let row = self.tableView.indexPath(for: cell)?.row {
|
||||
self.selectedSSIDs[row] = text
|
||||
self.updateCurrentSSIDEntry()
|
||||
self.updateTableViewAddSSIDRows()
|
||||
}
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
private func addSSIDCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell: TextCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
switch addSSIDRows[indexPath.row] {
|
||||
case .addConnectedSSID:
|
||||
cell.message = tr(format: "tunnelOnDemandAddMessageAddConnectedSSID (%@)", connectedSSID!)
|
||||
case .addNewSSID:
|
||||
cell.message = tr("tunnelOnDemandAddMessageAddNewSSID")
|
||||
}
|
||||
cell.isEditing = true
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
|
||||
switch sections[indexPath.section] {
|
||||
case .ssidOption:
|
||||
assertionFailure()
|
||||
case .selectedSSIDs:
|
||||
assert(editingStyle == .delete)
|
||||
selectedSSIDs.remove(at: indexPath.row)
|
||||
if !selectedSSIDs.isEmpty {
|
||||
tableView.deleteRows(at: [indexPath], with: .automatic)
|
||||
} else {
|
||||
tableView.reloadRows(at: [indexPath], with: .automatic)
|
||||
}
|
||||
updateCurrentSSIDEntry()
|
||||
updateTableViewAddSSIDRows()
|
||||
case .addSSIDs:
|
||||
assert(editingStyle == .insert)
|
||||
let newSSID: String
|
||||
switch addSSIDRows[indexPath.row] {
|
||||
case .addConnectedSSID(let connectedSSID):
|
||||
newSSID = connectedSSID
|
||||
case .addNewSSID:
|
||||
newSSID = ""
|
||||
}
|
||||
selectedSSIDs.append(newSSID)
|
||||
loadSections()
|
||||
let selectedSSIDsSection = sections.firstIndex(of: .selectedSSIDs)!
|
||||
let indexPath = IndexPath(row: selectedSSIDs.count - 1, section: selectedSSIDsSection)
|
||||
if selectedSSIDs.count == 1 {
|
||||
tableView.reloadRows(at: [indexPath], with: .automatic)
|
||||
} else {
|
||||
tableView.insertRows(at: [indexPath], with: .automatic)
|
||||
}
|
||||
updateCurrentSSIDEntry()
|
||||
updateTableViewAddSSIDRows()
|
||||
if newSSID.isEmpty {
|
||||
if let selectedSSIDCell = tableView.cellForRow(at: indexPath) as? EditableTextCell {
|
||||
selectedSSIDCell.beginEditing()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getConnectedSSID(completionHandler: @escaping (String?) -> Void) {
|
||||
#if targetEnvironment(simulator)
|
||||
completionHandler("Simulator Wi-Fi")
|
||||
#else
|
||||
NEHotspotNetwork.fetchCurrent { hotspotNetwork in
|
||||
completionHandler(hotspotNetwork?.ssid)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension SSIDOptionEditTableViewController {
|
||||
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
|
||||
switch sections[indexPath.section] {
|
||||
case .ssidOption:
|
||||
return indexPath
|
||||
case .selectedSSIDs, .addSSIDs:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
switch sections[indexPath.section] {
|
||||
case .ssidOption:
|
||||
let previousOption = selectedOption
|
||||
selectedOption = ssidOptionFields[indexPath.row]
|
||||
guard previousOption != selectedOption else {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
return
|
||||
}
|
||||
loadSections()
|
||||
if previousOption == .anySSID {
|
||||
let indexSet = IndexSet(1 ... 2)
|
||||
tableView.insertSections(indexSet, with: .fade)
|
||||
}
|
||||
if selectedOption == .anySSID {
|
||||
let indexSet = IndexSet(1 ... 2)
|
||||
tableView.deleteSections(indexSet, with: .fade)
|
||||
}
|
||||
tableView.reloadSections(IndexSet(integer: indexPath.section), with: .none)
|
||||
case .selectedSSIDs, .addSSIDs:
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
import os.log
|
||||
@ -10,14 +10,14 @@ class SettingsTableViewController: UITableViewController {
|
||||
case iosAppVersion
|
||||
case goBackendVersion
|
||||
case exportZipArchive
|
||||
case exportLogFile
|
||||
case viewLog
|
||||
|
||||
var localizedUIString: String {
|
||||
switch self {
|
||||
case .iosAppVersion: return tr("settingsVersionKeyWireGuardForIOS")
|
||||
case .goBackendVersion: return tr("settingsVersionKeyWireGuardGoBackend")
|
||||
case .exportZipArchive: return tr("settingsExportZipButtonTitle")
|
||||
case .exportLogFile: return tr("settingsExportLogFileButtonTitle")
|
||||
case .viewLog: return tr("settingsViewLogButtonTitle")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -25,7 +25,7 @@ class SettingsTableViewController: UITableViewController {
|
||||
let settingsFieldsBySection: [[SettingsFields]] = [
|
||||
[.iosAppVersion, .goBackendVersion],
|
||||
[.exportZipArchive],
|
||||
[.exportLogFile]
|
||||
[.viewLog]
|
||||
]
|
||||
|
||||
let tunnelsManager: TunnelsManager?
|
||||
@ -86,65 +86,32 @@ class SettingsTableViewController: UITableViewController {
|
||||
}
|
||||
|
||||
func exportConfigurationsAsZipFile(sourceView: UIView) {
|
||||
guard let tunnelsManager = tunnelsManager else { return }
|
||||
guard let destinationDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
|
||||
PrivateDataConfirmation.confirmAccess(to: tr("iosExportPrivateData")) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let tunnelsManager = self.tunnelsManager else { return }
|
||||
guard let destinationDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
|
||||
|
||||
let destinationURL = destinationDir.appendingPathComponent("wireguard-export.zip")
|
||||
_ = FileManager.deleteFile(at: destinationURL)
|
||||
let destinationURL = destinationDir.appendingPathComponent("wireguard-export.zip")
|
||||
_ = FileManager.deleteFile(at: destinationURL)
|
||||
|
||||
let count = tunnelsManager.numberOfTunnels()
|
||||
let tunnelConfigurations = (0 ..< count).compactMap { tunnelsManager.tunnel(at: $0).tunnelConfiguration }
|
||||
ZipExporter.exportConfigFiles(tunnelConfigurations: tunnelConfigurations, to: destinationURL) { [weak self] error in
|
||||
if let error = error {
|
||||
ErrorPresenter.showErrorAlert(error: error, from: self)
|
||||
return
|
||||
let count = tunnelsManager.numberOfTunnels()
|
||||
let tunnelConfigurations = (0 ..< count).compactMap { tunnelsManager.tunnel(at: $0).tunnelConfiguration }
|
||||
ZipExporter.exportConfigFiles(tunnelConfigurations: tunnelConfigurations, to: destinationURL) { [weak self] error in
|
||||
if let error = error {
|
||||
ErrorPresenter.showErrorAlert(error: error, from: self)
|
||||
return
|
||||
}
|
||||
|
||||
let fileExportVC = UIDocumentPickerViewController(url: destinationURL, in: .exportToService)
|
||||
self?.present(fileExportVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
let fileExportVC = UIDocumentPickerViewController(url: destinationURL, in: .exportToService)
|
||||
self?.present(fileExportVC, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func exportLogForLastActivatedTunnel(sourceView: UIView) {
|
||||
guard let destinationDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
|
||||
func presentLogView() {
|
||||
let logVC = LogViewController()
|
||||
navigationController?.pushViewController(logVC, animated: true)
|
||||
|
||||
let dateFormatter = ISO8601DateFormatter()
|
||||
dateFormatter.formatOptions = [.withFullDate, .withTime, .withTimeZone] // Avoid ':' in the filename
|
||||
let timeStampString = dateFormatter.string(from: Date())
|
||||
let destinationURL = destinationDir.appendingPathComponent("wireguard-log-\(timeStampString).txt")
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
|
||||
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
||||
let isDeleted = FileManager.deleteFile(at: destinationURL)
|
||||
if !isDeleted {
|
||||
ErrorPresenter.showErrorAlert(title: tr("alertUnableToRemovePreviousLogTitle"), message: tr("alertUnableToRemovePreviousLogMessage"), from: self)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard let networkExtensionLogFilePath = FileManager.networkExtensionLogFileURL?.path else {
|
||||
ErrorPresenter.showErrorAlert(title: tr("alertUnableToFindExtensionLogPathTitle"), message: tr("alertUnableToFindExtensionLogPathMessage"), from: self)
|
||||
return
|
||||
}
|
||||
|
||||
let isWritten = Logger.global?.writeLog(called: "APP", mergedWith: networkExtensionLogFilePath, called: "NET", to: destinationURL.path) ?? false
|
||||
guard isWritten else {
|
||||
ErrorPresenter.showErrorAlert(title: tr("alertUnableToWriteLogTitle"), message: tr("alertUnableToWriteLogMessage"), from: self)
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let activityVC = UIActivityViewController(activityItems: [destinationURL], applicationActivities: nil)
|
||||
activityVC.popoverPresentationController?.sourceView = sourceView
|
||||
activityVC.popoverPresentationController?.sourceRect = sourceView.bounds
|
||||
activityVC.completionWithItemsHandler = { _, _, _, _ in
|
||||
// Remove the exported log file after the activity has completed
|
||||
_ = FileManager.deleteFile(at: destinationURL)
|
||||
}
|
||||
self.present(activityVC, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -177,8 +144,8 @@ extension SettingsTableViewController {
|
||||
cell.copyableGesture = false
|
||||
cell.key = field.localizedUIString
|
||||
if field == .iosAppVersion {
|
||||
var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown version"
|
||||
if let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String {
|
||||
var appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown version"
|
||||
if let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String {
|
||||
appVersion += " (\(appBuild))"
|
||||
}
|
||||
cell.value = appVersion
|
||||
@ -193,14 +160,14 @@ extension SettingsTableViewController {
|
||||
self?.exportConfigurationsAsZipFile(sourceView: cell.button)
|
||||
}
|
||||
return cell
|
||||
} else {
|
||||
assert(field == .exportLogFile)
|
||||
} else if field == .viewLog {
|
||||
let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
cell.buttonText = field.localizedUIString
|
||||
cell.onTapped = { [weak self] in
|
||||
self?.exportLogForLastActivatedTunnel(sourceView: cell.button)
|
||||
self?.presentLogView()
|
||||
}
|
||||
return cell
|
||||
}
|
||||
fatalError()
|
||||
}
|
||||
}
|
@ -0,0 +1,496 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
class TunnelDetailTableViewController: UITableViewController {
|
||||
|
||||
private enum Section {
|
||||
case status
|
||||
case interface
|
||||
case peer(index: Int, peer: TunnelViewModel.PeerData)
|
||||
case onDemand
|
||||
case delete
|
||||
}
|
||||
|
||||
static let interfaceFields: [TunnelViewModel.InterfaceField] = [
|
||||
.name, .publicKey, .addresses,
|
||||
.listenPort, .mtu, .dns
|
||||
]
|
||||
|
||||
static let peerFields: [TunnelViewModel.PeerField] = [
|
||||
.publicKey, .preSharedKey, .endpoint,
|
||||
.allowedIPs, .persistentKeepAlive,
|
||||
.rxBytes, .txBytes, .lastHandshakeTime
|
||||
]
|
||||
|
||||
static let onDemandFields: [ActivateOnDemandViewModel.OnDemandField] = [
|
||||
.onDemand, .ssid
|
||||
]
|
||||
|
||||
let tunnelsManager: TunnelsManager
|
||||
let tunnel: TunnelContainer
|
||||
var tunnelViewModel: TunnelViewModel
|
||||
var onDemandViewModel: ActivateOnDemandViewModel
|
||||
|
||||
private var sections = [Section]()
|
||||
private var interfaceFieldIsVisible = [Bool]()
|
||||
private var peerFieldIsVisible = [[Bool]]()
|
||||
|
||||
private var statusObservationToken: AnyObject?
|
||||
private var onDemandObservationToken: AnyObject?
|
||||
private var reloadRuntimeConfigurationTimer: Timer?
|
||||
|
||||
init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer) {
|
||||
self.tunnelsManager = tunnelsManager
|
||||
self.tunnel = tunnel
|
||||
tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration)
|
||||
onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel)
|
||||
super.init(style: .grouped)
|
||||
loadSections()
|
||||
loadVisibleFields()
|
||||
statusObservationToken = tunnel.observe(\.status) { [weak self] _, _ in
|
||||
guard let self = self else { return }
|
||||
if tunnel.status == .active {
|
||||
self.startUpdatingRuntimeConfiguration()
|
||||
} else if tunnel.status == .inactive {
|
||||
self.reloadRuntimeConfiguration()
|
||||
self.stopUpdatingRuntimeConfiguration()
|
||||
}
|
||||
}
|
||||
onDemandObservationToken = tunnel.observe(\.isActivateOnDemandEnabled) { [weak self] tunnel, _ in
|
||||
// Handle On-Demand getting turned on/off outside of the app
|
||||
self?.onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel)
|
||||
self?.updateActivateOnDemandFields()
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = tunnelViewModel.interfaceData[.name]
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(editTapped))
|
||||
|
||||
tableView.estimatedRowHeight = 44
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.register(SwitchCell.self)
|
||||
tableView.register(KeyValueCell.self)
|
||||
tableView.register(ButtonCell.self)
|
||||
tableView.register(ChevronCell.self)
|
||||
|
||||
restorationIdentifier = "TunnelDetailVC:\(tunnel.name)"
|
||||
}
|
||||
|
||||
private func loadSections() {
|
||||
sections.removeAll()
|
||||
sections.append(.status)
|
||||
sections.append(.interface)
|
||||
for (index, peer) in tunnelViewModel.peersData.enumerated() {
|
||||
sections.append(.peer(index: index, peer: peer))
|
||||
}
|
||||
sections.append(.onDemand)
|
||||
sections.append(.delete)
|
||||
}
|
||||
|
||||
private func loadVisibleFields() {
|
||||
let visibleInterfaceFields = tunnelViewModel.interfaceData.filterFieldsWithValueOrControl(interfaceFields: TunnelDetailTableViewController.interfaceFields)
|
||||
interfaceFieldIsVisible = TunnelDetailTableViewController.interfaceFields.map { visibleInterfaceFields.contains($0) }
|
||||
peerFieldIsVisible = tunnelViewModel.peersData.map { peer in
|
||||
let visiblePeerFields = peer.filterFieldsWithValueOrControl(peerFields: TunnelDetailTableViewController.peerFields)
|
||||
return TunnelDetailTableViewController.peerFields.map { visiblePeerFields.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
if tunnel.status == .active {
|
||||
self.startUpdatingRuntimeConfiguration()
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
stopUpdatingRuntimeConfiguration()
|
||||
}
|
||||
|
||||
@objc func editTapped() {
|
||||
PrivateDataConfirmation.confirmAccess(to: tr("iosViewPrivateData")) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let editVC = TunnelEditTableViewController(tunnelsManager: self.tunnelsManager, tunnel: self.tunnel)
|
||||
editVC.delegate = self
|
||||
let editNC = UINavigationController(rootViewController: editVC)
|
||||
editNC.modalPresentationStyle = .fullScreen
|
||||
self.present(editNC, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
func startUpdatingRuntimeConfiguration() {
|
||||
reloadRuntimeConfiguration()
|
||||
reloadRuntimeConfigurationTimer?.invalidate()
|
||||
let reloadTimer = Timer(timeInterval: 1 /* second */, repeats: true) { [weak self] _ in
|
||||
self?.reloadRuntimeConfiguration()
|
||||
}
|
||||
reloadRuntimeConfigurationTimer = reloadTimer
|
||||
RunLoop.main.add(reloadTimer, forMode: .common)
|
||||
}
|
||||
|
||||
func stopUpdatingRuntimeConfiguration() {
|
||||
reloadRuntimeConfigurationTimer?.invalidate()
|
||||
reloadRuntimeConfigurationTimer = nil
|
||||
}
|
||||
|
||||
func applyTunnelConfiguration(tunnelConfiguration: TunnelConfiguration) {
|
||||
// Incorporates changes from tunnelConfiguation. Ignores any changes in peer ordering.
|
||||
guard let tableView = self.tableView else { return }
|
||||
let sections = self.sections
|
||||
let interfaceSectionIndex = sections.firstIndex {
|
||||
if case .interface = $0 {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}!
|
||||
let firstPeerSectionIndex = interfaceSectionIndex + 1
|
||||
let interfaceFieldIsVisible = self.interfaceFieldIsVisible
|
||||
let peerFieldIsVisible = self.peerFieldIsVisible
|
||||
|
||||
func handleSectionFieldsModified<T>(fields: [T], fieldIsVisible: [Bool], section: Int, changes: [T: TunnelViewModel.Changes.FieldChange]) {
|
||||
for (index, field) in fields.enumerated() {
|
||||
guard let change = changes[field] else { continue }
|
||||
if case .modified(let newValue) = change {
|
||||
let row = fieldIsVisible[0 ..< index].filter { $0 }.count
|
||||
let indexPath = IndexPath(row: row, section: section)
|
||||
if let cell = tableView.cellForRow(at: indexPath) as? KeyValueCell {
|
||||
cell.value = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleSectionRowsInsertedOrRemoved<T>(fields: [T], fieldIsVisible fieldIsVisibleInput: [Bool], section: Int, changes: [T: TunnelViewModel.Changes.FieldChange]) {
|
||||
var fieldIsVisible = fieldIsVisibleInput
|
||||
|
||||
var removedIndexPaths = [IndexPath]()
|
||||
for (index, field) in fields.enumerated().reversed() where changes[field] == .removed {
|
||||
let row = fieldIsVisible[0 ..< index].filter { $0 }.count
|
||||
removedIndexPaths.append(IndexPath(row: row, section: section))
|
||||
fieldIsVisible[index] = false
|
||||
}
|
||||
if !removedIndexPaths.isEmpty {
|
||||
tableView.deleteRows(at: removedIndexPaths, with: .automatic)
|
||||
}
|
||||
|
||||
var addedIndexPaths = [IndexPath]()
|
||||
for (index, field) in fields.enumerated() where changes[field] == .added {
|
||||
let row = fieldIsVisible[0 ..< index].filter { $0 }.count
|
||||
addedIndexPaths.append(IndexPath(row: row, section: section))
|
||||
fieldIsVisible[index] = true
|
||||
}
|
||||
if !addedIndexPaths.isEmpty {
|
||||
tableView.insertRows(at: addedIndexPaths, with: .automatic)
|
||||
}
|
||||
}
|
||||
|
||||
let changes = self.tunnelViewModel.applyConfiguration(other: tunnelConfiguration)
|
||||
|
||||
if !changes.interfaceChanges.isEmpty {
|
||||
handleSectionFieldsModified(fields: TunnelDetailTableViewController.interfaceFields, fieldIsVisible: interfaceFieldIsVisible,
|
||||
section: interfaceSectionIndex, changes: changes.interfaceChanges)
|
||||
}
|
||||
for (peerIndex, peerChanges) in changes.peerChanges {
|
||||
handleSectionFieldsModified(fields: TunnelDetailTableViewController.peerFields, fieldIsVisible: peerFieldIsVisible[peerIndex], section: firstPeerSectionIndex + peerIndex, changes: peerChanges)
|
||||
}
|
||||
|
||||
let isAnyInterfaceFieldAddedOrRemoved = changes.interfaceChanges.contains { $0.value == .added || $0.value == .removed }
|
||||
let isAnyPeerFieldAddedOrRemoved = changes.peerChanges.contains { $0.changes.contains { $0.value == .added || $0.value == .removed } }
|
||||
let peersRemovedSectionIndices = changes.peersRemovedIndices.map { firstPeerSectionIndex + $0 }
|
||||
let peersInsertedSectionIndices = changes.peersInsertedIndices.map { firstPeerSectionIndex + $0 }
|
||||
|
||||
if isAnyInterfaceFieldAddedOrRemoved || isAnyPeerFieldAddedOrRemoved || !peersRemovedSectionIndices.isEmpty || !peersInsertedSectionIndices.isEmpty {
|
||||
tableView.beginUpdates()
|
||||
if isAnyInterfaceFieldAddedOrRemoved {
|
||||
handleSectionRowsInsertedOrRemoved(fields: TunnelDetailTableViewController.interfaceFields, fieldIsVisible: interfaceFieldIsVisible, section: interfaceSectionIndex, changes: changes.interfaceChanges)
|
||||
}
|
||||
if isAnyPeerFieldAddedOrRemoved {
|
||||
for (peerIndex, peerChanges) in changes.peerChanges {
|
||||
handleSectionRowsInsertedOrRemoved(fields: TunnelDetailTableViewController.peerFields, fieldIsVisible: peerFieldIsVisible[peerIndex], section: firstPeerSectionIndex + peerIndex, changes: peerChanges)
|
||||
}
|
||||
}
|
||||
if !peersRemovedSectionIndices.isEmpty {
|
||||
tableView.deleteSections(IndexSet(peersRemovedSectionIndices), with: .automatic)
|
||||
}
|
||||
if !peersInsertedSectionIndices.isEmpty {
|
||||
tableView.insertSections(IndexSet(peersInsertedSectionIndices), with: .automatic)
|
||||
}
|
||||
self.loadSections()
|
||||
self.loadVisibleFields()
|
||||
tableView.endUpdates()
|
||||
} else {
|
||||
self.loadSections()
|
||||
self.loadVisibleFields()
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadRuntimeConfiguration() {
|
||||
tunnel.getRuntimeTunnelConfiguration { [weak self] tunnelConfiguration in
|
||||
guard let tunnelConfiguration = tunnelConfiguration else { return }
|
||||
guard let self = self else { return }
|
||||
self.applyTunnelConfiguration(tunnelConfiguration: tunnelConfiguration)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateActivateOnDemandFields() {
|
||||
guard let onDemandSection = sections.firstIndex(where: { if case .onDemand = $0 { return true } else { return false } }) else { return }
|
||||
let numberOfTableViewOnDemandRows = tableView.numberOfRows(inSection: onDemandSection)
|
||||
let ssidRowIndexPath = IndexPath(row: 1, section: onDemandSection)
|
||||
switch (numberOfTableViewOnDemandRows, onDemandViewModel.isWiFiInterfaceEnabled) {
|
||||
case (1, true):
|
||||
tableView.insertRows(at: [ssidRowIndexPath], with: .automatic)
|
||||
case (2, false):
|
||||
tableView.deleteRows(at: [ssidRowIndexPath], with: .automatic)
|
||||
default:
|
||||
break
|
||||
}
|
||||
tableView.reloadSections(IndexSet(integer: onDemandSection), with: .automatic)
|
||||
}
|
||||
}
|
||||
|
||||
extension TunnelDetailTableViewController: TunnelEditTableViewControllerDelegate {
|
||||
func tunnelSaved(tunnel: TunnelContainer) {
|
||||
tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration)
|
||||
onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel)
|
||||
loadSections()
|
||||
loadVisibleFields()
|
||||
title = tunnel.name
|
||||
restorationIdentifier = "TunnelDetailVC:\(tunnel.name)"
|
||||
tableView.reloadData()
|
||||
}
|
||||
func tunnelEditingCancelled() {
|
||||
// Nothing to do
|
||||
}
|
||||
}
|
||||
|
||||
extension TunnelDetailTableViewController {
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return sections.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
switch sections[section] {
|
||||
case .status:
|
||||
return 1
|
||||
case .interface:
|
||||
return interfaceFieldIsVisible.filter { $0 }.count
|
||||
case .peer(let peerIndex, _):
|
||||
return peerFieldIsVisible[peerIndex].filter { $0 }.count
|
||||
case .onDemand:
|
||||
return onDemandViewModel.isWiFiInterfaceEnabled ? 2 : 1
|
||||
case .delete:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
switch sections[section] {
|
||||
case .status:
|
||||
return tr("tunnelSectionTitleStatus")
|
||||
case .interface:
|
||||
return tr("tunnelSectionTitleInterface")
|
||||
case .peer:
|
||||
return tr("tunnelSectionTitlePeer")
|
||||
case .onDemand:
|
||||
return tr("tunnelSectionTitleOnDemand")
|
||||
case .delete:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
switch sections[indexPath.section] {
|
||||
case .status:
|
||||
return statusCell(for: tableView, at: indexPath)
|
||||
case .interface:
|
||||
return interfaceCell(for: tableView, at: indexPath)
|
||||
case .peer(let index, let peer):
|
||||
return peerCell(for: tableView, at: indexPath, with: peer, peerIndex: index)
|
||||
case .onDemand:
|
||||
return onDemandCell(for: tableView, at: indexPath)
|
||||
case .delete:
|
||||
return deleteConfigurationCell(for: tableView, at: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
private func statusCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell: SwitchCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
|
||||
func update(cell: SwitchCell?, with tunnel: TunnelContainer) {
|
||||
guard let cell = cell else { return }
|
||||
|
||||
let status = tunnel.status
|
||||
let isOnDemandEngaged = tunnel.isActivateOnDemandEnabled
|
||||
|
||||
let isSwitchOn = (status == .activating || status == .active || isOnDemandEngaged)
|
||||
cell.switchView.setOn(isSwitchOn, animated: true)
|
||||
|
||||
if isOnDemandEngaged && !(status == .activating || status == .active) {
|
||||
cell.switchView.onTintColor = UIColor.systemYellow
|
||||
} else {
|
||||
cell.switchView.onTintColor = UIColor.systemGreen
|
||||
}
|
||||
|
||||
var text: String
|
||||
switch status {
|
||||
case .inactive:
|
||||
text = tr("tunnelStatusInactive")
|
||||
case .activating:
|
||||
text = tr("tunnelStatusActivating")
|
||||
case .active:
|
||||
text = tr("tunnelStatusActive")
|
||||
case .deactivating:
|
||||
text = tr("tunnelStatusDeactivating")
|
||||
case .reasserting:
|
||||
text = tr("tunnelStatusReasserting")
|
||||
case .restarting:
|
||||
text = tr("tunnelStatusRestarting")
|
||||
case .waiting:
|
||||
text = tr("tunnelStatusWaiting")
|
||||
}
|
||||
|
||||
if tunnel.hasOnDemandRules {
|
||||
text += isOnDemandEngaged ? tr("tunnelStatusAddendumOnDemand") : ""
|
||||
cell.switchView.isUserInteractionEnabled = true
|
||||
cell.isEnabled = true
|
||||
} else {
|
||||
cell.switchView.isUserInteractionEnabled = (status == .inactive || status == .active)
|
||||
cell.isEnabled = (status == .inactive || status == .active)
|
||||
}
|
||||
|
||||
if tunnel.hasOnDemandRules && !isOnDemandEngaged && status == .inactive {
|
||||
text = tr("tunnelStatusOnDemandDisabled")
|
||||
}
|
||||
|
||||
cell.textLabel?.text = text
|
||||
}
|
||||
|
||||
update(cell: cell, with: tunnel)
|
||||
cell.statusObservationToken = tunnel.observe(\.status) { [weak cell] tunnel, _ in
|
||||
update(cell: cell, with: tunnel)
|
||||
}
|
||||
cell.isOnDemandEnabledObservationToken = tunnel.observe(\.isActivateOnDemandEnabled) { [weak cell] tunnel, _ in
|
||||
update(cell: cell, with: tunnel)
|
||||
}
|
||||
cell.hasOnDemandRulesObservationToken = tunnel.observe(\.hasOnDemandRules) { [weak cell] tunnel, _ in
|
||||
update(cell: cell, with: tunnel)
|
||||
}
|
||||
|
||||
cell.onSwitchToggled = { [weak self] isOn in
|
||||
guard let self = self else { return }
|
||||
|
||||
if self.tunnel.hasOnDemandRules {
|
||||
self.tunnelsManager.setOnDemandEnabled(isOn, on: self.tunnel) { error in
|
||||
if error == nil && !isOn {
|
||||
self.tunnelsManager.startDeactivation(of: self.tunnel)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if isOn {
|
||||
self.tunnelsManager.startActivation(of: self.tunnel)
|
||||
} else {
|
||||
self.tunnelsManager.startDeactivation(of: self.tunnel)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
private func interfaceCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
|
||||
let visibleInterfaceFields = TunnelDetailTableViewController.interfaceFields.enumerated().filter { interfaceFieldIsVisible[$0.offset] }.map { $0.element }
|
||||
let field = visibleInterfaceFields[indexPath.row]
|
||||
let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
cell.key = field.localizedUIString
|
||||
cell.value = tunnelViewModel.interfaceData[field]
|
||||
return cell
|
||||
}
|
||||
|
||||
private func peerCell(for tableView: UITableView, at indexPath: IndexPath, with peerData: TunnelViewModel.PeerData, peerIndex: Int) -> UITableViewCell {
|
||||
let visiblePeerFields = TunnelDetailTableViewController.peerFields.enumerated().filter { peerFieldIsVisible[peerIndex][$0.offset] }.map { $0.element }
|
||||
let field = visiblePeerFields[indexPath.row]
|
||||
let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
cell.key = field.localizedUIString
|
||||
if field == .persistentKeepAlive {
|
||||
cell.value = tr(format: "tunnelPeerPersistentKeepaliveValue (%@)", peerData[field])
|
||||
} else if field == .preSharedKey {
|
||||
cell.value = tr("tunnelPeerPresharedKeyEnabled")
|
||||
} else {
|
||||
cell.value = peerData[field]
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
private func onDemandCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
|
||||
let field = TunnelDetailTableViewController.onDemandFields[indexPath.row]
|
||||
if field == .onDemand {
|
||||
let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
cell.key = field.localizedUIString
|
||||
cell.value = onDemandViewModel.localizedInterfaceDescription
|
||||
cell.copyableGesture = false
|
||||
return cell
|
||||
} else {
|
||||
assert(field == .ssid)
|
||||
if onDemandViewModel.ssidOption == .anySSID {
|
||||
let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
cell.key = field.localizedUIString
|
||||
cell.value = onDemandViewModel.ssidOption.localizedUIString
|
||||
cell.copyableGesture = false
|
||||
return cell
|
||||
} else {
|
||||
let cell: ChevronCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
cell.message = field.localizedUIString
|
||||
cell.detailMessage = onDemandViewModel.localizedSSIDDescription
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteConfigurationCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
cell.buttonText = tr("deleteTunnelButtonTitle")
|
||||
cell.hasDestructiveAction = true
|
||||
cell.onTapped = { [weak self] in
|
||||
guard let self = self else { return }
|
||||
ConfirmationAlertPresenter.showConfirmationAlert(message: tr("deleteTunnelConfirmationAlertMessage"),
|
||||
buttonTitle: tr("deleteTunnelConfirmationAlertButtonTitle"),
|
||||
from: cell, presentingVC: self) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.tunnelsManager.remove(tunnel: self.tunnel) { error in
|
||||
if error != nil {
|
||||
print("Error removing tunnel: \(String(describing: error))")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension TunnelDetailTableViewController {
|
||||
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
|
||||
if case .onDemand = sections[indexPath.section],
|
||||
case .ssid = TunnelDetailTableViewController.onDemandFields[indexPath.row] {
|
||||
return indexPath
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
if case .onDemand = sections[indexPath.section],
|
||||
case .ssid = TunnelDetailTableViewController.onDemandFields[indexPath.row] {
|
||||
let ssidDetailVC = SSIDOptionDetailTableViewController(title: onDemandViewModel.ssidOption.localizedUIString, ssids: onDemandViewModel.selectedSSIDs)
|
||||
navigationController?.pushViewController(ssidDetailVC, animated: true)
|
||||
}
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
}
|
||||
}
|
@ -1,9 +1,9 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol TunnelEditTableViewControllerDelegate: class {
|
||||
protocol TunnelEditTableViewControllerDelegate: AnyObject {
|
||||
func tunnelSaved(tunnel: TunnelContainer)
|
||||
func tunnelEditingCancelled()
|
||||
}
|
||||
@ -43,16 +43,16 @@ class TunnelEditTableViewController: UITableViewController {
|
||||
.deletePeer
|
||||
]
|
||||
|
||||
let activateOnDemandOptions: [ActivateOnDemandOption] = [
|
||||
.useOnDemandOverWiFiOrCellular,
|
||||
.useOnDemandOverWiFiOnly,
|
||||
.useOnDemandOverCellularOnly
|
||||
let onDemandFields: [ActivateOnDemandViewModel.OnDemandField] = [
|
||||
.nonWiFiInterface,
|
||||
.wiFiInterface,
|
||||
.ssid
|
||||
]
|
||||
|
||||
let tunnelsManager: TunnelsManager
|
||||
let tunnel: TunnelContainer?
|
||||
let tunnelViewModel: TunnelViewModel
|
||||
var activateOnDemandSetting: ActivateOnDemandSetting
|
||||
var onDemandViewModel: ActivateOnDemandViewModel
|
||||
private var sections = [Section]()
|
||||
|
||||
// Use this initializer to edit an existing tunnel.
|
||||
@ -60,7 +60,7 @@ class TunnelEditTableViewController: UITableViewController {
|
||||
self.tunnelsManager = tunnelsManager
|
||||
self.tunnel = tunnel
|
||||
tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration)
|
||||
activateOnDemandSetting = tunnel.activateOnDemandSetting
|
||||
onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel)
|
||||
super.init(style: .grouped)
|
||||
loadSections()
|
||||
}
|
||||
@ -70,7 +70,7 @@ class TunnelEditTableViewController: UITableViewController {
|
||||
self.tunnelsManager = tunnelsManager
|
||||
tunnel = nil
|
||||
tunnelViewModel = TunnelViewModel(tunnelConfiguration: nil)
|
||||
activateOnDemandSetting = ActivateOnDemandSetting.defaultSetting
|
||||
onDemandViewModel = ActivateOnDemandViewModel()
|
||||
super.init(style: .grouped)
|
||||
loadSections()
|
||||
}
|
||||
@ -92,7 +92,7 @@ class TunnelEditTableViewController: UITableViewController {
|
||||
tableView.register(TunnelEditEditableKeyValueCell.self)
|
||||
tableView.register(ButtonCell.self)
|
||||
tableView.register(SwitchCell.self)
|
||||
tableView.register(CheckmarkCell.self)
|
||||
tableView.register(ChevronCell.self)
|
||||
}
|
||||
|
||||
private func loadSections() {
|
||||
@ -113,9 +113,10 @@ class TunnelEditTableViewController: UITableViewController {
|
||||
ErrorPresenter.showErrorAlert(title: alertTitle, message: errorMessage, from: self)
|
||||
tableView.reloadData() // Highlight erroring fields
|
||||
case .saved(let tunnelConfiguration):
|
||||
let onDemandOption = onDemandViewModel.toOnDemandOption()
|
||||
if let tunnel = tunnel {
|
||||
// We're modifying an existing tunnel
|
||||
tunnelsManager.modify(tunnel: tunnel, tunnelConfiguration: tunnelConfiguration, activateOnDemandSetting: activateOnDemandSetting) { [weak self] error in
|
||||
tunnelsManager.modify(tunnel: tunnel, tunnelConfiguration: tunnelConfiguration, onDemandOption: onDemandOption) { [weak self] error in
|
||||
if let error = error {
|
||||
ErrorPresenter.showErrorAlert(error: error, from: self)
|
||||
} else {
|
||||
@ -125,11 +126,11 @@ class TunnelEditTableViewController: UITableViewController {
|
||||
}
|
||||
} else {
|
||||
// We're adding a new tunnel
|
||||
tunnelsManager.add(tunnelConfiguration: tunnelConfiguration, activateOnDemandSetting: activateOnDemandSetting) { [weak self] result in
|
||||
if let error = result.error {
|
||||
tunnelsManager.add(tunnelConfiguration: tunnelConfiguration, onDemandOption: onDemandOption) { [weak self] result in
|
||||
switch result {
|
||||
case .failure(let error):
|
||||
ErrorPresenter.showErrorAlert(error: error, from: self)
|
||||
} else {
|
||||
let tunnel: TunnelContainer = result.value!
|
||||
case .success(let tunnel):
|
||||
self?.dismiss(animated: true, completion: nil)
|
||||
self?.delegate?.tunnelSaved(tunnel: tunnel)
|
||||
}
|
||||
@ -161,10 +162,10 @@ extension TunnelEditTableViewController {
|
||||
case .addPeer:
|
||||
return 1
|
||||
case .onDemand:
|
||||
if activateOnDemandSetting.isActivateOnDemandEnabled {
|
||||
return 4
|
||||
if onDemandViewModel.isWiFiInterfaceEnabled {
|
||||
return 3
|
||||
} else {
|
||||
return 1
|
||||
return 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -213,7 +214,7 @@ extension TunnelEditTableViewController {
|
||||
cell.onTapped = { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.tunnelViewModel.interfaceData[.privateKey] = Curve25519.generatePrivateKey().base64EncodedString()
|
||||
self.tunnelViewModel.interfaceData[.privateKey] = PrivateKey().base64Key
|
||||
if let privateKeyRow = self.interfaceFieldsBySection[indexPath.section].firstIndex(of: .privateKey),
|
||||
let publicKeyRow = self.interfaceFieldsBySection[indexPath.section].firstIndex(of: .publicKey) {
|
||||
let privateKeyIndex = IndexPath(row: privateKeyRow, section: indexPath.section)
|
||||
@ -243,13 +244,15 @@ extension TunnelEditTableViewController {
|
||||
cell.placeholderText = tr("tunnelEditPlaceholderTextStronglyRecommended")
|
||||
cell.keyboardType = .numbersAndPunctuation
|
||||
case .dns:
|
||||
cell.placeholderText = tunnelViewModel.peersData.contains(where: { return $0.shouldStronglyRecommendDNS }) ? tr("tunnelEditPlaceholderTextStronglyRecommended") : tr("tunnelEditPlaceholderTextOptional")
|
||||
cell.placeholderText = tunnelViewModel.peersData.contains(where: { $0.shouldStronglyRecommendDNS }) ? tr("tunnelEditPlaceholderTextStronglyRecommended") : tr("tunnelEditPlaceholderTextOptional")
|
||||
cell.keyboardType = .numbersAndPunctuation
|
||||
case .listenPort, .mtu:
|
||||
cell.placeholderText = tr("tunnelEditPlaceholderTextAutomatic")
|
||||
cell.keyboardType = .numberPad
|
||||
case .publicKey, .generateKeyPair:
|
||||
cell.keyboardType = .default
|
||||
case .status, .toggleStatus:
|
||||
fatalError("Unexpected interface field")
|
||||
}
|
||||
|
||||
cell.isValueValid = (!tunnelViewModel.interfaceData.fieldsWithError.contains(field))
|
||||
@ -259,8 +262,18 @@ extension TunnelEditTableViewController {
|
||||
cell.onValueBeingEdited = { [weak self] value in
|
||||
self?.tunnelViewModel.interfaceData[field] = value
|
||||
}
|
||||
cell.onValueChanged = { [weak self] oldValue, newValue in
|
||||
guard let self = self else { return }
|
||||
let isAllowedIPsChanged = self.tunnelViewModel.updateDNSServersInAllowedIPsIfRequired(oldDNSServers: oldValue, newDNSServers: newValue)
|
||||
if isAllowedIPsChanged {
|
||||
let section = self.sections.firstIndex { if case .peer = $0 { return true } else { return false } }
|
||||
if let section = section, let row = self.peerFields.firstIndex(of: .allowedIPs) {
|
||||
self.tableView.reloadRows(at: [IndexPath(row: row, section: section)], with: .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cell.onValueChanged = { [weak self] value in
|
||||
cell.onValueChanged = { [weak self] _, value in
|
||||
self?.tunnelViewModel.interfaceData[field] = value
|
||||
}
|
||||
}
|
||||
@ -298,10 +311,14 @@ extension TunnelEditTableViewController {
|
||||
cell.hasDestructiveAction = true
|
||||
cell.onTapped = { [weak self, weak peerData] in
|
||||
guard let self = self, let peerData = peerData else { return }
|
||||
self.showConfirmationAlert(message: tr("deletePeerConfirmationAlertMessage"), buttonTitle: tr("deletePeerConfirmationAlertButtonTitle"), from: cell) { [weak self] in
|
||||
ConfirmationAlertPresenter.showConfirmationAlert(message: tr("deletePeerConfirmationAlertMessage"),
|
||||
buttonTitle: tr("deletePeerConfirmationAlertButtonTitle"),
|
||||
from: cell, presentingVC: self) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let removedSectionIndices = self.deletePeer(peer: peerData)
|
||||
let shouldShowExcludePrivateIPs = (self.tunnelViewModel.peersData.count == 1 && self.tunnelViewModel.peersData[0].shouldAllowExcludePrivateIPsControl)
|
||||
|
||||
// swiftlint:disable:next trailing_closure
|
||||
tableView.performBatchUpdates({
|
||||
self.tableView.deleteSections(removedSectionIndices, with: .fade)
|
||||
if shouldShowExcludePrivateIPs {
|
||||
@ -309,7 +326,6 @@ extension TunnelEditTableViewController {
|
||||
let rowIndexPath = IndexPath(row: row, section: self.interfaceFieldsBySection.count /* First peer section */)
|
||||
self.tableView.insertRows(at: [rowIndexPath], with: .fade)
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -351,15 +367,17 @@ extension TunnelEditTableViewController {
|
||||
cell.keyboardType = .numberPad
|
||||
case .excludePrivateIPs, .deletePeer:
|
||||
cell.keyboardType = .default
|
||||
case .rxBytes, .txBytes, .lastHandshakeTime:
|
||||
fatalError()
|
||||
}
|
||||
|
||||
cell.isValueValid = !peerData.fieldsWithError.contains(field)
|
||||
cell.value = peerData[field]
|
||||
|
||||
if field == .allowedIPs {
|
||||
let firstInterfaceSection = sections.firstIndex(where: { $0 == .interface })!
|
||||
let interfaceSubSection = interfaceFieldsBySection.firstIndex(where: { $0.contains(.dns) })!
|
||||
let dnsRow = interfaceFieldsBySection[interfaceSubSection].firstIndex(where: { $0 == .dns })!
|
||||
let firstInterfaceSection = sections.firstIndex { $0 == .interface }!
|
||||
let interfaceSubSection = interfaceFieldsBySection.firstIndex { $0.contains(.dns) }!
|
||||
let dnsRow = interfaceFieldsBySection[interfaceSubSection].firstIndex { $0 == .dns }!
|
||||
|
||||
cell.onValueBeingEdited = { [weak self, weak peerData] value in
|
||||
guard let self = self, let peerData = peerData else { return }
|
||||
@ -377,7 +395,7 @@ extension TunnelEditTableViewController {
|
||||
tableView.reloadRows(at: [IndexPath(row: dnsRow, section: firstInterfaceSection + interfaceSubSection)], with: .none)
|
||||
}
|
||||
} else {
|
||||
cell.onValueChanged = { [weak peerData] value in
|
||||
cell.onValueChanged = { [weak peerData] _, value in
|
||||
peerData?[field] = value
|
||||
}
|
||||
}
|
||||
@ -406,36 +424,29 @@ extension TunnelEditTableViewController {
|
||||
}
|
||||
|
||||
private func onDemandCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
|
||||
if indexPath.row == 0 {
|
||||
let field = onDemandFields[indexPath.row]
|
||||
if indexPath.row < 2 {
|
||||
let cell: SwitchCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
cell.message = tr("tunnelOnDemandKey")
|
||||
cell.isOn = activateOnDemandSetting.isActivateOnDemandEnabled
|
||||
cell.message = field.localizedUIString
|
||||
cell.isOn = onDemandViewModel.isEnabled(field: field)
|
||||
cell.onSwitchToggled = { [weak self] isOn in
|
||||
guard let self = self else { return }
|
||||
guard isOn != self.activateOnDemandSetting.isActivateOnDemandEnabled else { return }
|
||||
|
||||
self.activateOnDemandSetting.isActivateOnDemandEnabled = isOn
|
||||
self.loadSections()
|
||||
|
||||
let section = self.sections.firstIndex(where: { $0 == .onDemand })!
|
||||
let indexPaths = (1 ..< 4).map { IndexPath(row: $0, section: section) }
|
||||
if isOn {
|
||||
if self.activateOnDemandSetting.activateOnDemandOption == .none {
|
||||
self.activateOnDemandSetting.activateOnDemandOption = TunnelViewModel.defaultActivateOnDemandOption()
|
||||
self.onDemandViewModel.setEnabled(field: field, isEnabled: isOn)
|
||||
let section = self.sections.firstIndex { $0 == .onDemand }!
|
||||
let indexPath = IndexPath(row: 2, section: section)
|
||||
if field == .wiFiInterface {
|
||||
if isOn {
|
||||
tableView.insertRows(at: [indexPath], with: .fade)
|
||||
} else {
|
||||
tableView.deleteRows(at: [indexPath], with: .fade)
|
||||
}
|
||||
self.tableView.insertRows(at: indexPaths, with: .fade)
|
||||
} else {
|
||||
self.tableView.deleteRows(at: indexPaths, with: .fade)
|
||||
}
|
||||
}
|
||||
return cell
|
||||
} else {
|
||||
let cell: CheckmarkCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
let rowOption = activateOnDemandOptions[indexPath.row - 1]
|
||||
let selectedOption = activateOnDemandSetting.activateOnDemandOption
|
||||
assert(selectedOption != .none)
|
||||
cell.message = TunnelViewModel.activateOnDemandOptionText(for: rowOption)
|
||||
cell.isChecked = selectedOption == rowOption
|
||||
let cell: ChevronCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
cell.message = field.localizedUIString
|
||||
cell.detailMessage = onDemandViewModel.localizedSSIDDescription
|
||||
return cell
|
||||
}
|
||||
}
|
||||
@ -452,26 +463,11 @@ extension TunnelEditTableViewController {
|
||||
loadSections()
|
||||
return IndexSet(integer: interfaceFieldsBySection.count + peer.index)
|
||||
}
|
||||
|
||||
func showConfirmationAlert(message: String, buttonTitle: String, from sourceView: UIView, onConfirmed: @escaping (() -> Void)) {
|
||||
let destroyAction = UIAlertAction(title: buttonTitle, style: .destructive) { _ in
|
||||
onConfirmed()
|
||||
}
|
||||
let cancelAction = UIAlertAction(title: tr("actionCancel"), style: .cancel)
|
||||
let alert = UIAlertController(title: "", message: message, preferredStyle: .actionSheet)
|
||||
alert.addAction(destroyAction)
|
||||
alert.addAction(cancelAction)
|
||||
|
||||
alert.popoverPresentationController?.sourceView = sourceView
|
||||
alert.popoverPresentationController?.sourceRect = sourceView.bounds
|
||||
|
||||
present(alert, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension TunnelEditTableViewController {
|
||||
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
|
||||
if case .onDemand = sections[indexPath.section], indexPath.row > 0 {
|
||||
if case .onDemand = sections[indexPath.section], indexPath.row == 2 {
|
||||
return indexPath
|
||||
} else {
|
||||
return nil
|
||||
@ -481,16 +477,27 @@ extension TunnelEditTableViewController {
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
switch sections[indexPath.section] {
|
||||
case .onDemand:
|
||||
let option = activateOnDemandOptions[indexPath.row - 1]
|
||||
assert(option != .none)
|
||||
activateOnDemandSetting.activateOnDemandOption = option
|
||||
|
||||
let indexPaths = (1 ..< 4).map { IndexPath(row: $0, section: indexPath.section) }
|
||||
UIView.performWithoutAnimation {
|
||||
tableView.reloadRows(at: indexPaths, with: .none)
|
||||
}
|
||||
assert(indexPath.row == 2)
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
let ssidOptionVC = SSIDOptionEditTableViewController(option: onDemandViewModel.ssidOption, ssids: onDemandViewModel.selectedSSIDs)
|
||||
ssidOptionVC.delegate = self
|
||||
navigationController?.pushViewController(ssidOptionVC, animated: true)
|
||||
default:
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TunnelEditTableViewController: SSIDOptionEditTableViewControllerDelegate {
|
||||
func ssidOptionSaved(option: ActivateOnDemandViewModel.OnDemandSSIDOption, ssids: [String]) {
|
||||
onDemandViewModel.selectedSSIDs = ssids
|
||||
onDemandViewModel.ssidOption = option
|
||||
onDemandViewModel.fixSSIDOption()
|
||||
if let onDemandSection = sections.firstIndex(where: { $0 == .onDemand }) {
|
||||
if let ssidRowIndex = onDemandFields.firstIndex(of: .ssid) {
|
||||
let indexPath = IndexPath(row: ssidRowIndex, section: onDemandSection)
|
||||
tableView.reloadRows(at: [indexPath], with: .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
import MobileCoreServices
|
||||
@ -9,6 +9,12 @@ class TunnelsListTableViewController: UIViewController {
|
||||
|
||||
var tunnelsManager: TunnelsManager?
|
||||
|
||||
enum TableState: Equatable {
|
||||
case normal
|
||||
case rowSwiped
|
||||
case multiSelect(selectionCount: Int)
|
||||
}
|
||||
|
||||
let tableView: UITableView = {
|
||||
let tableView = UITableView(frame: CGRect.zero, style: .plain)
|
||||
tableView.estimatedRowHeight = 60
|
||||
@ -26,14 +32,22 @@ class TunnelsListTableViewController: UIViewController {
|
||||
}()
|
||||
|
||||
let busyIndicator: UIActivityIndicatorView = {
|
||||
let busyIndicator = UIActivityIndicatorView(style: .gray)
|
||||
let busyIndicator: UIActivityIndicatorView
|
||||
busyIndicator = UIActivityIndicatorView(style: .medium)
|
||||
busyIndicator.hidesWhenStopped = true
|
||||
return busyIndicator
|
||||
}()
|
||||
|
||||
var detailDisplayedTunnel: TunnelContainer?
|
||||
var tableState: TableState = .normal {
|
||||
didSet {
|
||||
handleTableStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
view = UIView()
|
||||
view.backgroundColor = .white
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
@ -72,13 +86,39 @@ class TunnelsListTableViewController: UIViewController {
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
title = tr("tunnelsListTitle")
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addButtonTapped(sender:)))
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListSettingsButtonTitle"), style: .plain, target: self, action: #selector(settingsButtonTapped(sender:)))
|
||||
|
||||
tableState = .normal
|
||||
restorationIdentifier = "TunnelsListVC"
|
||||
}
|
||||
|
||||
func handleTableStateChange() {
|
||||
switch tableState {
|
||||
case .normal:
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addButtonTapped(sender:)))
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListSettingsButtonTitle"), style: .plain, target: self, action: #selector(settingsButtonTapped(sender:)))
|
||||
case .rowSwiped:
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneButtonTapped))
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListSelectButtonTitle"), style: .plain, target: self, action: #selector(selectButtonTapped))
|
||||
case .multiSelect(let selectionCount):
|
||||
if selectionCount > 0 {
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonTapped))
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListDeleteButtonTitle"), style: .plain, target: self, action: #selector(deleteButtonTapped(sender:)))
|
||||
} else {
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonTapped))
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListSelectAllButtonTitle"), style: .plain, target: self, action: #selector(selectAllButtonTapped))
|
||||
}
|
||||
}
|
||||
if case .multiSelect(let selectionCount) = tableState, selectionCount > 0 {
|
||||
navigationItem.title = tr(format: "tunnelsListSelectedTitle (%d)", selectionCount)
|
||||
} else {
|
||||
navigationItem.title = tr("tunnelsListTitle")
|
||||
}
|
||||
if case .multiSelect = tableState {
|
||||
tableView.allowsMultipleSelectionDuringEditing = true
|
||||
} else {
|
||||
tableView.allowsMultipleSelectionDuringEditing = false
|
||||
}
|
||||
}
|
||||
|
||||
func setTunnelsManager(tunnelsManager: TunnelsManager) {
|
||||
self.tunnelsManager = tunnelsManager
|
||||
tunnelsManager.tunnelsListDelegate = self
|
||||
@ -139,7 +179,7 @@ class TunnelsListTableViewController: UIViewController {
|
||||
func presentViewControllerForTunnelCreation(tunnelsManager: TunnelsManager) {
|
||||
let editVC = TunnelEditTableViewController(tunnelsManager: tunnelsManager)
|
||||
let editNC = UINavigationController(rootViewController: editVC)
|
||||
editNC.modalPresentationStyle = .formSheet
|
||||
editNC.modalPresentationStyle = .fullScreen
|
||||
present(editNC, animated: true)
|
||||
}
|
||||
|
||||
@ -158,49 +198,84 @@ class TunnelsListTableViewController: UIViewController {
|
||||
present(scanQRCodeNC, animated: true)
|
||||
}
|
||||
|
||||
func importFromFile(url: URL, completionHandler: (() -> Void)?) {
|
||||
@objc func selectButtonTapped() {
|
||||
let shouldCancelSwipe = tableState == .rowSwiped
|
||||
tableState = .multiSelect(selectionCount: 0)
|
||||
if shouldCancelSwipe {
|
||||
tableView.setEditing(false, animated: false)
|
||||
}
|
||||
tableView.setEditing(true, animated: true)
|
||||
}
|
||||
|
||||
@objc func doneButtonTapped() {
|
||||
tableState = .normal
|
||||
tableView.setEditing(false, animated: true)
|
||||
}
|
||||
|
||||
@objc func selectAllButtonTapped() {
|
||||
guard tableView.isEditing else { return }
|
||||
guard let tunnelsManager = tunnelsManager else { return }
|
||||
if url.pathExtension == "zip" {
|
||||
ZipImporter.importConfigFiles(from: url) { [weak self] result in
|
||||
if let error = result.error {
|
||||
for index in 0 ..< tunnelsManager.numberOfTunnels() {
|
||||
tableView.selectRow(at: IndexPath(row: index, section: 0), animated: false, scrollPosition: .none)
|
||||
}
|
||||
tableState = .multiSelect(selectionCount: tableView.indexPathsForSelectedRows?.count ?? 0)
|
||||
}
|
||||
|
||||
@objc func cancelButtonTapped() {
|
||||
tableState = .normal
|
||||
tableView.setEditing(false, animated: true)
|
||||
}
|
||||
|
||||
@objc func deleteButtonTapped(sender: AnyObject?) {
|
||||
guard let sender = sender as? UIBarButtonItem else { return }
|
||||
guard let tunnelsManager = tunnelsManager else { return }
|
||||
|
||||
let selectedTunnelIndices = tableView.indexPathsForSelectedRows?.map { $0.row } ?? []
|
||||
let selectedTunnels = selectedTunnelIndices.compactMap { tunnelIndex in
|
||||
tunnelIndex >= 0 && tunnelIndex < tunnelsManager.numberOfTunnels() ? tunnelsManager.tunnel(at: tunnelIndex) : nil
|
||||
}
|
||||
guard !selectedTunnels.isEmpty else { return }
|
||||
let message = selectedTunnels.count == 1 ?
|
||||
tr(format: "deleteTunnelConfirmationAlertButtonMessage (%d)", selectedTunnels.count) :
|
||||
tr(format: "deleteTunnelsConfirmationAlertButtonMessage (%d)", selectedTunnels.count)
|
||||
let title = tr("deleteTunnelsConfirmationAlertButtonTitle")
|
||||
ConfirmationAlertPresenter.showConfirmationAlert(message: message, buttonTitle: title,
|
||||
from: sender, presentingVC: self) { [weak self] in
|
||||
self?.tunnelsManager?.removeMultiple(tunnels: selectedTunnels) { [weak self] error in
|
||||
guard let self = self else { return }
|
||||
if let error = error {
|
||||
ErrorPresenter.showErrorAlert(error: error, from: self)
|
||||
return
|
||||
}
|
||||
let configs = result.value!
|
||||
tunnelsManager.addMultiple(tunnelConfigurations: configs.compactMap { $0 }) { [weak self] numberSuccessful in
|
||||
if numberSuccessful == configs.count {
|
||||
completionHandler?()
|
||||
return
|
||||
}
|
||||
let title = tr(format: "alertImportedFromZipTitle (%d)", numberSuccessful)
|
||||
let message = tr(format: "alertImportedFromZipMessage (%1$d of %2$d)", numberSuccessful, configs.count)
|
||||
ErrorPresenter.showErrorAlert(title: title, message: message, from: self, onPresented: completionHandler)
|
||||
}
|
||||
}
|
||||
} else /* if (url.pathExtension == "conf") -- we assume everything else is a conf */ {
|
||||
let fileBaseName = url.deletingPathExtension().lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let fileContents = try? String(contentsOf: url),
|
||||
let tunnelConfiguration = try? TunnelConfiguration(fromWgQuickConfig: fileContents, called: fileBaseName) {
|
||||
tunnelsManager.add(tunnelConfiguration: tunnelConfiguration) { [weak self] result in
|
||||
if let error = result.error {
|
||||
ErrorPresenter.showErrorAlert(error: error, from: self, onPresented: completionHandler)
|
||||
} else {
|
||||
completionHandler?()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ErrorPresenter.showErrorAlert(title: tr("alertUnableToImportTitle"), message: tr("alertUnableToImportMessage"),
|
||||
from: self, onPresented: completionHandler)
|
||||
self.tableState = .normal
|
||||
self.tableView.setEditing(false, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func showTunnelDetail(for tunnel: TunnelContainer, animated: Bool) {
|
||||
guard let tunnelsManager = tunnelsManager else { return }
|
||||
guard let splitViewController = splitViewController else { return }
|
||||
guard let navController = navigationController else { return }
|
||||
|
||||
let tunnelDetailVC = TunnelDetailTableViewController(tunnelsManager: tunnelsManager,
|
||||
tunnel: tunnel)
|
||||
let tunnelDetailNC = UINavigationController(rootViewController: tunnelDetailVC)
|
||||
tunnelDetailNC.restorationIdentifier = "DetailNC"
|
||||
if splitViewController.isCollapsed && navController.viewControllers.count > 1 {
|
||||
navController.setViewControllers([self, tunnelDetailNC], animated: animated)
|
||||
} else {
|
||||
splitViewController.showDetailViewController(tunnelDetailNC, sender: self, animated: animated)
|
||||
}
|
||||
detailDisplayedTunnel = tunnel
|
||||
self.presentedViewController?.dismiss(animated: false, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension TunnelsListTableViewController: UIDocumentPickerDelegate {
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
urls.forEach {
|
||||
importFromFile(url: $0, completionHandler: nil)
|
||||
}
|
||||
guard let tunnelsManager = tunnelsManager else { return }
|
||||
TunnelImporter.importFromFile(urls: urls, into: tunnelsManager, sourceVC: self, errorPresenterType: ErrorPresenter.self)
|
||||
}
|
||||
}
|
||||
|
||||
@ -208,9 +283,10 @@ extension TunnelsListTableViewController: QRScanViewControllerDelegate {
|
||||
func addScannedQRCode(tunnelConfiguration: TunnelConfiguration, qrScanViewController: QRScanViewController,
|
||||
completionHandler: (() -> Void)?) {
|
||||
tunnelsManager?.add(tunnelConfiguration: tunnelConfiguration) { result in
|
||||
if let error = result.error {
|
||||
switch result {
|
||||
case .failure(let error):
|
||||
ErrorPresenter.showErrorAlert(error: error, from: qrScanViewController, onDismissal: completionHandler)
|
||||
} else {
|
||||
case .success:
|
||||
completionHandler?()
|
||||
}
|
||||
}
|
||||
@ -233,10 +309,18 @@ extension TunnelsListTableViewController: UITableViewDataSource {
|
||||
cell.tunnel = tunnel
|
||||
cell.onSwitchToggled = { [weak self] isOn in
|
||||
guard let self = self, let tunnelsManager = self.tunnelsManager else { return }
|
||||
if isOn {
|
||||
tunnelsManager.startActivation(of: tunnel)
|
||||
if tunnel.hasOnDemandRules {
|
||||
tunnelsManager.setOnDemandEnabled(isOn, on: tunnel) { error in
|
||||
if error == nil && !isOn {
|
||||
tunnelsManager.startDeactivation(of: tunnel)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tunnelsManager.startDeactivation(of: tunnel)
|
||||
if isOn {
|
||||
tunnelsManager.startActivation(of: tunnel)
|
||||
} else {
|
||||
tunnelsManager.startDeactivation(of: tunnel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -246,13 +330,20 @@ extension TunnelsListTableViewController: UITableViewDataSource {
|
||||
|
||||
extension TunnelsListTableViewController: UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
guard !tableView.isEditing else {
|
||||
tableState = .multiSelect(selectionCount: tableView.indexPathsForSelectedRows?.count ?? 0)
|
||||
return
|
||||
}
|
||||
guard let tunnelsManager = tunnelsManager else { return }
|
||||
let tunnel = tunnelsManager.tunnel(at: indexPath.row)
|
||||
let tunnelDetailVC = TunnelDetailTableViewController(tunnelsManager: tunnelsManager,
|
||||
tunnel: tunnel)
|
||||
let tunnelDetailNC = UINavigationController(rootViewController: tunnelDetailVC)
|
||||
tunnelDetailNC.restorationIdentifier = "DetailNC"
|
||||
showDetailViewController(tunnelDetailNC, sender: self) // Shall get propagated up to the split-vc
|
||||
showTunnelDetail(for: tunnel, animated: true)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
|
||||
guard !tableView.isEditing else {
|
||||
tableState = .multiSelect(selectionCount: tableView.indexPathsForSelectedRows?.count ?? 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView,
|
||||
@ -271,6 +362,18 @@ extension TunnelsListTableViewController: UITableViewDelegate {
|
||||
}
|
||||
return UISwipeActionsConfiguration(actions: [deleteAction])
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
|
||||
if tableState == .normal {
|
||||
tableState = .rowSwiped
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
|
||||
if tableState == .rowSwiped {
|
||||
tableState = .normal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TunnelsListTableViewController: TunnelsManagerListDelegate {
|
||||
@ -287,8 +390,34 @@ extension TunnelsListTableViewController: TunnelsManagerListDelegate {
|
||||
tableView.moveRow(at: IndexPath(row: oldIndex, section: 0), to: IndexPath(row: newIndex, section: 0))
|
||||
}
|
||||
|
||||
func tunnelRemoved(at index: Int) {
|
||||
func tunnelRemoved(at index: Int, tunnel: TunnelContainer) {
|
||||
tableView.deleteRows(at: [IndexPath(row: index, section: 0)], with: .automatic)
|
||||
centeredAddButton.isHidden = tunnelsManager?.numberOfTunnels() ?? 0 > 0
|
||||
if detailDisplayedTunnel == tunnel, let splitViewController = splitViewController {
|
||||
if splitViewController.isCollapsed != false {
|
||||
(splitViewController.viewControllers[0] as? UINavigationController)?.popToRootViewController(animated: false)
|
||||
} else {
|
||||
let detailVC = UIViewController()
|
||||
detailVC.view.backgroundColor = .systemBackground
|
||||
let detailNC = UINavigationController(rootViewController: detailVC)
|
||||
splitViewController.showDetailViewController(detailNC, sender: self)
|
||||
}
|
||||
detailDisplayedTunnel = nil
|
||||
if let presentedNavController = self.presentedViewController as? UINavigationController, presentedNavController.viewControllers.first is TunnelEditTableViewController {
|
||||
self.presentedViewController?.dismiss(animated: false, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UISplitViewController {
|
||||
func showDetailViewController(_ viewController: UIViewController, sender: Any?, animated: Bool) {
|
||||
if animated {
|
||||
showDetailViewController(viewController, sender: sender)
|
||||
} else {
|
||||
UIView.performWithoutAnimation {
|
||||
showDetailViewController(viewController, sender: sender)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -6,9 +6,11 @@
|
||||
<array>
|
||||
<string>packet-tunnel-provider</string>
|
||||
</array>
|
||||
<key>com.apple.developer.networking.wifi-info</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.$(APP_ID)</string>
|
||||
</array>
|
||||
<string>group.$(APP_ID_IOS)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
236
Sources/WireGuardApp/UI/macOS/AppDelegate.swift
Normal file
@ -0,0 +1,236 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Cocoa
|
||||
import ServiceManagement
|
||||
|
||||
@NSApplicationMain
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
var tunnelsManager: TunnelsManager?
|
||||
var tunnelsTracker: TunnelsTracker?
|
||||
var statusItemController: StatusItemController?
|
||||
|
||||
var manageTunnelsRootVC: ManageTunnelsRootViewController?
|
||||
var manageTunnelsWindowObject: NSWindow?
|
||||
var onAppDeactivation: (() -> Void)?
|
||||
|
||||
func applicationWillFinishLaunching(_ notification: Notification) {
|
||||
// To workaround a possible AppKit bug that causes the main menu to become unresponsive sometimes
|
||||
// (especially when launched through Xcode) if we call setActivationPolicy(.regular) in
|
||||
// in applicationDidFinishLaunching, we set it to .prohibited here.
|
||||
// Setting it to .regular would fix that problem too, but at this point, we don't know
|
||||
// whether the app was launched at login or not, so we're not sure whether we should
|
||||
// show the app icon in the dock or not.
|
||||
NSApp.setActivationPolicy(.prohibited)
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||
Logger.configureGlobal(tagged: "APP", withFilePath: FileManager.logFileURL?.path)
|
||||
registerLoginItem(shouldLaunchAtLogin: true)
|
||||
|
||||
var isLaunchedAtLogin = false
|
||||
if let appleEvent = NSAppleEventManager.shared().currentAppleEvent {
|
||||
isLaunchedAtLogin = LaunchedAtLoginDetector.isLaunchedAtLogin(openAppleEvent: appleEvent)
|
||||
}
|
||||
|
||||
NSApp.mainMenu = MainMenu()
|
||||
setDockIconAndMainMenuVisibility(isVisible: !isLaunchedAtLogin)
|
||||
|
||||
TunnelsManager.create { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
|
||||
switch result {
|
||||
case .failure(let error):
|
||||
ErrorPresenter.showErrorAlert(error: error, from: nil)
|
||||
case .success(let tunnelsManager):
|
||||
let statusMenu = StatusMenu(tunnelsManager: tunnelsManager)
|
||||
statusMenu.windowDelegate = self
|
||||
|
||||
let statusItemController = StatusItemController()
|
||||
statusItemController.statusItem.menu = statusMenu
|
||||
|
||||
let tunnelsTracker = TunnelsTracker(tunnelsManager: tunnelsManager)
|
||||
tunnelsTracker.statusMenu = statusMenu
|
||||
tunnelsTracker.statusItemController = statusItemController
|
||||
|
||||
self.tunnelsManager = tunnelsManager
|
||||
self.tunnelsTracker = tunnelsTracker
|
||||
self.statusItemController = statusItemController
|
||||
|
||||
if !isLaunchedAtLogin {
|
||||
self.showManageTunnelsWindow(completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func confirmAndQuit() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = tr("macConfirmAndQuitAlertMessage")
|
||||
if let currentTunnel = tunnelsTracker?.currentTunnel, currentTunnel.status == .active || currentTunnel.status == .activating {
|
||||
alert.informativeText = tr(format: "macConfirmAndQuitInfoWithActiveTunnel (%@)", currentTunnel.name)
|
||||
} else {
|
||||
alert.informativeText = tr("macConfirmAndQuitAlertInfo")
|
||||
}
|
||||
alert.addButton(withTitle: tr("macConfirmAndQuitAlertCloseWindow"))
|
||||
alert.addButton(withTitle: tr("macConfirmAndQuitAlertQuitWireGuard"))
|
||||
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if let manageWindow = manageTunnelsWindowObject {
|
||||
manageWindow.orderFront(self)
|
||||
alert.beginSheetModal(for: manageWindow) { response in
|
||||
switch response {
|
||||
case .alertFirstButtonReturn:
|
||||
manageWindow.close()
|
||||
case .alertSecondButtonReturn:
|
||||
NSApp.terminate(nil)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func quit() {
|
||||
if let manageWindow = manageTunnelsWindowObject, manageWindow.attachedSheet != nil {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
manageWindow.orderFront(self)
|
||||
return
|
||||
}
|
||||
registerLoginItem(shouldLaunchAtLogin: false)
|
||||
guard let currentTunnel = tunnelsTracker?.currentTunnel, currentTunnel.status == .active || currentTunnel.status == .activating else {
|
||||
NSApp.terminate(nil)
|
||||
return
|
||||
}
|
||||
let alert = NSAlert()
|
||||
alert.messageText = tr("macAppExitingWithActiveTunnelMessage")
|
||||
alert.informativeText = tr("macAppExitingWithActiveTunnelInfo")
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if let manageWindow = manageTunnelsWindowObject {
|
||||
manageWindow.orderFront(self)
|
||||
alert.beginSheetModal(for: manageWindow) { _ in
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
} else {
|
||||
alert.runModal()
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
guard let currentTunnel = tunnelsTracker?.currentTunnel, currentTunnel.status == .active || currentTunnel.status == .activating else {
|
||||
return .terminateNow
|
||||
}
|
||||
guard let appleEvent = NSAppleEventManager.shared().currentAppleEvent else {
|
||||
return .terminateNow
|
||||
}
|
||||
guard MacAppStoreUpdateDetector.isUpdatingFromMacAppStore(quitAppleEvent: appleEvent) else {
|
||||
return .terminateNow
|
||||
}
|
||||
let alert = NSAlert()
|
||||
alert.messageText = tr("macAppStoreUpdatingAlertMessage")
|
||||
if currentTunnel.isActivateOnDemandEnabled {
|
||||
alert.informativeText = tr(format: "macAppStoreUpdatingAlertInfoWithOnDemand (%@)", currentTunnel.name)
|
||||
} else {
|
||||
alert.informativeText = tr(format: "macAppStoreUpdatingAlertInfoWithoutOnDemand (%@)", currentTunnel.name)
|
||||
}
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if let manageWindow = manageTunnelsWindowObject {
|
||||
alert.beginSheetModal(for: manageWindow) { _ in }
|
||||
} else {
|
||||
alert.runModal()
|
||||
}
|
||||
return .terminateCancel
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ application: NSApplication) -> Bool {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak self] in
|
||||
self?.setDockIconAndMainMenuVisibility(isVisible: false)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func setDockIconAndMainMenuVisibility(isVisible: Bool, completion: (() -> Void)? = nil) {
|
||||
let currentActivationPolicy = NSApp.activationPolicy()
|
||||
let newActivationPolicy: NSApplication.ActivationPolicy = isVisible ? .regular : .accessory
|
||||
guard currentActivationPolicy != newActivationPolicy else {
|
||||
if newActivationPolicy == .regular {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
if newActivationPolicy == .regular && NSApp.isActive {
|
||||
// To workaround a possible AppKit bug that causes the main menu to become unresponsive,
|
||||
// we should deactivate the app first and then set the activation policy.
|
||||
// NSApp.deactivate() doesn't always deactivate the app, so we instead use
|
||||
// setActivationPolicy(.prohibited).
|
||||
onAppDeactivation = {
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
completion?()
|
||||
}
|
||||
NSApp.setActivationPolicy(.prohibited)
|
||||
} else {
|
||||
NSApp.setActivationPolicy(newActivationPolicy)
|
||||
if newActivationPolicy == .regular {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
func applicationDidResignActive(_ notification: Notification) {
|
||||
onAppDeactivation?()
|
||||
onAppDeactivation = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension AppDelegate {
|
||||
@objc func aboutClicked() {
|
||||
var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
|
||||
if let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String {
|
||||
appVersion += " (\(appBuild))"
|
||||
}
|
||||
let appVersionString = [
|
||||
tr(format: "macAppVersion (%@)", appVersion),
|
||||
tr(format: "macGoBackendVersion (%@)", WIREGUARD_GO_VERSION)
|
||||
].joined(separator: "\n")
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
NSApp.orderFrontStandardAboutPanel(options: [
|
||||
.applicationVersion: appVersionString,
|
||||
.version: "",
|
||||
.credits: ""
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
extension AppDelegate: StatusMenuWindowDelegate {
|
||||
func showManageTunnelsWindow(completion: ((NSWindow?) -> Void)?) {
|
||||
guard let tunnelsManager = tunnelsManager else {
|
||||
completion?(nil)
|
||||
return
|
||||
}
|
||||
if manageTunnelsWindowObject == nil {
|
||||
manageTunnelsRootVC = ManageTunnelsRootViewController(tunnelsManager: tunnelsManager)
|
||||
let window = NSWindow(contentViewController: manageTunnelsRootVC!)
|
||||
window.title = tr("macWindowTitleManageTunnels")
|
||||
window.setContentSize(NSSize(width: 800, height: 480))
|
||||
window.setFrameAutosaveName(NSWindow.FrameAutosaveName("ManageTunnelsWindow")) // Auto-save window position and size
|
||||
manageTunnelsWindowObject = window
|
||||
tunnelsTracker?.manageTunnelsRootVC = manageTunnelsRootVC
|
||||
}
|
||||
setDockIconAndMainMenuVisibility(isVisible: true) { [weak manageTunnelsWindowObject] in
|
||||
manageTunnelsWindowObject?.makeKeyAndOrderFront(self)
|
||||
completion?(manageTunnelsWindowObject)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func registerLoginItem(shouldLaunchAtLogin: Bool) -> Bool {
|
||||
let appId = Bundle.main.bundleIdentifier!
|
||||
let helperBundleId = "\(appId).login-item-helper"
|
||||
return SMLoginItemSetEnabled(helperBundleId as CFString, shouldLaunchAtLogin)
|
||||
}
|
19
Sources/WireGuardApp/UI/macOS/Application.swift
Normal file
@ -0,0 +1,19 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Cocoa
|
||||
|
||||
class Application: NSApplication {
|
||||
|
||||
private var appDelegate: AppDelegate? // swiftlint:disable:this weak_delegate
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
appDelegate = AppDelegate() // Keep a strong reference to the app delegate
|
||||
delegate = appDelegate // Set delegate before app.run() gets called in NSApplicationMain()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
@ -0,0 +1,68 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "16x16",
|
||||
"idiom" : "mac",
|
||||
"filename" : "WireGuardMacAppIcon16.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "16x16",
|
||||
"idiom" : "mac",
|
||||
"filename" : "WireGuardMacAppIcon32.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "32x32",
|
||||
"idiom" : "mac",
|
||||
"filename" : "WireGuardMacAppIcon32-1.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "32x32",
|
||||
"idiom" : "mac",
|
||||
"filename" : "WireGuardMacAppIcon64.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "128x128",
|
||||
"idiom" : "mac",
|
||||
"filename" : "WireGuardMacAppIcon128.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "128x128",
|
||||
"idiom" : "mac",
|
||||
"filename" : "WireGuardMacAppIcon256.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "256x256",
|
||||
"idiom" : "mac",
|
||||
"filename" : "WireGuardMacAppIcon256-1.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "256x256",
|
||||
"idiom" : "mac",
|
||||
"filename" : "WireGuardMacAppIcon512.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "512x512",
|
||||
"idiom" : "mac",
|
||||
"filename" : "WireGuardMacAppIcon512-1.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "512x512",
|
||||
"idiom" : "mac",
|
||||
"filename" : "WireGuardMacAppIcon.png",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 88 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 22 KiB |
After Width: | Height: | Size: 22 KiB |
After Width: | Height: | Size: 2.3 KiB |
After Width: | Height: | Size: 2.3 KiB |
After Width: | Height: | Size: 49 KiB |