Init commit
@@ -0,0 +1,4 @@
|
||||
/.idea/
|
||||
/Libbox.xcframework/
|
||||
xcuserdata/
|
||||
.DS_Store/
|
||||
@@ -0,0 +1 @@
|
||||
5.7
|
||||
@@ -0,0 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
public class ApplicationLibrary {
|
||||
public static let bundle = Bundle(for: ApplicationLibrary.self)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
},
|
||||
"symbols": [
|
||||
{
|
||||
"filename": "save-save_symbol.svg",
|
||||
"idiom": "universal"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="3300" height="2000">
|
||||
<g id="Notes">
|
||||
<style>
|
||||
text {
|
||||
font-family: monospace;
|
||||
text-anchor: middle;
|
||||
}
|
||||
text.emphasis {
|
||||
font-weight:bold;
|
||||
}
|
||||
text.lanchor {
|
||||
text-anchor: start;
|
||||
}
|
||||
</style>
|
||||
<rect height="2200" id="artboard" fill="white" width="3300" x="0" y="0"/>
|
||||
<line id="" style="fill:none;stroke:black;stroke-width:0.5;" x1="263" x2="3036" y1="292" y2="292"/>
|
||||
<text class="lanchor emphasis" transform="matrix(1 0 0 1 263 322)">Weight/Scale Variations</text>
|
||||
<text transform="matrix(1 0 0 1 559.711 322)">Ultralight</text>
|
||||
<text transform="matrix(1 0 0 1 856.422 322)">Thin</text>
|
||||
<text transform="matrix(1 0 0 1 1153.13 322)">Light</text>
|
||||
<text transform="matrix(1 0 0 1 1449.84 322)">Regular</text>
|
||||
<text transform="matrix(1 0 0 1 1746.56 322)">Medium</text>
|
||||
<text transform="matrix(1 0 0 1 2043.27 322)">Semibold</text>
|
||||
<text transform="matrix(1 0 0 1 2339.98 322)">Bold</text>
|
||||
<text transform="matrix(1 0 0 1 2636.69 322)">Heavy</text>
|
||||
<text transform="matrix(1 0 0 1 2933.4 322)">Black</text>
|
||||
<text id="template-version" style="text-anchor: end;" transform="matrix(1 0 0 1 3036 1933)">Template v.1.0
|
||||
</text>
|
||||
<text class="lanchor" transform="matrix(1 0 0 1 263 726)">Small</text>
|
||||
<text class="lanchor" transform="matrix(1 0 0 1 263 1156)">Medium</text>
|
||||
<text class="lanchor" transform="matrix(1 0 0 1 263 1586)">Large</text>
|
||||
</g>
|
||||
<g id="Guides">
|
||||
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 696)">
|
||||
<path d="M 54.9316 0 L 57.666 0 L 30.5664 -70.459 L 28.0762 -70.459 L 0.976562 0 L 3.66211 0 L 12.9395 -24.4629 L 45.7031 -24.4629 Z M 29.1992 -67.0898 L 29.4434 -67.0898 L 44.8242 -26.709 L 13.8184 -26.709 Z"/>
|
||||
</g>
|
||||
<line id="Baseline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.577;" x1="263" x2="3036" y1="696"
|
||||
y2="696"/>
|
||||
<line id="Capline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.577;" x1="263" x2="3036"
|
||||
y1="625.541" y2="625.541"/>
|
||||
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 1126)">
|
||||
<path d="M 54.9316 0 L 57.666 0 L 30.5664 -70.459 L 28.0762 -70.459 L 0.976562 0 L 3.66211 0 L 12.9395 -24.4629 L 45.7031 -24.4629 Z M 29.1992 -67.0898 L 29.4434 -67.0898 L 44.8242 -26.709 L 13.8184 -26.709 Z"/>
|
||||
</g>
|
||||
<line id="Baseline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.577;" x1="263" x2="3036"
|
||||
y1="1126" y2="1126"/>
|
||||
<line id="Capline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.577;" x1="263" x2="3036"
|
||||
y1="1055.54" y2="1055.54"/>
|
||||
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 1556)">
|
||||
<path d="M 54.9316 0 L 57.666 0 L 30.5664 -70.459 L 28.0762 -70.459 L 0.976562 0 L 3.66211 0 L 12.9395 -24.4629 L 45.7031 -24.4629 Z M 29.1992 -67.0898 L 29.4434 -67.0898 L 44.8242 -26.709 L 13.8184 -26.709 Z"/>
|
||||
</g>
|
||||
<line id="Baseline-L" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.577;" x1="263" x2="3036"
|
||||
y1="1556" y2="1556"/>
|
||||
<line id="Capline-L" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.577;" x1="263" x2="3036"
|
||||
y1="1485.54" y2="1485.54"/>
|
||||
<rect height="119.336" id="left-margin" style="fill:darkred;stroke:none;opacity:0.4;" width="12.4512"
|
||||
x="1394.06" y="1030.79"/>
|
||||
<rect height="119.336" id="right-margin" style="fill:darkgreen;stroke:none;opacity:0.4;" width="12.4512"
|
||||
x="1502.5112" y="1030.79"/>
|
||||
<rect x="1406.5112" y="1045" width="96" height="96" fill="none" stroke="blue" stroke-dasharray="5"/>
|
||||
<rect x="1394.06" y="1030.79" width="120.9024" height="119.336" fill="none" stroke="blue"/>
|
||||
</g>
|
||||
<g id="Symbols">
|
||||
<g id="Bold-L" transform="matrix(1 0 0 1 2266 1556)">
|
||||
<path d="M139.6425 -69.73749999999998 V11.693750000000016 Q139.6425 17.990562500000017 135.16474875 22.420906250000016 Q130.68715875 26.851250000000014 124.32375 26.851250000000014 H30.47625 Q24.1794375 26.851250000000014 19.74909375 22.420906250000016 Q15.31875 17.990562500000017 15.31875 11.693750000000016 V-82.15374999999999 Q15.31875 -88.51715875 19.74909375 -92.99474874999999 Q24.1794375 -97.4725 30.47625 -97.4725 H111.9075 L139.6425 -69.73749999999998 Z M124.32375 -62.96499999999999 105.135 -82.15374999999999 H30.47625 Q30.47625 -82.15374999999999 30.47625 -82.15374999999999 Q30.47625 -82.15374999999999 30.47625 -82.15374999999999 V11.693750000000016 Q30.47625 11.693750000000016 30.47625 11.693750000000016 Q30.47625 11.693750000000016 30.47625 11.693750000000016 H124.32375 Q124.32375 11.693750000000016 124.32375 11.693750000000016 Q124.32375 11.693750000000016 124.32375 11.693750000000016 V-62.96499999999999 Z M77.36210625 1.2125000000000128 Q84.33375000000001 1.2125000000000128 89.251875 -3.6677312499999815 Q94.17 -8.547801249999985 94.17 -15.519606249999981 Q94.17 -22.49124999999998 89.28976875000001 -27.409374999999983 Q84.40969875 -32.327499999999986 77.43789375 -32.327499999999986 Q70.46625 -32.327499999999986 65.548125 -27.447268749999992 Q60.63 -22.56719874999999 60.63 -15.595393749999985 Q60.63 -8.623749999999987 65.51023125 -3.7056249999999835 Q70.39030125000001 1.2125000000000128 77.36210625 1.2125000000000128 Z M39.0225 -50.548749999999984 H96.75 V-73.60749999999999 H39.0225 V-50.548749999999984 Z"/>
|
||||
</g>
|
||||
<g id="Semibold-L" transform="matrix(1 0 0 1 1970.27 1556)">
|
||||
<path d="M137.81973000000002 -68.96640249999999 V12.324721250000017 Q137.81973000000002 17.566475000000015 134.02696875 21.332468750000015 Q130.23404625 25.098623750000016 124.95472125 25.098623750000016 H29.84527875 Q24.603525 25.098623750000016 20.83753125 21.332468750000015 Q17.07137625 17.566475000000015 17.07137625 12.324721250000017 V-82.78472124999999 Q17.07137625 -88.06404624999999 20.83753125 -91.85696875 Q24.603525 -95.64973 29.84527875 -95.64973 H111.1364025 L137.81973000000002 -68.96640249999999 Z M124.95472125 -63.24541375 105.41541375000001 -82.78472124999999 H29.84527875 Q29.84527875 -82.78472124999999 29.84527875 -82.78472124999999 Q29.84527875 -82.78472124999999 29.84527875 -82.78472124999999 V12.324721250000017 Q29.84527875 12.324721250000017 29.84527875 12.324721250000017 Q29.84527875 12.324721250000017 29.84527875 12.324721250000017 H124.95472125 Q124.95472125 12.324721250000017 124.95472125 12.324721250000017 Q124.95472125 12.324721250000017 124.95472125 12.324721250000017 V-63.24541375 Z M77.36210625 1.8434712500000145 Q84.33375000000001 1.8434712500000145 89.251875 -3.0367599999999797 Q94.17 -7.916991249999988 94.17 -14.88863499999998 Q94.17 -21.860278749999978 89.28976875000001 -26.77840374999998 Q84.40969875 -31.696528749999985 77.43789375 -31.696528749999985 Q70.46625 -31.696528749999985 65.548125 -26.81645874999998 Q60.63 -21.936227499999987 60.63 -14.964583749999981 Q60.63 -7.992778749999985 65.51023125 -3.0746537499999818 Q70.39030125000001 1.8434712500000145 77.36210625 1.8434712500000145 Z M38.39152875 -51.179721249999986 H96.11902875 V-74.23847124999999 H38.39152875 V-51.179721249999986 Z"/>
|
||||
</g>
|
||||
<g id="Medium-L" transform="matrix(1 0 0 1 1674.31 1556)">
|
||||
<path d="M136.4526525 -68.38783749999999 V12.797990000000016 Q136.4526525 17.248328750000017 133.17331125 20.516221250000015 Q129.89413125 23.783952500000016 125.42799 23.783952500000016 H29.37201 Q24.92167125 23.783952500000016 21.65377875 20.516221250000015 Q18.3860475 17.248328750000017 18.3860475 12.797990000000016 V-83.25798999999998 Q18.3860475 -87.72413124999997 21.65377875 -91.00331125 Q24.92167125 -94.28265249999998 29.37201 -94.28265249999998 H110.5578375 L136.4526525 -68.38783749999999 Z M125.42799 -63.45568374999999 105.62568375000001 -83.25798999999998 H29.37201 Q29.37201 -83.25798999999998 29.37201 -83.25798999999998 Q29.37201 -83.25798999999998 29.37201 -83.25798999999998 V12.797990000000016 Q29.37201 12.797990000000016 29.37201 12.797990000000016 Q29.37201 12.797990000000016 29.37201 12.797990000000016 H125.42799 Q125.42799 12.797990000000016 125.42799 12.797990000000016 Q125.42799 12.797990000000016 125.42799 12.797990000000016 V-63.45568374999999 Z M77.36210625 2.316740000000017 Q84.33375000000001 2.316740000000017 89.251875 -2.5634912499999842 Q94.17 -7.443722499999986 94.17 -14.415366249999984 Q94.17 -21.387009999999982 89.28976875000001 -26.30513499999998 Q84.40969875 -31.223259999999982 77.43789375 -31.223259999999982 Q70.46625 -31.223259999999982 65.548125 -26.34318999999998 Q60.63 -21.462958749999984 60.63 -14.491314999999979 Q60.63 -7.519509999999983 65.51023125 -2.6013849999999863 Q70.39030125000001 2.316740000000017 77.36210625 2.316740000000017 Z M37.91826 -51.652989999999974 H95.64576000000001 V-74.71173999999998 H37.91826 V-51.652989999999974 Z"/>
|
||||
</g>
|
||||
<g id="Regular-L" transform="matrix(1 0 0 1 1378.56 1556)">
|
||||
<path d="M135.45000000000002 -67.96374999999999 V13.145000000000014 Q135.45000000000002 17.015000000000015 132.5475 19.917500000000015 Q129.645 22.820000000000014 125.775 22.820000000000014 H29.025000000000002 Q25.155 22.820000000000014 22.2525 19.917500000000015 Q19.35 17.015000000000015 19.35 13.145000000000014 V-83.60499999999999 Q19.35 -87.475 22.2525 -90.3775 Q25.155 -93.28 29.025000000000002 -93.28 H110.13375 L135.45000000000002 -67.96374999999999 Z M125.775 -63.609999999999985 105.78 -83.60499999999999 H29.025000000000002 Q29.025000000000002 -83.60499999999999 29.025000000000002 -83.60499999999999 Q29.025000000000002 -83.60499999999999 29.025000000000002 -83.60499999999999 V13.145000000000014 Q29.025000000000002 13.145000000000014 29.025000000000002 13.145000000000014 Q29.025000000000002 13.145000000000014 29.025000000000002 13.145000000000014 H125.775 Q125.775 13.145000000000014 125.775 13.145000000000014 Q125.775 13.145000000000014 125.775 13.145000000000014 V-63.609999999999985 Z M77.36210625 2.6637500000000145 Q84.33375000000001 2.6637500000000145 89.251875 -2.2164812499999798 Q94.17 -7.096551249999983 94.17 -14.068356249999987 Q94.17 -21.039999999999985 89.28976875000001 -25.95812499999998 Q84.40969875 -30.876249999999985 77.43789375 -30.876249999999985 Q70.46625 -30.876249999999985 65.548125 -25.99601874999999 Q60.63 -21.115948749999987 60.63 -14.14414374999999 Q60.63 -7.172499999999985 65.51023125 -2.254374999999982 Q70.39030125000001 2.6637500000000145 77.36210625 2.6637500000000145 Z M37.57125 -51.999999999999986 H95.29875 V-75.05874999999999 H37.57125 V-51.999999999999986 Z"/>
|
||||
</g>
|
||||
<g id="Light-L" transform="matrix(1 0 0 1 1082.77 1556)">
|
||||
<path d="M132.22483875 -66.47525124999999 V10.291842500000016 Q132.22483875 14.085893750000018 129.47036625 16.840366250000017 Q126.71589375 19.594838750000015 122.92184250000001 19.594838750000015 H31.8781575 Q28.084106249999998 19.594838750000015 25.32963375 16.840366250000017 Q22.57516125 14.085893750000018 22.57516125 10.291842500000016 V-80.7518425 Q22.57516125 -84.54589374999999 25.32963375 -87.30036625 Q28.084106249999998 -90.05483874999999 31.8781575 -90.05483874999999 H108.64525125 L132.22483875 -66.47525124999999 Z M124.90666875000001 -63.175753749999984 105.34575375 -82.73666874999999 H31.8781575 Q31.00982625 -82.73666874999999 30.451578750000003 -82.17842124999999 Q29.89333125 -81.62017374999999 29.89333125 -80.7518425 V10.291842500000016 Q29.89333125 11.160173750000016 30.451578750000003 11.718421250000013 Q31.00982625 12.276668750000017 31.8781575 12.276668750000017 H122.92184250000001 Q123.79017375000001 12.276668750000017 124.34842125 11.718421250000013 Q124.90666875000001 11.160173750000016 124.90666875000001 10.291842500000016 V-63.175753749999984 Z M77.36210625 -1.86398874999999 Q83.25353625000001 -1.86398874999999 87.44023125 -6.012789999999981 Q91.6270875 -10.16159124999998 91.6270875 -16.05302124999998 Q91.6270875 -21.944451249999986 87.478125 -26.13130749999999 Q83.32932375 -30.31800249999999 77.43789375 -30.31800249999999 Q71.54646375 -30.31800249999999 67.35976875 -26.169201249999986 Q63.172912499999995 -22.02039999999998 63.172912499999995 -16.12896999999998 Q63.172912499999995 -10.237539999999981 67.321875 -6.050683749999983 Q71.47067625 -1.86398874999999 77.36210625 -1.86398874999999 Z M40.36232625 -51.68991624999998 H93.06592125 V-72.26767374999999 H40.36232625 V-51.68991624999998 Z"/>
|
||||
</g>
|
||||
<g id="Thin-L" transform="matrix(1 0 0 1 787.282 1556)">
|
||||
<path d="M129.0 -64.98691374999999 V7.4393300000000195 Q129.0 11.157271250000015 126.39371625 13.763716250000012 Q123.78727125 16.370000000000015 120.06933000000001 16.370000000000015 H34.730669999999996 Q31.01272875 16.370000000000015 28.406283750000004 13.763716250000012 Q25.8 11.157271250000015 25.8 7.4393300000000195 V-77.89932999999999 Q25.8 -81.61727124999999 28.406283750000004 -84.22371624999998 Q31.01272875 -86.82999999999998 34.730669999999996 -86.82999999999998 H107.15691375 L129.0 -64.98691374999999 Z M124.03849875 -62.74166874999999 104.91166875 -81.86849874999999 H34.730669999999996 Q32.99416875 -81.86849874999999 31.877835 -80.75216499999999 Q30.761501250000002 -79.63583124999998 30.761501250000002 -77.89932999999999 V7.4393300000000195 Q30.761501250000002 9.175831250000016 31.877835 10.292165000000015 Q32.99416875 11.408498750000014 34.730669999999996 11.408498750000014 H120.06933000000001 Q121.80583125 11.408498750000014 122.922165 10.292165000000015 Q124.03849875 9.175831250000016 124.03849875 7.4393300000000195 V-62.74166874999999 Z M77.36210625 -6.391082499999982 Q82.17348375 -6.391082499999982 85.62891 -9.808614999999989 Q89.0844975 -13.226147499999982 89.0844975 -18.037524999999988 Q89.0844975 -22.848902499999994 85.66680375000001 -26.304328749999982 Q82.24927124999999 -29.75991624999999 77.43789375 -29.75991624999999 Q72.62651625 -29.75991624999999 69.17109 -26.342383749999982 Q65.7155025 -22.924689999999984 65.7155025 -18.113473749999983 Q65.7155025 -13.302096249999984 69.13319625 -9.846508749999991 Q72.55072875 -6.391082499999982 77.36210625 -6.391082499999982 Z M43.152918750000005 -51.37983249999999 H90.833415 V-69.47708124999998 H43.152918750000005 V-51.37983249999999 Z"/>
|
||||
</g>
|
||||
<g id="Ultralight-L" transform="matrix(1 0 0 1 491.205 1556)">
|
||||
<path d="M127.065 -64.09374999999999 V5.727500000000013 Q127.065 9.399968750000014 124.547565 11.917403750000016 Q122.02996875 14.435000000000016 118.3575 14.435000000000016 H36.4425 Q32.77003125 14.435000000000016 30.25259625 11.917403750000016 Q27.735 9.399968750000014 27.735 5.727500000000013 V-76.18749999999999 Q27.735 -79.85996874999998 30.25259625 -82.37756499999999 Q32.77003125 -84.89499999999998 36.4425 -84.89499999999998 H106.26375 L127.065 -64.09374999999999 Z M123.5175 -62.48124999999999 104.65125 -81.34749999999998 H36.4425 Q34.185 -81.34749999999998 32.73375 -79.89624999999998 Q31.282500000000002 -78.445 31.282500000000002 -76.18749999999999 V5.727500000000013 Q31.282500000000002 7.985000000000014 32.73375 9.436250000000015 Q34.185 10.887500000000014 36.4425 10.887500000000014 H118.3575 Q120.61500000000001 10.887500000000014 122.06625 9.436250000000015 Q123.5175 7.985000000000014 123.5175 5.727500000000013 V-62.48124999999999 Z M77.36210625 -9.107499999999987 Q81.52525875 -9.107499999999987 84.542085 -12.086271249999989 Q87.55875 -15.06504249999999 87.55875 -19.228356249999983 Q87.55875 -23.391508749999986 84.57997875000001 -26.40833499999998 Q81.6012075 -29.424999999999983 77.43789375 -29.424999999999983 Q73.27474124999999 -29.424999999999983 70.25791500000001 -26.44622874999999 Q67.24125000000001 -23.46745749999998 67.24125000000001 -19.304143749999987 Q67.24125000000001 -15.140991249999985 70.22002125 -12.12416499999999 Q73.19879250000001 -9.107499999999987 77.36210625 -9.107499999999987 Z M44.8275 -51.19374999999998 H89.49375 V-67.80249999999998 H44.8275 V-51.19374999999998 Z"/>
|
||||
</g>
|
||||
<g id="Bold-M" transform="matrix(1 0 0 1 2281.8 1126)">
|
||||
<path d="M109.25 -61.97999999999999 V-1.7299999999999898 Q109.25 4.89500000000001 104.6875 9.45750000000001 Q100.125 14.02000000000001 93.5 14.02000000000001 H26.5 Q19.875 14.02000000000001 15.3125 9.45750000000001 Q10.75 4.89500000000001 10.75 -1.7299999999999898 V-68.72999999999999 Q10.75 -75.35499999999999 15.3125 -79.91749999999999 Q19.875 -84.47999999999999 26.5 -84.47999999999999 H86.75 L109.25 -61.97999999999999 Z M93.5 -55.35499999999999 80.125 -68.72999999999999 H26.5 Q26.5 -68.72999999999999 26.5 -68.72999999999999 Q26.5 -68.72999999999999 26.5 -68.72999999999999 V-1.7299999999999898 Q26.5 -1.7299999999999898 26.5 -1.7299999999999898 Q26.5 -1.7299999999999898 26.5 -1.7299999999999898 H93.5 Q93.5 -1.7299999999999898 93.5 -1.7299999999999898 Q93.5 -1.7299999999999898 93.5 -1.7299999999999898 V-55.35499999999999 Z M60.0 -6.72999999999999 Q66.25 -6.72999999999999 70.625 -11.10499999999999 Q75.0 -15.47999999999999 75.0 -21.72999999999999 Q75.0 -27.97999999999999 70.625 -32.35499999999999 Q66.25 -36.72999999999999 60.0 -36.72999999999999 Q53.75 -36.72999999999999 49.375 -32.35499999999999 Q45.0 -27.97999999999999 45.0 -21.72999999999999 Q45.0 -15.47999999999999 49.375 -11.10499999999999 Q53.75 -6.72999999999999 60.0 -6.72999999999999 Z M31.5 -43.72999999999999 H76.5 V-63.72999999999999 H31.5 V-43.72999999999999 Z"/>
|
||||
</g>
|
||||
<g id="Semibold-M" transform="matrix(1 0 0 1 1985.97 1126)">
|
||||
<path d="M107.40225 -61.21912499999999 V-1.0778749999999881 Q107.40225 4.460250000000009 103.54625 8.31625000000001 Q99.69025 12.17225000000001 94.152125 12.17225000000001 H25.847875 Q20.30975 12.17225000000001 16.45375 8.31625000000001 Q12.59775 4.460250000000009 12.59775 -1.0778749999999881 V-69.38212499999999 Q12.59775 -74.92025 16.45375 -78.77624999999999 Q20.30975 -82.63224999999998 25.847875 -82.63224999999998 H85.989125 L107.40225 -61.21912499999999 Z M94.152125 -55.62674999999999 80.39675 -69.38212499999999 H25.847875 Q25.847875 -69.38212499999999 25.847875 -69.38212499999999 Q25.847875 -69.38212499999999 25.847875 -69.38212499999999 V-1.0778749999999881 Q25.847875 -1.0778749999999881 25.847875 -1.0778749999999881 Q25.847875 -1.0778749999999881 25.847875 -1.0778749999999881 H94.152125 Q94.152125 -1.0778749999999881 94.152125 -1.0778749999999881 Q94.152125 -1.0778749999999881 94.152125 -1.0778749999999881 V-55.62674999999999 Z M60.0 -6.077874999999988 Q66.25 -6.077874999999988 70.625 -10.452874999999992 Q75.0 -14.827874999999992 75.0 -21.07787499999999 Q75.0 -27.32787499999999 70.625 -31.70287499999999 Q66.25 -36.07787499999999 60.0 -36.07787499999999 Q53.75 -36.07787499999999 49.375 -31.70287499999999 Q45.0 -27.32787499999999 45.0 -21.07787499999999 Q45.0 -14.827874999999992 49.375 -10.452874999999992 Q53.75 -6.077874999999988 60.0 -6.077874999999988 Z M30.847875 -44.38212499999999 H75.847875 V-64.38212499999999 H30.847875 V-44.38212499999999 Z"/>
|
||||
</g>
|
||||
<g id="Medium-M" transform="matrix(1 0 0 1 1689.91 1126)">
|
||||
<path d="M106.016375 -60.648499999999984 V-0.5887499999999903 Q106.016375 4.134125000000012 102.69025 7.460250000000009 Q99.364125 10.78637500000001 94.64125 10.78637500000001 H25.35875 Q20.635875 10.78637500000001 17.30975 7.460250000000009 Q13.983625 4.134125000000012 13.983625 -0.5887499999999903 V-69.87124999999999 Q13.983625 -74.59412499999999 17.30975 -77.92025 Q20.635875 -81.24637499999999 25.35875 -81.24637499999999 H85.4185 L106.016375 -60.648499999999984 Z M94.64125 -55.83049999999999 80.6005 -69.87124999999999 H25.35875 Q25.35875 -69.87124999999999 25.35875 -69.87124999999999 Q25.35875 -69.87124999999999 25.35875 -69.87124999999999 V-0.5887499999999903 Q25.35875 -0.5887499999999903 25.35875 -0.5887499999999903 Q25.35875 -0.5887499999999903 25.35875 -0.5887499999999903 H94.64125 Q94.64125 -0.5887499999999903 94.64125 -0.5887499999999903 Q94.64125 -0.5887499999999903 94.64125 -0.5887499999999903 V-55.83049999999999 Z M60.0 -5.58874999999999 Q66.25 -5.58874999999999 70.625 -9.96374999999999 Q75.0 -14.33874999999999 75.0 -20.58874999999999 Q75.0 -26.83874999999999 70.625 -31.21374999999999 Q66.25 -35.58874999999999 60.0 -35.58874999999999 Q53.75 -35.58874999999999 49.375 -31.21374999999999 Q45.0 -26.83874999999999 45.0 -20.58874999999999 Q45.0 -14.33874999999999 49.375 -9.96374999999999 Q53.75 -5.58874999999999 60.0 -5.58874999999999 Z M30.35875 -44.87124999999999 H75.35875 V-64.87124999999999 H30.35875 V-44.87124999999999 Z"/>
|
||||
</g>
|
||||
<g id="Regular-M" transform="matrix(1 0 0 1 1394.06 1126)">
|
||||
<path d="M105.0 -60.22999999999999 V-0.22999999999998977 Q105.0 3.8950000000000102 102.0625 6.83250000000001 Q99.125 9.77000000000001 95.0 9.77000000000001 H25.0 Q20.875 9.77000000000001 17.9375 6.83250000000001 Q15.0 3.8950000000000102 15.0 -0.22999999999998977 V-70.22999999999999 Q15.0 -74.35499999999999 17.9375 -77.29249999999999 Q20.875 -80.22999999999999 25.0 -80.22999999999999 H85.0 L105.0 -60.22999999999999 Z M95.0 -55.97999999999999 80.75 -70.22999999999999 H25.0 Q25.0 -70.22999999999999 25.0 -70.22999999999999 Q25.0 -70.22999999999999 25.0 -70.22999999999999 V-0.22999999999998977 Q25.0 -0.22999999999998977 25.0 -0.22999999999998977 Q25.0 -0.22999999999998977 25.0 -0.22999999999998977 H95.0 Q95.0 -0.22999999999998977 95.0 -0.22999999999998977 Q95.0 -0.22999999999998977 95.0 -0.22999999999998977 V-55.97999999999999 Z M60.0 -5.22999999999999 Q66.25 -5.22999999999999 70.625 -9.60499999999999 Q75.0 -13.97999999999999 75.0 -20.22999999999999 Q75.0 -26.47999999999999 70.625 -30.85499999999999 Q66.25 -35.22999999999999 60.0 -35.22999999999999 Q53.75 -35.22999999999999 49.375 -30.85499999999999 Q45.0 -26.47999999999999 45.0 -20.22999999999999 Q45.0 -13.97999999999999 49.375 -9.60499999999999 Q53.75 -5.22999999999999 60.0 -5.22999999999999 Z M30.0 -45.22999999999999 H75.0 V-65.22999999999999 H30.0 V-45.22999999999999 Z"/>
|
||||
</g>
|
||||
<g id="Light-M" transform="matrix(1 0 0 1 1098.18 1126)">
|
||||
<path d="M102.499875 -59.17224999999999 V-1.7686249999999895 Q102.499875 2.0198750000000096 99.874875 4.64487500000001 Q97.249875 7.26987500000001 93.461375 7.26987500000001 H26.538625 Q22.750125 7.26987500000001 20.125125 4.64487500000001 Q17.500125 2.0198750000000096 17.500125 -1.7686249999999895 V-68.691375 Q17.500125 -72.47987499999999 20.125125 -75.10487499999999 Q22.750125 -77.72987499999999 26.538625 -77.72987499999999 H83.94225 L102.499875 -59.17224999999999 Z M95.0 -55.97999999999999 80.75 -70.22999999999999 H26.538625 Q25.8655 -70.22999999999999 25.43275 -69.79724999999999 Q25.0 -69.36449999999999 25.0 -68.691375 V-1.7686249999999895 Q25.0 -1.0954999999999906 25.43275 -0.6627499999999884 Q25.8655 -0.22999999999998977 26.538625 -0.22999999999998977 H93.461375 Q94.1345 -0.22999999999998977 94.56725 -0.6627499999999884 Q95.0 -1.0954999999999906 95.0 -1.7686249999999895 V-55.97999999999999 Z M60.0 -8.88412499999999 Q65.19225 -8.88412499999999 68.846 -12.537874999999993 Q72.499875 -16.191749999999992 72.499875 -21.38387499999999 Q72.499875 -26.57612499999999 68.846 -30.22999999999999 Q65.19225 -33.88374999999999 60.0 -33.88374999999999 Q54.80775 -33.88374999999999 51.154 -30.22999999999999 Q47.500125 -26.57612499999999 47.500125 -21.38387499999999 Q47.500125 -16.191749999999992 51.154 -12.537874999999993 Q54.80775 -8.88412499999999 60.0 -8.88412499999999 Z M31.92325 -45.80699999999999 H72.980625 V-63.306749999999994 H31.92325 V-45.80699999999999 Z"/>
|
||||
</g>
|
||||
<g id="Thin-M" transform="matrix(1 0 0 1 802.565 1126)">
|
||||
<path d="M100.0 -58.11462499999999 V-3.306874999999991 Q100.0 0.14500000000001023 97.6875 2.4575000000000102 Q95.375 4.77000000000001 91.923125 4.77000000000001 H28.076875 Q24.625 4.77000000000001 22.3125 2.4575000000000102 Q20.0 0.14500000000001023 20.0 -3.306874999999991 V-67.15312499999999 Q20.0 -70.60499999999999 22.3125 -72.91749999999999 Q24.625 -75.22999999999999 28.076875 -75.22999999999999 H82.884625 L100.0 -58.11462499999999 Z M95.0 -55.97999999999999 80.75 -70.22999999999999 H28.076875 Q26.73075 -70.22999999999999 25.865375 -69.36462499999999 Q25.0 -68.49924999999999 25.0 -67.15312499999999 V-3.306874999999991 Q25.0 -1.9607499999999902 25.865375 -1.09537499999999 Q26.73075 -0.22999999999998977 28.076875 -0.22999999999998977 H91.923125 Q93.26925 -0.22999999999998977 94.134625 -1.09537499999999 Q95.0 -1.9607499999999902 95.0 -3.306874999999991 V-55.97999999999999 Z M60.0 -12.537624999999991 Q64.134625 -12.537624999999991 67.067375 -15.47037499999999 Q70.0 -18.40299999999999 70.0 -22.53762499999999 Q70.0 -26.67224999999999 67.067375 -29.60499999999999 Q64.134625 -32.53774999999999 60.0 -32.53774999999999 Q55.865375 -32.53774999999999 52.932625 -29.60499999999999 Q50.0 -26.67224999999999 50.0 -22.53762499999999 Q50.0 -18.40299999999999 52.932625 -15.47037499999999 Q55.865375 -12.537624999999991 60.0 -12.537624999999991 Z M33.846125 -46.38387499999999 H70.961625 V-61.38387499999999 H33.846125 V-46.38387499999999 Z"/>
|
||||
</g>
|
||||
<g id="Ultralight-M" transform="matrix(1 0 0 1 506.415 1126)">
|
||||
<path d="M98.5 -57.47999999999999 V-4.22999999999999 Q98.5 -0.9799999999999898 96.375 1.1450000000000102 Q94.25 3.2700000000000102 91.0 3.2700000000000102 H29.0 Q25.75 3.2700000000000102 23.625 1.1450000000000102 Q21.5 -0.9799999999999898 21.5 -4.22999999999999 V-66.22999999999999 Q21.5 -69.47999999999999 23.625 -71.60499999999999 Q25.75 -73.72999999999999 29.0 -73.72999999999999 H82.25 L98.5 -57.47999999999999 Z M95.0 -55.97999999999999 80.75 -70.22999999999999 H29.0 Q27.25 -70.22999999999999 26.125 -69.10499999999999 Q25.0 -67.97999999999999 25.0 -66.22999999999999 V-4.22999999999999 Q25.0 -2.4799999999999898 26.125 -1.3549999999999898 Q27.25 -0.22999999999998977 29.0 -0.22999999999998977 H91.0 Q92.75 -0.22999999999998977 93.875 -1.3549999999999898 Q95.0 -2.4799999999999898 95.0 -4.22999999999999 V-55.97999999999999 Z M60.0 -14.72999999999999 Q63.5 -14.72999999999999 66.0 -17.22999999999999 Q68.5 -19.72999999999999 68.5 -23.22999999999999 Q68.5 -26.72999999999999 66.0 -29.22999999999999 Q63.5 -31.72999999999999 60.0 -31.72999999999999 Q56.5 -31.72999999999999 54.0 -29.22999999999999 Q51.5 -26.72999999999999 51.5 -23.22999999999999 Q51.5 -19.72999999999999 54.0 -17.22999999999999 Q56.5 -14.72999999999999 60.0 -14.72999999999999 Z M35.0 -46.72999999999999 H69.75 V-60.22999999999999 H35.0 V-46.72999999999999 Z"/>
|
||||
</g>
|
||||
<g id="Bold-S" transform="matrix(1 0 0 1 2294.23 696)">
|
||||
<path d="M83.83125 -55.54674999999999 V-10.376499999999986 Q83.83125 -5.474837499999989 80.46320625 -2.106793749999987 Q77.09516250000001 1.2612500000000129 72.1935 1.2612500000000129 H22.4865 Q17.584837500000003 1.2612500000000129 14.21679375 -2.106793749999987 Q10.84875 -5.474837499999989 10.84875 -10.376499999999986 V-60.08349999999999 Q10.84875 -64.9851625 14.21679375 -68.35320624999999 Q17.584837500000003 -71.72124999999998 22.4865 -71.72124999999998 H67.65675 L83.83125 -55.54674999999999 Z M72.1935 -50.71412499999999 62.824125 -60.08349999999999 H22.4865 Q22.4865 -60.08349999999999 22.4865 -60.08349999999999 Q22.4865 -60.08349999999999 22.4865 -60.08349999999999 V-10.376499999999986 Q22.4865 -10.376499999999986 22.4865 -10.376499999999986 Q22.4865 -10.376499999999986 22.4865 -10.376499999999986 H72.1935 Q72.1935 -10.376499999999986 72.1935 -10.376499999999986 Q72.1935 -10.376499999999986 72.1935 -10.376499999999986 V-50.71412499999999 Z M47.34 -13.926999999999989 Q51.778125 -13.926999999999989 54.8848125 -17.03368749999999 Q57.9915 -20.140374999999985 57.9915 -24.57849999999999 Q57.9915 -29.01662499999999 54.8848125 -32.12331249999999 Q51.778125 -35.22999999999999 47.34 -35.22999999999999 Q42.901875000000004 -35.22999999999999 39.795187500000004 -32.12331249999999 Q36.688500000000005 -29.01662499999999 36.688500000000005 -24.57849999999999 Q36.688500000000005 -20.140374999999985 39.795187500000004 -17.03368749999999 Q42.901875000000004 -13.926999999999989 47.34 -13.926999999999989 Z M27.2205 -41.14749999999999 H60.3585 V-55.34949999999999 H27.2205 V-41.14749999999999 Z"/>
|
||||
</g>
|
||||
<g id="Semibold-S" transform="matrix(1 0 0 1 1998.1 696)">
|
||||
<path d="M82.37337525 -54.94641962499999 V-9.861973374999987 Q82.37337525 -5.817855249999987 79.56276000000001 -3.007239999999987 Q76.75214475 -0.19662474999998736 72.708026625 -0.19662474999998736 H21.971973375 Q17.92785525 -0.19662474999998736 15.11724 -3.007239999999987 Q12.306624750000001 -5.817855249999987 12.306624750000001 -9.861973374999987 V-60.59802662499999 Q12.306624750000001 -64.64214474999999 15.11724 -67.45276 Q17.92785525 -70.26337524999998 21.971973375 -70.26337524999998 H67.056419625 L82.37337525 -54.94641962499999 Z M72.708026625 -50.92853574999999 63.03853575 -60.59802662499999 H21.971973375 Q21.971973375 -60.59802662499999 21.971973375 -60.59802662499999 Q21.971973375 -60.59802662499999 21.971973375 -60.59802662499999 V-9.861973374999987 Q21.971973375 -9.861973374999987 21.971973375 -9.861973374999987 Q21.971973375 -9.861973374999987 21.971973375 -9.861973374999987 H72.708026625 Q72.708026625 -9.861973374999987 72.708026625 -9.861973374999987 Q72.708026625 -9.861973374999987 72.708026625 -9.861973374999987 V-50.92853574999999 Z M47.34 -13.41247337499999 Q51.778125 -13.41247337499999 54.8848125 -16.51916087499999 Q57.9915 -19.62584837499999 57.9915 -24.06397337499999 Q57.9915 -28.50209837499999 54.8848125 -31.608785874999988 Q51.778125 -34.71547337499999 47.34 -34.71547337499999 Q42.901875000000004 -34.71547337499999 39.795187500000004 -31.608785874999988 Q36.688500000000005 -28.50209837499999 36.688500000000005 -24.06397337499999 Q36.688500000000005 -19.62584837499999 39.795187500000004 -16.51916087499999 Q42.901875000000004 -13.41247337499999 47.34 -13.41247337499999 Z M26.705973375000003 -41.66202662499999 H59.843973375000004 V-55.864026624999994 H26.705973375000003 V-41.66202662499999 Z"/>
|
||||
</g>
|
||||
<g id="Medium-S" transform="matrix(1 0 0 1 1701.85 696)">
|
||||
<path d="M81.279919875 -54.49619649999998 V-9.476053749999988 Q81.279919875 -6.075167874999988 78.887376 -3.6826239999999864 Q76.494832125 -1.290080124999987 73.09394625 -1.290080124999987 H21.58605375 Q18.185167875 -1.290080124999987 15.792624 -3.6826239999999864 Q13.400080125 -6.075167874999988 13.400080125 -9.476053749999988 V-60.98394624999999 Q13.400080125 -64.38483212499999 15.792624 -66.77737599999999 Q18.185167875 -69.16991987499999 21.58605375 -69.16991987499999 H66.6061965 L81.279919875 -54.49619649999998 Z M73.09394625 -51.08929449999999 63.1992945 -60.98394624999999 H21.58605375 Q21.58605375 -60.98394624999999 21.58605375 -60.98394624999999 Q21.58605375 -60.98394624999999 21.58605375 -60.98394624999999 V-9.476053749999988 Q21.58605375 -9.476053749999988 21.58605375 -9.476053749999988 Q21.58605375 -9.476053749999988 21.58605375 -9.476053749999988 H73.09394625 Q73.09394625 -9.476053749999988 73.09394625 -9.476053749999988 Q73.09394625 -9.476053749999988 73.09394625 -9.476053749999988 V-51.08929449999999 Z M47.34 -13.026553749999987 Q51.778125 -13.026553749999987 54.8848125 -16.133241249999987 Q57.9915 -19.239928749999986 57.9915 -23.67805374999999 Q57.9915 -28.11617874999999 54.8848125 -31.22286624999999 Q51.778125 -34.32955374999999 47.34 -34.32955374999999 Q42.901875000000004 -34.32955374999999 39.795187500000004 -31.22286624999999 Q36.688500000000005 -28.11617874999999 36.688500000000005 -23.67805374999999 Q36.688500000000005 -19.239928749999986 39.795187500000004 -16.133241249999987 Q42.901875000000004 -13.026553749999987 47.34 -13.026553749999987 Z M26.320053750000003 -42.04794624999999 H59.458053750000005 V-56.249946249999994 H26.320053750000003 V-42.04794624999999 Z"/>
|
||||
</g>
|
||||
<g id="Regular-S" transform="matrix(1 0 0 1 1405.73 696)">
|
||||
<path d="M80.47800000000001 -54.16599999999998 V-9.192999999999987 Q80.47800000000001 -6.263837499999987 78.39208125 -4.177918749999989 Q76.30616250000001 -2.0919999999999863 73.37700000000001 -2.0919999999999863 H21.303 Q18.3738375 -2.0919999999999863 16.287918750000003 -4.177918749999989 Q14.202 -6.263837499999987 14.202 -9.192999999999987 V-61.266999999999996 Q14.202 -64.1961625 16.287918750000003 -66.28208124999999 Q18.3738375 -68.368 21.303 -68.368 H66.276 L80.47800000000001 -54.16599999999998 Z M73.37700000000001 -51.20724999999999 63.31725 -61.266999999999996 H21.303 Q21.303 -61.266999999999996 21.303 -61.266999999999996 Q21.303 -61.266999999999996 21.303 -61.266999999999996 V-9.192999999999987 Q21.303 -9.192999999999987 21.303 -9.192999999999987 Q21.303 -9.192999999999987 21.303 -9.192999999999987 H73.37700000000001 Q73.37700000000001 -9.192999999999987 73.37700000000001 -9.192999999999987 Q73.37700000000001 -9.192999999999987 73.37700000000001 -9.192999999999987 V-51.20724999999999 Z M47.34 -12.743499999999987 Q51.778125 -12.743499999999987 54.8848125 -15.850187499999986 Q57.9915 -18.95687499999999 57.9915 -23.39499999999999 Q57.9915 -27.83312499999999 54.8848125 -30.939812499999988 Q51.778125 -34.04649999999999 47.34 -34.04649999999999 Q42.901875000000004 -34.04649999999999 39.795187500000004 -30.939812499999988 Q36.688500000000005 -27.83312499999999 36.688500000000005 -23.39499999999999 Q36.688500000000005 -18.95687499999999 39.795187500000004 -15.850187499999986 Q42.901875000000004 -12.743499999999987 47.34 -12.743499999999987 Z M26.037000000000003 -42.33099999999999 H59.175000000000004 V-56.53299999999999 H26.037000000000003 V-42.33099999999999 Z"/>
|
||||
</g>
|
||||
<g id="Light-S" transform="matrix(1 0 0 1 1109.75 696)">
|
||||
<path d="M78.505401375 -53.331435249999984 V-10.406975124999988 Q78.505401375 -7.7433111249999875 76.66604512500001 -5.9039548749999895 Q74.826688875 -4.064598624999988 72.163024875 -4.064598624999988 H22.516975125000002 Q19.853311125 -4.064598624999988 18.013954875000003 -5.9039548749999895 Q16.174598625 -7.7433111249999875 16.174598625 -10.406975124999988 V-60.05302487499999 Q16.174598625 -62.71668887499999 18.013954875000003 -64.556045125 Q19.853311125 -66.39540137499999 22.516975125000002 -66.39540137499999 H65.44143525 L78.505401375 -53.331435249999984 Z M73.37700000000001 -51.20724999999999 63.31725 -61.266999999999996 H22.516975125000002 Q21.985879500000003 -61.266999999999996 21.64443975 -60.92556024999999 Q21.303 -60.5841205 21.303 -60.05302487499999 V-10.406975124999988 Q21.303 -9.87587949999999 21.64443975 -9.534439749999986 Q21.985879500000003 -9.192999999999987 22.516975125000002 -9.192999999999987 H72.163024875 Q72.69412050000001 -9.192999999999987 73.03556025 -9.534439749999986 Q73.37700000000001 -9.87587949999999 73.37700000000001 -10.406975124999988 V-51.20724999999999 Z M47.34 -15.626604624999988 Q50.943560250000004 -15.626604624999988 53.481181500000005 -18.16422587499999 Q56.018901375000006 -20.701945749999986 56.018901375000006 -24.305407374999987 Q56.018901375000006 -27.90896762499999 53.481181500000005 -30.44668749999999 Q50.943560250000004 -32.98430874999999 47.34 -32.98430874999999 Q43.73643975 -32.98430874999999 41.1988185 -30.44668749999999 Q38.661098625 -27.90896762499999 38.661098625 -24.305407374999987 Q38.661098625 -20.701945749999986 41.1988185 -18.16422587499999 Q43.73643975 -15.626604624999988 47.34 -15.626604624999988 Z M27.554444250000003 -42.78625299999999 H57.58171312500001 V-55.01555574999999 H27.554444250000003 V-42.78625299999999 Z"/>
|
||||
</g>
|
||||
<g id="Thin-S" transform="matrix(1 0 0 1 814.015 696)">
|
||||
<path d="M76.533 -52.49696912499999 V-11.620654374999987 Q76.533 -9.222587499999989 74.94020625 -7.629793749999987 Q73.3474125 -6.036999999999988 70.949345625 -6.036999999999988 H23.730654375 Q21.332587500000002 -6.036999999999988 19.73979375 -7.629793749999987 Q18.147000000000002 -9.222587499999989 18.147000000000002 -11.620654374999987 V-58.83934562499999 Q18.147000000000002 -61.23741249999999 19.73979375 -62.83020624999999 Q21.332587500000002 -64.42299999999999 23.730654375 -64.42299999999999 H64.606969125 L76.533 -52.49696912499999 Z M73.37700000000001 -51.20724999999999 63.31725 -61.266999999999996 H23.730654375 Q22.668561750000002 -61.266999999999996 21.985780875 -60.58421912499999 Q21.303 -59.901438249999984 21.303 -58.83934562499999 V-11.620654374999987 Q21.303 -10.558561749999988 21.985780875 -9.875780874999986 Q22.668561750000002 -9.192999999999987 23.730654375 -9.192999999999987 H70.949345625 Q72.01143825 -9.192999999999987 72.694219125 -9.875780874999986 Q73.37700000000001 -10.558561749999988 73.37700000000001 -11.620654374999987 V-51.20724999999999 Z M47.34 -18.509216124999988 Q50.109094125 -18.509216124999988 52.077846375 -20.47796837499999 Q54.0465 -22.44662199999999 54.0465 -25.215716124999986 Q54.0465 -27.98481024999999 52.077846375 -29.95356249999999 Q50.109094125 -31.922314749999984 47.34 -31.922314749999984 Q44.570905875 -31.922314749999984 42.602153625 -29.95356249999999 Q40.633500000000005 -27.98481024999999 40.633500000000005 -25.215716124999986 Q40.633500000000005 -22.44662199999999 42.602153625 -20.47796837499999 Q44.570905875 -18.509216124999988 47.34 -18.509216124999988 Z M29.071592625 -43.24140737499999 H55.988722125 V-53.498407374999985 H29.071592625 V-43.24140737499999 Z"/>
|
||||
</g>
|
||||
<g id="Ultralight-S" transform="matrix(1 0 0 1 517.792 696)">
|
||||
<path d="M75.3495 -51.99624999999999 V-12.348999999999986 Q75.3495 -10.110212499999989 73.90464375 -8.665356249999988 Q72.4597875 -7.220499999999987 70.221 -7.220499999999987 H24.459 Q22.220212500000002 -7.220499999999987 20.77535625 -8.665356249999988 Q19.3305 -10.110212499999989 19.3305 -12.348999999999986 V-58.11099999999999 Q19.3305 -60.34978749999999 20.77535625 -61.79464374999999 Q22.220212500000002 -63.23949999999999 24.459 -63.23949999999999 H64.10625 L75.3495 -51.99624999999999 Z M73.37700000000001 -51.20724999999999 63.31725 -61.266999999999996 H24.459 Q23.07825 -61.266999999999996 22.190625 -60.379374999999996 Q21.303 -59.491749999999996 21.303 -58.11099999999999 V-12.348999999999986 Q21.303 -10.968249999999987 22.190625 -10.080624999999987 Q23.07825 -9.192999999999987 24.459 -9.192999999999987 H70.221 Q71.60175000000001 -9.192999999999987 72.48937500000001 -10.080624999999987 Q73.37700000000001 -10.968249999999987 73.37700000000001 -12.348999999999986 V-51.20724999999999 Z M47.34 -20.23899999999999 Q49.608375 -20.23899999999999 51.235687500000004 -21.866312499999985 Q52.863 -23.493624999999987 52.863 -25.761999999999986 Q52.863 -28.030374999999985 51.235687500000004 -29.657687499999987 Q49.608375 -31.28499999999999 47.34 -31.28499999999999 Q45.071625000000004 -31.28499999999999 43.4443125 -29.657687499999987 Q41.817 -28.030374999999985 41.817 -25.761999999999986 Q41.817 -23.493624999999987 43.4443125 -21.866312499999985 Q45.071625000000004 -20.23899999999999 47.34 -20.23899999999999 Z M29.982000000000003 -43.51449999999999 H55.03275 V-52.587999999999994 H29.982000000000003 V-43.51449999999999 Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
import Library
|
||||
|
||||
public enum ProfileUpdateTask {
|
||||
private static var timer: Timer?
|
||||
|
||||
public static func setup() throws {
|
||||
var earliestBeginDate: Date?
|
||||
if let updatedAt = try oldestUpdated() {
|
||||
if updatedAt > Date(timeIntervalSinceNow: -taskInterval) {
|
||||
earliestBeginDate = updatedAt.addingTimeInterval(taskInterval)
|
||||
}
|
||||
}
|
||||
timer = Timer(fire: earliestBeginDate ?? Date.now, interval: taskInterval, repeats: true, block: { _ in
|
||||
do {
|
||||
_ = try updateProfiles()
|
||||
NSLog("profile update task succeed")
|
||||
} catch {
|
||||
NSLog("profile update task failed: \(error.localizedDescription)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static let taskInterval: TimeInterval = 15 * 60
|
||||
|
||||
static func oldestUpdated() throws -> Date? {
|
||||
let profiles = try ProfileManager.listAutoUpdateEnabled()
|
||||
return profiles.map { profile in
|
||||
profile.lastUpdated!
|
||||
}
|
||||
.min()
|
||||
}
|
||||
|
||||
static func updateProfiles() throws -> Bool {
|
||||
let profiles = try ProfileManager.listAutoUpdateEnabled()
|
||||
var success = true
|
||||
for profile in profiles {
|
||||
if profile.lastUpdated! > Date(timeIntervalSinceNow: -taskInterval) {
|
||||
continue
|
||||
}
|
||||
do {
|
||||
try profile.updateRemoteProfile()
|
||||
} catch {
|
||||
NSLog("Update profile \(profile.name) failed: \(error.localizedDescription)")
|
||||
success = false
|
||||
}
|
||||
}
|
||||
return success
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import BackgroundTasks
|
||||
import Foundation
|
||||
import Library
|
||||
|
||||
#if os(iOS)
|
||||
public class UIProfileUpdateTask: BGAppRefreshTask {
|
||||
public static let taskSchedulerPermittedIdentifier = "\(FilePath.packageName).update_profiles"
|
||||
|
||||
public static func setup() async throws {
|
||||
let success = BGTaskScheduler.shared.register(forTaskWithIdentifier: taskSchedulerPermittedIdentifier, using: nil) { task in
|
||||
NSLog("profile update task started")
|
||||
do {
|
||||
let success = try ProfileUpdateTask.updateProfiles()
|
||||
try? scheduleUpdate(Date(timeIntervalSinceNow: ProfileUpdateTask.taskInterval))
|
||||
task.setTaskCompleted(success: success)
|
||||
NSLog("profile update task succeed")
|
||||
} catch {
|
||||
try? scheduleUpdate(nil)
|
||||
task.setTaskCompleted(success: false)
|
||||
NSLog("profile update task failed: \(error.localizedDescription)")
|
||||
}
|
||||
task.expirationHandler = {
|
||||
try? scheduleUpdate(nil)
|
||||
NSLog("profile update task expired")
|
||||
}
|
||||
}
|
||||
if !success {
|
||||
throw NSError(domain: "register failed", code: 0)
|
||||
}
|
||||
if await BGTaskScheduler.shared.pendingTaskRequests().isEmpty {
|
||||
var earliestBeginDate: Date? = nil
|
||||
if let updatedAt = try ProfileUpdateTask.oldestUpdated() {
|
||||
if updatedAt > Date(timeIntervalSinceNow: -ProfileUpdateTask.taskInterval) {
|
||||
earliestBeginDate = updatedAt.addingTimeInterval(ProfileUpdateTask.taskInterval)
|
||||
}
|
||||
}
|
||||
try scheduleUpdate(earliestBeginDate)
|
||||
}
|
||||
}
|
||||
|
||||
private static func scheduleUpdate(_ earliestBeginDate: Date?) throws {
|
||||
let request = BGAppRefreshTaskRequest(identifier: taskSchedulerPermittedIdentifier)
|
||||
request.earliestBeginDate = earliestBeginDate
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
public extension Binding {
|
||||
func unwrapped<T>(_ defaultValue: T) -> Binding<T> where Value == T? {
|
||||
Binding<T>(get: {
|
||||
wrappedValue ?? defaultValue
|
||||
}, set: { newValue in
|
||||
wrappedValue = newValue
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public func FormView(@ViewBuilder content: () -> some View) -> some View {
|
||||
Form {
|
||||
content()
|
||||
}
|
||||
#if os(macOS)
|
||||
.formStyle(.grouped)
|
||||
#endif
|
||||
}
|
||||
|
||||
public func FormTextItem(_ name: String, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(name)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.font(Font.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
public func FormItem(_ title: String, @ViewBuilder content: () -> some View) -> some View {
|
||||
#if os(iOS)
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer()
|
||||
Spacer()
|
||||
content()
|
||||
}
|
||||
#else
|
||||
content()
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public func viewBuilder(@ViewBuilder _ builder: () -> some View) -> some View {
|
||||
builder()
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct ActiveDashboardView: View {
|
||||
public static let NotificationUpdateSelectedProfile = Notification.Name("update-selected-profile")
|
||||
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@Environment(\.selection) private var selection
|
||||
|
||||
@EnvironmentObject private var profile: ExtensionProfile
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var profileList: [Profile] = []
|
||||
@State private var selectedProfileID: Int64!
|
||||
@State private var reasserting = false
|
||||
@State private var observer: Any?
|
||||
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task.detached {
|
||||
await doReload()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if profileList.isEmpty {
|
||||
Text("Empty profiles")
|
||||
} else {
|
||||
#if os(iOS)
|
||||
StartStopButton()
|
||||
#endif
|
||||
if profile.status.isConnected {
|
||||
Section("Status") {
|
||||
ExtensionStatusView()
|
||||
}
|
||||
}
|
||||
Section("Profile") {
|
||||
#if os(iOS)
|
||||
Picker(selection: $selectedProfileID) {
|
||||
ForEach(profileList, id: \.id) { profile in
|
||||
Text(profile.name).tag(profile.id)
|
||||
}
|
||||
} label: {}
|
||||
.pickerStyle(.inline)
|
||||
|
||||
#elseif os(macOS)
|
||||
ForEach(profileList, id: \.id) { profile in
|
||||
Picker(profile.name, selection: $selectedProfileID) {
|
||||
Text("").tag(profile.id)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
#endif
|
||||
}
|
||||
.onChange(of: selectedProfileID) { _ in
|
||||
reasserting = true
|
||||
Task.detached {
|
||||
await switchProfile(selectedProfileID!)
|
||||
}
|
||||
}
|
||||
.disabled(!profile.status.isSwitchable || reasserting)
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.onChange(of: scenePhase, perform: { newValue in
|
||||
if newValue == .active {
|
||||
Task.detached {
|
||||
await doReload()
|
||||
}
|
||||
}
|
||||
})
|
||||
.onChange(of: selection.wrappedValue, perform: { newValue in
|
||||
if newValue == .dashboard {
|
||||
Task.detached {
|
||||
await doReload()
|
||||
}
|
||||
}
|
||||
})
|
||||
#elseif os(macOS)
|
||||
.onAppear {
|
||||
if observer == nil {
|
||||
observer = NotificationCenter.default.addObserver(forName: ActiveDashboardView.NotificationUpdateSelectedProfile, object: nil, queue: nil, using: { _ in
|
||||
Task.detached {
|
||||
await doReload()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if let observer {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func doReload() {
|
||||
defer {
|
||||
isLoading = false
|
||||
}
|
||||
do {
|
||||
profileList = try ProfileManager.list()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
if profileList.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
selectedProfileID = SharedPreferences.selectedProfileID
|
||||
if profileList.filter({ profile in
|
||||
profile.id == selectedProfileID
|
||||
})
|
||||
.isEmpty {
|
||||
selectedProfileID = profileList[0].id!
|
||||
SharedPreferences.selectedProfileID = selectedProfileID
|
||||
}
|
||||
}
|
||||
|
||||
private func switchProfile(_ newProfileID: Int64) {
|
||||
SharedPreferences.selectedProfileID = newProfileID
|
||||
NotificationCenter.default.post(name: ActiveDashboardView.NotificationUpdateSelectedProfile, object: nil)
|
||||
if profile.status.isConnected {
|
||||
do {
|
||||
try LibboxNewStandaloneCommandClient(FilePath.sharedDirectory.relativePath)?.serviceReload()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
}
|
||||
}
|
||||
reasserting = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct DashboardView: View {
|
||||
@Environment(\.extensionProfile) private var extensionProfile
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
FormView {
|
||||
if let profile = extensionProfile.wrappedValue {
|
||||
ActiveDashboardView().environmentObject(profile)
|
||||
} else {
|
||||
InstallProfileButton()
|
||||
}
|
||||
}.navigationTitle("Dashboard")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct ExtensionStatusView: View {
|
||||
@State private var commandClient: LibboxCommandClient?
|
||||
@State private var message: LibboxStatusMessage?
|
||||
@State private var connectTask: Task<Void, Error>?
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
private let infoFont = Font.system(.caption, design: .monospaced)
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if let message {
|
||||
FormTextItem("Memory", LibboxFormatBytes(message.memory))
|
||||
FormTextItem("Goroutines", "\(message.goroutines)")
|
||||
FormTextItem("Connections", "\(message.connections)").contextMenu {
|
||||
Button("Close", role: .destructive) {
|
||||
Task.detached {
|
||||
closeConnections()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FormTextItem("Memory", "Loading...")
|
||||
FormTextItem("Goroutines", "Loading...")
|
||||
FormTextItem("Connections", "Loading...")
|
||||
}
|
||||
}
|
||||
|
||||
.onAppear(perform: doReload)
|
||||
.onDisappear {
|
||||
connectTask?.cancel()
|
||||
if let commandClient {
|
||||
try? commandClient.disconnect()
|
||||
}
|
||||
commandClient = nil
|
||||
}
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func doReload() {
|
||||
connectTask?.cancel()
|
||||
connectTask = Task.detached {
|
||||
await connect()
|
||||
}
|
||||
}
|
||||
|
||||
private func connect() async {
|
||||
let clientOptions = LibboxCommandClientOptions()
|
||||
clientOptions.command = LibboxCommandStatus
|
||||
clientOptions.statusInterval = Int64(2 * NSEC_PER_SEC)
|
||||
let client = LibboxNewCommandClient(FilePath.sharedDirectory.relativePath, statusHandler(self), clientOptions)!
|
||||
|
||||
do {
|
||||
for i in 0 ..< 10 {
|
||||
try await Task.sleep(nanoseconds: UInt64(Double(100 + (i * 50)) * Double(NSEC_PER_MSEC)))
|
||||
try Task.checkCancellation()
|
||||
let isConnected: Bool
|
||||
do {
|
||||
try client.connect()
|
||||
isConnected = true
|
||||
} catch {
|
||||
isConnected = false
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
if isConnected {
|
||||
commandClient = client
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
NSLog("failed to connect status: \(error.localizedDescription)")
|
||||
try? client.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private func closeConnections() {
|
||||
do {
|
||||
try LibboxNewStandaloneCommandClient(FilePath.sharedDirectory.relativePath)?.closeConnections()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
}
|
||||
}
|
||||
|
||||
private class statusHandler: NSObject, LibboxCommandClientHandlerProtocol {
|
||||
private let statusView: ExtensionStatusView
|
||||
|
||||
init(_ statusView: ExtensionStatusView) {
|
||||
self.statusView = statusView
|
||||
}
|
||||
|
||||
func connected() {}
|
||||
|
||||
func disconnected(_: String?) {}
|
||||
|
||||
func writeLog(_: String?) {}
|
||||
|
||||
func writeStatus(_ message: LibboxStatusMessage?) {
|
||||
statusView.message = message
|
||||
}
|
||||
|
||||
func writeGroups(_: LibboxOutboundGroupIteratorProtocol?) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct InstallProfileButton: View {
|
||||
@Environment(\.extensionProfile) private var extensionProfile
|
||||
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
Button("Install NetworkExtension") {
|
||||
Task {
|
||||
await installProfile()
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func installProfile() async {
|
||||
do {
|
||||
try await ExtensionProfile.install()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Library
|
||||
import NetworkExtension
|
||||
import SwiftUI
|
||||
|
||||
public struct StartStopButton: View {
|
||||
@Environment(\.extensionProfile) private var extensionProfile
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if let profile = extensionProfile.wrappedValue {
|
||||
Button0(profile)
|
||||
} else {
|
||||
#if os(iOS)
|
||||
Toggle(isOn: .constant(false)) {
|
||||
Text("Enabled")
|
||||
}
|
||||
#elseif os(macOS)
|
||||
Button(action: {}, label: {
|
||||
Label("Start", systemImage: "play.fill")
|
||||
})
|
||||
.disabled(true)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Button0: View {
|
||||
@Environment(\.logClient) private var logClient
|
||||
@ObservedObject private var profile: ExtensionProfile
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
init(_ profile: ExtensionProfile) {
|
||||
self.profile = profile
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
viewBuilder {
|
||||
#if os(iOS)
|
||||
Toggle(isOn: Binding(get: {
|
||||
profile.status.isConnected
|
||||
}, set: { newValue, _ in
|
||||
Task.detached {
|
||||
await switchProfile(newValue)
|
||||
}
|
||||
})) {
|
||||
Text("Enabled")
|
||||
}
|
||||
#elseif os(macOS)
|
||||
Button(action: {
|
||||
Task.detached {
|
||||
await switchProfile(!profile.status.isConnected)
|
||||
}
|
||||
}, label: {
|
||||
if !profile.status.isConnected {
|
||||
Label("Start", systemImage: "play.fill")
|
||||
} else {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
}
|
||||
})
|
||||
#endif
|
||||
}
|
||||
.disabled(!profile.status.isEnabled)
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func switchProfile(_ isEnabled: Bool) async {
|
||||
do {
|
||||
if isEnabled {
|
||||
try await profile.start()
|
||||
logClient.wrappedValue?.reconnect()
|
||||
} else {
|
||||
profile.stop()
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public extension EnvironmentValues {
|
||||
private struct showMenuBarExtraKey: EnvironmentKey {
|
||||
static let defaultValue: Binding<Bool> = .constant(true)
|
||||
}
|
||||
|
||||
var showMenuBarExtra: Binding<Bool> {
|
||||
get {
|
||||
self[showMenuBarExtraKey.self]
|
||||
}
|
||||
set {
|
||||
self[showMenuBarExtraKey.self] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
private struct selectionKey: EnvironmentKey {
|
||||
static let defaultValue: Binding<NavigationPage> = .constant(.dashboard)
|
||||
}
|
||||
|
||||
var selection: Binding<NavigationPage> {
|
||||
get {
|
||||
self[selectionKey.self]
|
||||
}
|
||||
set {
|
||||
self[selectionKey.self] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
private struct extensionProfileKey: EnvironmentKey {
|
||||
static let defaultValue: Binding<ExtensionProfile?> = .constant(nil)
|
||||
}
|
||||
|
||||
var extensionProfile: Binding<ExtensionProfile?> {
|
||||
get {
|
||||
self[extensionProfileKey.self]
|
||||
}
|
||||
set {
|
||||
self[extensionProfileKey.self] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
private struct logClientKey: EnvironmentKey {
|
||||
static let defaultValue: Binding<LogClient?> = .constant(nil)
|
||||
}
|
||||
|
||||
var logClient: Binding<LogClient?> {
|
||||
get {
|
||||
self[logClientKey.self]
|
||||
}
|
||||
set {
|
||||
self[logClientKey.self] = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct GroupItemView: View {
|
||||
private let _group: Binding<OutboundGroup>
|
||||
private var group: OutboundGroup {
|
||||
_group.wrappedValue
|
||||
}
|
||||
|
||||
private let item: OutboundGroupItem
|
||||
public init(_ group: Binding<OutboundGroup>, _ item: OutboundGroupItem) {
|
||||
_group = group
|
||||
self.item = item
|
||||
}
|
||||
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
public var body: some View {
|
||||
HStack {
|
||||
if group.selected == item.tag {
|
||||
Rectangle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: 6)
|
||||
} else {
|
||||
Rectangle()
|
||||
.fill(.clear)
|
||||
.frame(width: 6)
|
||||
}
|
||||
VStack {
|
||||
HStack {
|
||||
Text(item.tag)
|
||||
.truncationMode(.tail)
|
||||
.lineLimit(1)
|
||||
.font(.system(size: 14))
|
||||
Spacer(minLength: 6)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
HStack(alignment: .center) {
|
||||
Text(item.type)
|
||||
.foregroundColor(.secondary)
|
||||
.font(.system(size: 12))
|
||||
Spacer(minLength: 6)
|
||||
if item.urlTestDelay > 0 {
|
||||
Text(item.delayString)
|
||||
.foregroundColor(item.delayColor)
|
||||
.font(.system(size: 11))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
.padding([.top, .bottom, .trailing], 12)
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.onTapGesture {
|
||||
if group.selectable, group.selected != item.tag {
|
||||
Task.detached {
|
||||
selectOutbound()
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectOutbound() {
|
||||
do {
|
||||
try LibboxNewStandaloneCommandClient(FilePath.sharedDirectory.relativePath)!.selectOutbound(group.tag, outboundTag: item.tag)
|
||||
var newGroup = group
|
||||
newGroup.selected = item.tag
|
||||
_group.wrappedValue = newGroup
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
#if os(iOS)
|
||||
return Color(uiColor: .secondarySystemGroupedBackground)
|
||||
#elseif os(macOS)
|
||||
return Color(nsColor: .textBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct GroupListView: View {
|
||||
@State private var isLoading = true
|
||||
@State private var connectTask: Task<Void, Error>?
|
||||
@State private var commandClient: LibboxCommandClient?
|
||||
@State private var groups: [OutboundGroup] = []
|
||||
@State private var groupExpand: [String: Bool] = [:]
|
||||
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
VStack {
|
||||
if isLoading {
|
||||
Text("Loading...")
|
||||
} else if !groups.isEmpty {
|
||||
ScrollView {
|
||||
VStack {
|
||||
ForEach(groups, id: \.hashValue) { it in
|
||||
GroupView(it, Binding(get: {
|
||||
groupExpand[it.tag] ?? it.selectable
|
||||
}, set: { newValue in
|
||||
groupExpand[it.tag] = newValue
|
||||
}))
|
||||
Spacer()
|
||||
}
|
||||
}.padding()
|
||||
}
|
||||
} else {
|
||||
Text("Empty groups")
|
||||
}
|
||||
}
|
||||
.onAppear(perform: doReload)
|
||||
.onDisappear {
|
||||
connectTask?.cancel()
|
||||
if let commandClient {
|
||||
try? commandClient.disconnect()
|
||||
}
|
||||
commandClient = nil
|
||||
}
|
||||
.navigationTitle("Groups")
|
||||
}
|
||||
|
||||
private func doReload() {
|
||||
connectTask?.cancel()
|
||||
connectTask = Task.detached {
|
||||
await connect()
|
||||
}
|
||||
}
|
||||
|
||||
private func connect() async {
|
||||
let clientOptions = LibboxCommandClientOptions()
|
||||
clientOptions.command = LibboxCommandGroup
|
||||
clientOptions.statusInterval = Int64(2 * NSEC_PER_SEC)
|
||||
let client = LibboxNewCommandClient(FilePath.sharedDirectory.relativePath, groupsHandler(self), clientOptions)!
|
||||
|
||||
do {
|
||||
for i in 0 ..< 10 {
|
||||
try await Task.sleep(nanoseconds: UInt64(Double(100 + (i * 50)) * Double(NSEC_PER_MSEC)))
|
||||
try Task.checkCancellation()
|
||||
let isConnected: Bool
|
||||
do {
|
||||
try client.connect()
|
||||
isConnected = true
|
||||
} catch {
|
||||
isConnected = false
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
if isConnected {
|
||||
commandClient = client
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
NSLog("failed to connect status: \(error.localizedDescription)")
|
||||
try? client.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private func setGroups(_ groupIterator: LibboxOutboundGroupIteratorProtocol) {
|
||||
var goGroups = [LibboxOutboundGroup]()
|
||||
while groupIterator.hasNext() {
|
||||
goGroups.append(groupIterator.next()!)
|
||||
}
|
||||
var groups = [OutboundGroup]()
|
||||
for goGroup in goGroups {
|
||||
var items = [OutboundGroupItem]()
|
||||
let itemIterator = goGroup.getItems()!
|
||||
while itemIterator.hasNext() {
|
||||
let goItem = itemIterator.next()!
|
||||
items.append(OutboundGroupItem(tag: goItem.tag, type: goItem.type, urlTestTime: Date(timeIntervalSince1970: Double(goItem.urlTestTime)), urlTestDelay: UInt16(goItem.urlTestDelay)))
|
||||
}
|
||||
groups.append(OutboundGroup(tag: goGroup.tag, type: goGroup.type, selected: goGroup.selected, selectable: goGroup.selectable, items: items))
|
||||
}
|
||||
self.groups = groups
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private class groupsHandler: NSObject, LibboxCommandClientHandlerProtocol {
|
||||
private let groupListView: GroupListView
|
||||
|
||||
init(_ statusView: GroupListView) {
|
||||
groupListView = statusView
|
||||
}
|
||||
|
||||
func connected() {}
|
||||
|
||||
func disconnected(_: String?) {}
|
||||
|
||||
func writeLog(_: String?) {}
|
||||
|
||||
func writeStatus(_: LibboxStatusMessage?) {}
|
||||
|
||||
func writeGroups(_ groupIterator: LibboxOutboundGroupIteratorProtocol?) {
|
||||
groupListView.setGroups(groupIterator!)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct GroupView: View {
|
||||
private var expland: Binding<Bool>
|
||||
@State private var group: OutboundGroup
|
||||
@State private var geometryWidth: CGFloat = 300
|
||||
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
public init(_ group: OutboundGroup, _ expland: Binding<Bool>) {
|
||||
self.group = group
|
||||
self.expland = expland
|
||||
}
|
||||
|
||||
private var title: some View {
|
||||
HStack {
|
||||
Text(group.tag)
|
||||
.font(.system(size: 17))
|
||||
Text(group.displayType)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(group.items.count)")
|
||||
.font(.system(size: 11))
|
||||
.padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4))
|
||||
.background(Color.gray.opacity(0.5))
|
||||
.cornerRadius(4)
|
||||
Button {
|
||||
expland.wrappedValue = !expland.wrappedValue
|
||||
} label: {
|
||||
if expland.wrappedValue {
|
||||
Image(systemName: "arrow.down.to.line")
|
||||
} else {
|
||||
Image(systemName: "arrow.up.to.line")
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
Button {
|
||||
Task.detached {
|
||||
doURLTest()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "bolt.fill")
|
||||
}
|
||||
#if os(macOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
Spacer(minLength: 6)
|
||||
}
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Section {
|
||||
if expland.wrappedValue {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()),
|
||||
count: explandColumnCount()))
|
||||
{
|
||||
ForEach(group.items, id: \.tag) { it in
|
||||
GroupItemView($group, it)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack {
|
||||
ForEach(Array(itemGroups.enumerated()), id: \.offset) { items in
|
||||
HStack {
|
||||
ForEach(items.element, id: \.tag) { it in
|
||||
Rectangle()
|
||||
.fill(it.delayColor)
|
||||
.frame(width: 10, height: 10)
|
||||
}
|
||||
}.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
title
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.background {
|
||||
GeometryReader { geometry in
|
||||
Rectangle()
|
||||
.fill(.clear)
|
||||
.frame(height: 1)
|
||||
.onChange(of: geometry.size.width) { newValue in
|
||||
geometryWidth = newValue
|
||||
}
|
||||
.onAppear {
|
||||
geometryWidth = geometry.size.width
|
||||
}
|
||||
}.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private var itemGroups: [[OutboundGroupItem]] {
|
||||
let count = Int(Int(geometryWidth) / 20)
|
||||
if count == 0 {
|
||||
return [group.items]
|
||||
} else {
|
||||
return group.items.chunked(
|
||||
into: count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func explandColumnCount() -> Int {
|
||||
let count = Int(Int(geometryWidth) / 180)
|
||||
#if os(iOS)
|
||||
return count < 2 ? 2 : count
|
||||
#else
|
||||
return count < 1 ? 1 : count
|
||||
#endif
|
||||
}
|
||||
|
||||
private func doURLTest() {
|
||||
do {
|
||||
try LibboxNewStandaloneCommandClient(FilePath.sharedDirectory.relativePath)!.urlTest(group.tag)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
func chunked(into size: Int) -> [[Element]] {
|
||||
stride(from: 0, to: count, by: size).map {
|
||||
Array(self[$0 ..< Swift.min($0 + size, count)])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public struct OutboundGroup: Codable {
|
||||
let tag: String
|
||||
let type: String
|
||||
var selected: String
|
||||
let selectable: Bool
|
||||
let items: [OutboundGroupItem]
|
||||
|
||||
var hashValue: Int {
|
||||
var value = tag.hashValue
|
||||
(value, _) = value.addingReportingOverflow(selected.hashValue)
|
||||
for item in items {
|
||||
(value, _) = value.addingReportingOverflow(item.urlTestTime.hashValue)
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
public extension OutboundGroup {
|
||||
var displayType: String {
|
||||
switch type {
|
||||
case "selector":
|
||||
return "Selector"
|
||||
case "urltest":
|
||||
return "URLTest"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public struct OutboundGroupItem: Codable {
|
||||
public let tag: String
|
||||
public let type: String
|
||||
|
||||
public let urlTestTime: Date
|
||||
public let urlTestDelay: UInt16
|
||||
}
|
||||
|
||||
public extension OutboundGroupItem {
|
||||
var delayString: String {
|
||||
"\(urlTestDelay)ms"
|
||||
}
|
||||
|
||||
var delayColor: Color {
|
||||
switch urlTestDelay {
|
||||
case 0:
|
||||
return .gray
|
||||
case ..<800:
|
||||
return .green
|
||||
case 800 ..< 1500:
|
||||
return .yellow
|
||||
default:
|
||||
return .orange
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public class LogClient: ObservableObject {
|
||||
private var maxLines: Int
|
||||
@Published public var isConnected: Bool
|
||||
@Published public var logList: [String]
|
||||
|
||||
private var commandClient: LibboxCommandClient!
|
||||
private var connectTask: Task<Void, Error>?
|
||||
|
||||
public init(_ maxLines: Int) {
|
||||
self.maxLines = maxLines
|
||||
isConnected = false
|
||||
logList = []
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let connectTask {
|
||||
connectTask.cancel()
|
||||
}
|
||||
if let commandClient {
|
||||
try? commandClient.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
public func reconnect() {
|
||||
if isConnected {
|
||||
return
|
||||
}
|
||||
if let connectTask {
|
||||
connectTask.cancel()
|
||||
}
|
||||
connectTask = Task.detached {
|
||||
await self.connect()
|
||||
}
|
||||
}
|
||||
|
||||
private func connect() async {
|
||||
let clientOptions = LibboxCommandClientOptions()
|
||||
clientOptions.command = LibboxCommandLog
|
||||
clientOptions.statusInterval = Int64(2 * NSEC_PER_SEC)
|
||||
let client = LibboxNewCommandClient(FilePath.sharedDirectory.relativePath, logHandler(self), clientOptions)!
|
||||
|
||||
do {
|
||||
for i in 0 ..< 10 {
|
||||
try await Task.sleep(nanoseconds: UInt64(Double(100 + (i * 50)) * Double(NSEC_PER_MSEC)))
|
||||
try Task.checkCancellation()
|
||||
let isConnected: Bool
|
||||
do {
|
||||
try client.connect()
|
||||
isConnected = true
|
||||
} catch {
|
||||
isConnected = false
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
if isConnected {
|
||||
commandClient = client
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try? client.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private class logHandler: NSObject, LibboxCommandClientHandlerProtocol {
|
||||
private let logClient: LogClient
|
||||
|
||||
init(_ logClient: LogClient) {
|
||||
self.logClient = logClient
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func connected() {
|
||||
logClient.logList.removeAll()
|
||||
logClient.isConnected = true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func disconnected(_ message: String?) {
|
||||
if let message {
|
||||
logClient.logList.append("(log client closed) \(message)")
|
||||
} else {
|
||||
logClient.logList.append("(log client closed)")
|
||||
}
|
||||
try? logClient.commandClient?.disconnect()
|
||||
logClient.commandClient = nil
|
||||
logClient.isConnected = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func writeLog(_ message: String?) {
|
||||
guard let message else {
|
||||
return
|
||||
}
|
||||
if logClient.logList.count > logClient.maxLines {
|
||||
logClient.logList.removeFirst()
|
||||
}
|
||||
logClient.logList.append(message)
|
||||
}
|
||||
|
||||
func writeStatus(_: LibboxStatusMessage?) {}
|
||||
func writeGroups(_: LibboxOutboundGroupIteratorProtocol?) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct LogView: View {
|
||||
@Environment(\.logClient) private var logClient
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if let logClient = logClient.wrappedValue {
|
||||
LogView0().environmentObject(logClient)
|
||||
} else {
|
||||
Text("Service not started")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Logs")
|
||||
}
|
||||
|
||||
private struct LogView0: View {
|
||||
@Environment(\.selection) private var selection
|
||||
@Environment(\.extensionProfile) private var extensionProfile
|
||||
@EnvironmentObject private var logClient: LogClient
|
||||
|
||||
private let logFont = Font.system(.caption2, design: .monospaced)
|
||||
|
||||
var body: some View {
|
||||
viewBuilder {
|
||||
if logClient.logList.isEmpty {
|
||||
VStack {
|
||||
if logClient.isConnected {
|
||||
Text("Empty logs")
|
||||
} else {
|
||||
Text("Service not started").onAppear(perform: connectLog)
|
||||
}
|
||||
}.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
} else {
|
||||
ScrollViewReader { reader in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(logClient.logList.enumerated()), id: \.offset) { it in
|
||||
Text(it.element)
|
||||
.font(logFont)
|
||||
Spacer(minLength: 5)
|
||||
}
|
||||
|
||||
.onChange(of: logClient.logList.count) { newCount in
|
||||
withAnimation {
|
||||
reader.scrollTo(newCount - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding()
|
||||
}
|
||||
.onAppear {
|
||||
reader.scrollTo(logClient.logList.count - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func connectLog() {
|
||||
guard let profile = extensionProfile.wrappedValue else {
|
||||
return
|
||||
}
|
||||
if profile.status.isConnected, !logClient.isConnected {
|
||||
logClient.reconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public enum NavigationPage: Int, CaseIterable, Identifiable {
|
||||
public var id: Self {
|
||||
self
|
||||
}
|
||||
|
||||
case dashboard
|
||||
case groups
|
||||
case logs
|
||||
case profiles
|
||||
case settings
|
||||
}
|
||||
|
||||
public extension NavigationPage {
|
||||
var label: some View {
|
||||
Label(title, systemImage: iconImage)
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .dashboard:
|
||||
return NSLocalizedString("Dashboard", comment: "")
|
||||
case .groups:
|
||||
return NSLocalizedString("Groups", comment: "")
|
||||
case .logs:
|
||||
return NSLocalizedString("Logs", comment: "")
|
||||
case .profiles:
|
||||
return NSLocalizedString("Profiles", comment: "")
|
||||
case .settings:
|
||||
return NSLocalizedString("Settings", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
private var iconImage: String {
|
||||
switch self {
|
||||
case .dashboard:
|
||||
return "text.and.command.macwindow"
|
||||
case .groups:
|
||||
return "rectangle.3.group.fill"
|
||||
case .logs:
|
||||
return "doc.text.fill"
|
||||
case .profiles:
|
||||
return "list.bullet.rectangle.fill"
|
||||
case .settings:
|
||||
return "gear.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var contentView: some View {
|
||||
viewBuilder {
|
||||
switch self {
|
||||
case .dashboard:
|
||||
DashboardView()
|
||||
case .groups:
|
||||
GroupListView()
|
||||
case .logs:
|
||||
LogView()
|
||||
case .profiles:
|
||||
ProfileView()
|
||||
case .settings:
|
||||
SettingView()
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.background(Color(uiColor: .systemGroupedBackground))
|
||||
#endif
|
||||
}
|
||||
|
||||
func visible(_ profile: ExtensionProfile?) -> Bool {
|
||||
switch self {
|
||||
case .groups:
|
||||
return profile?.status.isConnectedStrict == true
|
||||
case .profiles, .settings:
|
||||
#if os(iOS)
|
||||
return profile?.status.isConnected != true
|
||||
#else
|
||||
fallthrough
|
||||
#endif
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import Foundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct EditProfileContentView: View {
|
||||
#if os(macOS)
|
||||
public static let windowID = "edit-profile-content"
|
||||
#endif
|
||||
|
||||
public struct Context: Codable, Hashable {
|
||||
public let profileID: Int64
|
||||
public let readOnly: Bool
|
||||
}
|
||||
|
||||
private let profileID: Int64?
|
||||
private let readOnly: Bool
|
||||
|
||||
public init(_ context: Context?) {
|
||||
profileID = context?.profileID
|
||||
readOnly = context?.readOnly == true
|
||||
}
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var profile: Profile!
|
||||
@State private var profileContent: String = ""
|
||||
@State private var isChanged = false
|
||||
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
@State private var fatalError = false
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task.detached {
|
||||
loadContent()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
viewBuilder {
|
||||
if readOnly {
|
||||
TextEditor(text: .constant(profileContent))
|
||||
} else {
|
||||
TextEditor(text: $profileContent)
|
||||
}
|
||||
}
|
||||
.font(Font.system(.caption2, design: .monospaced))
|
||||
.disableAutocorrection(true)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.none)
|
||||
.background(Color(UIColor.secondarySystemGroupedBackground))
|
||||
#elseif os(macOS)
|
||||
.padding()
|
||||
#endif
|
||||
.onChange(of: profileContent) { _ in
|
||||
isChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"), action: {
|
||||
if fatalError {
|
||||
dismiss()
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
.navigationTitle(navigationTitle)
|
||||
#if os(macOS)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigation) {
|
||||
if !readOnly {
|
||||
Button(action: {
|
||||
Task.detached {
|
||||
saveContent()
|
||||
}
|
||||
}, label: {
|
||||
Image("save", label: Text("Save"))
|
||||
})
|
||||
.disabled(!isChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if !readOnly {
|
||||
Button("Save") {
|
||||
Task.detached {
|
||||
saveContent()
|
||||
}
|
||||
}.disabled(!isChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var navigationTitle: String {
|
||||
if readOnly {
|
||||
return "View Content"
|
||||
} else {
|
||||
return "Edit Content"
|
||||
}
|
||||
}
|
||||
|
||||
private func loadContent() {
|
||||
do {
|
||||
try loadContent0()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
fatalError = true
|
||||
errorPresented = true
|
||||
}
|
||||
}
|
||||
|
||||
private func loadContent0() throws {
|
||||
guard let profileID else {
|
||||
throw NSError(domain: "Context destroyed", code: 0)
|
||||
}
|
||||
guard let profile = try ProfileManager.get(profileID) else {
|
||||
throw NSError(domain: "Profile missing", code: 0)
|
||||
}
|
||||
profileContent = try profile.read()
|
||||
self.profile = profile
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func saveContent() {
|
||||
guard let profile else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try profile.write(profileContent)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
isChanged = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct EditProfileView: View {
|
||||
#if os(macOS)
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
#endif
|
||||
|
||||
@EnvironmentObject private var profile: Profile
|
||||
|
||||
@State private var isLoading = false
|
||||
@State private var isChanged = false
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
FormView {
|
||||
FormItem("Name") {
|
||||
TextField("Name", text: $profile.name, prompt: Text("Required"))
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
|
||||
Picker(selection: $profile.type) {
|
||||
Text("Local").tag(ProfileType.local)
|
||||
Text("iCloud").tag(ProfileType.icloud)
|
||||
Text("Remote").tag(ProfileType.remote)
|
||||
} label: {
|
||||
Text("Type")
|
||||
}
|
||||
.disabled(true)
|
||||
if profile.type == .icloud {
|
||||
FormItem("Path") {
|
||||
TextField("Path", text: $profile.path, prompt: Text("Required"))
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
} else if profile.type == .remote {
|
||||
FormItem("URL") {
|
||||
TextField("URL", text: $profile.remoteURL.unwrapped(""), prompt: Text("Required"))
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
Toggle("Auto Update", isOn: $profile.autoUpdate)
|
||||
}
|
||||
if profile.type == .remote {
|
||||
Section("Status") {
|
||||
FormTextItem("Last Updated", profile.lastUpdatedString)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
Section("Action") {
|
||||
if profile.type != .remote {
|
||||
NavigationLink {
|
||||
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: false))
|
||||
} label: {
|
||||
Text("Edit Content").foregroundColor(.accentColor)
|
||||
}
|
||||
} else {
|
||||
NavigationLink {
|
||||
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
|
||||
} label: {
|
||||
Text("View Content").foregroundColor(.accentColor)
|
||||
}
|
||||
Button("Update") {
|
||||
isLoading = true
|
||||
Task.detached {
|
||||
await updateProfile()
|
||||
}
|
||||
}
|
||||
.disabled(isLoading)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.onChange(of: profile.name, perform: { _ in
|
||||
isChanged = true
|
||||
})
|
||||
.onChange(of: profile.remoteURL, perform: { _ in
|
||||
isChanged = true
|
||||
})
|
||||
.onChange(of: profile.autoUpdate, perform: { _ in
|
||||
isChanged = true
|
||||
})
|
||||
.disabled(isLoading)
|
||||
#if os(macOS)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigation) {
|
||||
Button(action: {
|
||||
isLoading = true
|
||||
Task.detached {
|
||||
await saveProfile()
|
||||
}
|
||||
}, label: {
|
||||
Image("save", bundle: ApplicationLibrary.bundle, label: Text("Save"))
|
||||
})
|
||||
.disabled(isLoading || !isChanged)
|
||||
if profile.type != .remote {
|
||||
Button(action: {
|
||||
openWindow(id: EditProfileContentView.windowID, value: EditProfileContentView.Context(profileID: profile.id!, readOnly: false))
|
||||
}, label: {
|
||||
Label("Edit Content", systemImage: "pencil")
|
||||
})
|
||||
.disabled(isLoading)
|
||||
} else {
|
||||
Button(action: {
|
||||
isLoading = true
|
||||
Task.detached {
|
||||
await updateProfile()
|
||||
}
|
||||
}, label: {
|
||||
Label("Update", systemImage: "arrow.clockwise")
|
||||
})
|
||||
.disabled(isLoading)
|
||||
Button(action: {
|
||||
openWindow(id: EditProfileContentView.windowID, value: EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
|
||||
}, label: {
|
||||
Label("View Content", systemImage: "doc.text.fill")
|
||||
})
|
||||
.disabled(isLoading)
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
isLoading = true
|
||||
Task.detached {
|
||||
await saveProfile()
|
||||
}
|
||||
}.disabled(!isChanged)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"))
|
||||
)
|
||||
}
|
||||
.navigationTitle("Edit Profile")
|
||||
}
|
||||
|
||||
private func updateProfile() async {
|
||||
defer {
|
||||
isLoading = false
|
||||
}
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: UInt64(100 * Double(NSEC_PER_MSEC)))
|
||||
try profile.updateRemoteProfile()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
}
|
||||
}
|
||||
|
||||
private func saveProfile() async {
|
||||
do {
|
||||
_ = try ProfileManager.update(profile)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
isChanged = false
|
||||
isLoading = false
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: ProfileView.notificationName, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
#if os(macOS)
|
||||
public struct EditProfileWindowView: View {
|
||||
public static let windowID = "edit-profile"
|
||||
|
||||
private var profileID: Int64?
|
||||
|
||||
public init(_ profileID: Int64?) {
|
||||
self.profileID = profileID
|
||||
}
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var profile: Profile!
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task.detached {
|
||||
await doReload()
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"), action: {
|
||||
dismiss()
|
||||
})
|
||||
)
|
||||
}
|
||||
} else {
|
||||
EditProfileView().environmentObject(profile!)
|
||||
}
|
||||
}
|
||||
.onExitCommand {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private func doReload() async {
|
||||
guard let profileID else {
|
||||
errorMessage = "Context destroyed"
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
do {
|
||||
profile = try ProfileManager.get(profileID)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
if profile == nil {
|
||||
errorMessage = "Profile deleted"
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,223 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct NewProfileView: View {
|
||||
#if os(macOS)
|
||||
public static let windowID = "new-profile"
|
||||
#endif
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var isSaving = false
|
||||
@State private var profileName = ""
|
||||
@State private var profileType = ProfileType.local
|
||||
@State private var fileImport = false
|
||||
@State private var fileURL: URL!
|
||||
@State private var remotePath = ""
|
||||
@State private var pickerPresented = false
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
private let callback: (() -> Void)?
|
||||
public init(_ callback: (() -> Void)? = nil) {
|
||||
self.callback = callback
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
FormView {
|
||||
FormItem("Name") {
|
||||
TextField("Name", text: $profileName, prompt: Text("Required"))
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
Picker(selection: $profileType) {
|
||||
Text("Local").tag(ProfileType.local)
|
||||
Text("iCloud").tag(ProfileType.icloud)
|
||||
Text("Remote").tag(ProfileType.remote)
|
||||
} label: {
|
||||
Text("Type")
|
||||
}
|
||||
if profileType == .local {
|
||||
Picker(selection: $fileImport) {
|
||||
Text("Create New").tag(false)
|
||||
Text("Import").tag(true)
|
||||
} label: {
|
||||
Text("File")
|
||||
}
|
||||
viewBuilder {
|
||||
if fileImport {
|
||||
HStack {
|
||||
Text("File Path")
|
||||
Spacer()
|
||||
Spacer()
|
||||
if let fileURL {
|
||||
Button(fileURL.fileName) {
|
||||
pickerPresented = true
|
||||
}
|
||||
} else {
|
||||
Button("Choose") {
|
||||
pickerPresented = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if profileType == .icloud {
|
||||
FormItem("Path") {
|
||||
TextField("Path", text: $remotePath, prompt: Text("Required"))
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
} else if profileType == .remote {
|
||||
FormItem("URL") {
|
||||
TextField("URL", text: $remotePath, prompt: Text("Required"))
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
if !isSaving {
|
||||
Button("Create") {
|
||||
isSaving = true
|
||||
Task.detached {
|
||||
await createProfile()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("New Profile")
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"))
|
||||
)
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $pickerPresented,
|
||||
allowedContentTypes: [.json],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
do {
|
||||
let urls = try result.get()
|
||||
if !urls.isEmpty {
|
||||
fileURL = urls[0]
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createProfile() async {
|
||||
defer {
|
||||
isSaving = false
|
||||
}
|
||||
if profileName.isEmpty {
|
||||
errorMessage = "Missing profile name"
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
if remotePath.isEmpty {
|
||||
if profileType == .icloud {
|
||||
errorMessage = "Missing path"
|
||||
errorPresented = true
|
||||
return
|
||||
} else if profileType == .remote {
|
||||
errorMessage = "Missing URL"
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
}
|
||||
do {
|
||||
try createProfile0()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
dismiss()
|
||||
if let callback {
|
||||
callback()
|
||||
}
|
||||
#if os(macOS)
|
||||
NotificationCenter.default.post(name: ProfileView.notificationName, object: nil)
|
||||
resetFields()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func resetFields() {
|
||||
profileName = ""
|
||||
profileType = .local
|
||||
fileImport = false
|
||||
fileURL = nil
|
||||
remotePath = ""
|
||||
}
|
||||
|
||||
private func createProfile0() throws {
|
||||
let nextProfileID = try ProfileManager.nextID()
|
||||
|
||||
var savePath = ""
|
||||
var remoteURL: String? = nil
|
||||
|
||||
if profileType == .local {
|
||||
let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true)
|
||||
let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json")
|
||||
if fileImport {
|
||||
guard let fileURL else {
|
||||
errorMessage = "Missing file"
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
if !fileURL.startAccessingSecurityScopedResource() {
|
||||
errorMessage = "Missing access to selected file"
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
defer {
|
||||
fileURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
try String(contentsOf: fileURL).write(to: profileConfig, atomically: true, encoding: .utf8)
|
||||
} else {
|
||||
try "{}".write(to: profileConfig, atomically: true, encoding: .utf8)
|
||||
}
|
||||
savePath = profileConfig.relativePath
|
||||
} else if profileType == .icloud {
|
||||
if !FileManager.default.fileExists(atPath: FilePath.iCloudDirectory.path) {
|
||||
try FileManager.default.createDirectory(at: FilePath.iCloudDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
let saveURL = FilePath.iCloudDirectory.appendingPathComponent(remotePath, isDirectory: false)
|
||||
_ = saveURL.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
saveURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
do {
|
||||
_ = try String(contentsOf: saveURL)
|
||||
} catch {
|
||||
try "{}".write(to: saveURL, atomically: true, encoding: .utf8)
|
||||
}
|
||||
savePath = remotePath
|
||||
} else if profileType == .remote {
|
||||
let remoteContent = try HTTPClient().getString(remotePath)
|
||||
var error: NSError?
|
||||
LibboxCheckConfig(remoteContent, &error)
|
||||
if let error {
|
||||
throw error
|
||||
}
|
||||
let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true)
|
||||
let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json")
|
||||
try remoteContent.write(to: profileConfig, atomically: true, encoding: .utf8)
|
||||
savePath = profileConfig.relativePath
|
||||
remoteURL = remotePath
|
||||
}
|
||||
try ProfileManager.create(Profile(name: profileName, type: profileType, path: savePath, remoteURL: remoteURL))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import Foundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct ProfileView: View {
|
||||
public static let notificationName = Notification.Name("\(FilePath.packageName).update-profile")
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var isUpdating = false
|
||||
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
@State private var profileList: [Profile] = []
|
||||
|
||||
#if os(iOS)
|
||||
@State private var editMode = EditMode.inactive
|
||||
#elseif os(macOS)
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
#endif
|
||||
|
||||
@State private var observer: Any?
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task.detached {
|
||||
doReload()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
#if os(iOS)
|
||||
FormView {
|
||||
NavigationLink {
|
||||
NewProfileView {
|
||||
Task.detached {
|
||||
doReload()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("New Profile").foregroundColor(.accentColor)
|
||||
}
|
||||
.disabled(editMode.isEditing)
|
||||
if profileList.isEmpty {
|
||||
Text("Empty Profiles")
|
||||
} else {
|
||||
List {
|
||||
ForEach(profileList, id: \.mustID) { profile in
|
||||
viewBuilder {
|
||||
if editMode.isEditing == true {
|
||||
Text(profile.name)
|
||||
} else {
|
||||
NavigationLink {
|
||||
EditProfileView().environmentObject(profile)
|
||||
} label: {
|
||||
Text(profile.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onMove(perform: moveProfile)
|
||||
.onDelete(perform: deleteProfile)
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if profileList.isEmpty {
|
||||
Text("Empty Profiles")
|
||||
} else {
|
||||
FormView {
|
||||
List {
|
||||
ForEach(profileList, id: \.mustID) { profile in
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(profile.name)
|
||||
if profile.type == .remote {
|
||||
Spacer(minLength: 4)
|
||||
Text("Last Updated: \(profile.lastUpdatedString)").font(.caption)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
if profile.type == .remote {
|
||||
Button(action: {
|
||||
isUpdating = true
|
||||
Task.detached {
|
||||
updateProfile(profile)
|
||||
}
|
||||
}, label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
})
|
||||
}
|
||||
Button(action: {
|
||||
openWindow(id: EditProfileWindowView.windowID, value: profile.mustID)
|
||||
}, label: {
|
||||
Image(systemName: "pencil")
|
||||
})
|
||||
Button(action: {
|
||||
deleteProfile(profile)
|
||||
}, label: {
|
||||
Image(systemName: "trash.fill")
|
||||
})
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.onMove(perform: moveProfile)
|
||||
.onDelete(perform: deleteProfile)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.disabled(isUpdating)
|
||||
.navigationTitle("Profiles")
|
||||
#if os(macOS)
|
||||
.onAppear {
|
||||
if observer == nil {
|
||||
observer = NotificationCenter.default.addObserver(forName: ProfileView.notificationName, object: nil, queue: .main) { _ in
|
||||
Task.detached {
|
||||
doReload()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if let observer {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
observer = nil
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button(action: {
|
||||
openWindow(id: NewProfileView.windowID)
|
||||
}, label: {
|
||||
Label("New Profile", systemImage: "plus.square.fill")
|
||||
})
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
EditButton().disabled(profileList.isEmpty)
|
||||
}
|
||||
}
|
||||
.environment(\.editMode, $editMode)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func deleteSelectedProfiles(_ profileID: [Int64]) {
|
||||
do {
|
||||
if try ProfileManager.delete(by: profileID) > 0 {
|
||||
isLoading = true
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
}
|
||||
}
|
||||
|
||||
private func doReload() {
|
||||
defer {
|
||||
isLoading = false
|
||||
}
|
||||
do {
|
||||
profileList = try ProfileManager.list()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func updateProfile(_ profile: Profile) {
|
||||
do {
|
||||
_ = try profile.updateRemoteProfile()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
}
|
||||
isUpdating = false
|
||||
}
|
||||
|
||||
private func deleteProfile(_ profile: Profile) {
|
||||
Task.detached {
|
||||
do {
|
||||
_ = try ProfileManager.delete(profile)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
}
|
||||
}
|
||||
|
||||
private func moveProfile(from source: IndexSet, to destination: Int) {
|
||||
profileList.move(fromOffsets: source, toOffset: destination)
|
||||
for (index, profile) in profileList.enumerated() {
|
||||
profile.order = UInt32(index)
|
||||
}
|
||||
do {
|
||||
try ProfileManager.update(profileList)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteProfile(where profileIndex: IndexSet) {
|
||||
let profileToDelete = profileIndex.map { index in
|
||||
profileList[index]
|
||||
}
|
||||
profileList.remove(atOffsets: profileIndex)
|
||||
Task.detached {
|
||||
do {
|
||||
_ = try ProfileManager.delete(profileToDelete)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
public struct ServiceLogView: View {
|
||||
#if os(macOS)
|
||||
public static let windowID = "service-log"
|
||||
#endif
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var content = ""
|
||||
@State private var fileExporterPresented = false
|
||||
private let logFont = Font.system(.caption, design: .monospaced)
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task.detached {
|
||||
loadContent()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if content.isEmpty {
|
||||
Text("Empty content")
|
||||
} else {
|
||||
ScrollView {
|
||||
Text(content).font(logFont)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
Button("Export") {
|
||||
fileExporterPresented = true
|
||||
}
|
||||
.disabled(content.isEmpty)
|
||||
}
|
||||
.fileExporter(
|
||||
isPresented: $fileExporterPresented,
|
||||
document: LogDocument(content),
|
||||
contentType: .text,
|
||||
defaultFilename: "service-log.txt",
|
||||
onCompletion: { _ in }
|
||||
)
|
||||
.navigationTitle("Service Log")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func loadContent() {
|
||||
do {
|
||||
content = try String(contentsOf: FilePath.cacheDirectory.appendingPathComponent("stderr.log"))
|
||||
} catch {}
|
||||
if content.isEmpty {
|
||||
do {
|
||||
content = try String(contentsOf: FilePath.cacheDirectory.appendingPathComponent("stderr.log.old"))
|
||||
} catch {}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private struct LogDocument: FileDocument {
|
||||
static var readableContentTypes = [UTType.text]
|
||||
|
||||
let content: String
|
||||
|
||||
init(_ content: String) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
init(configuration: ReadConfiguration) throws {
|
||||
if let data = configuration.file.regularFileContents {
|
||||
content = String(decoding: data, as: UTF8.self)
|
||||
} else {
|
||||
content = ""
|
||||
}
|
||||
}
|
||||
|
||||
func fileWrapper(configuration _: WriteConfiguration) throws -> FileWrapper {
|
||||
FileWrapper(regularFileWithContents: Data(content.utf8))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import ServiceManagement
|
||||
#endif
|
||||
|
||||
public struct SettingView: View {
|
||||
#if os(macOS)
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
#endif
|
||||
|
||||
@State private var isLoading = true
|
||||
|
||||
#if os(macOS)
|
||||
@State private var startAtLogin = false
|
||||
@Environment(\.showMenuBarExtra) private var showMenuBarExtra
|
||||
#endif
|
||||
|
||||
@State private var disableMemoryLimit = false
|
||||
@State private var version = ""
|
||||
@State private var dataSize = ""
|
||||
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task.detached {
|
||||
await loadSettings()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FormView {
|
||||
#if os(macOS)
|
||||
Section("MacOS") {
|
||||
Toggle("Start At Login", isOn: $startAtLogin)
|
||||
.onChange(of: startAtLogin) { newValue in
|
||||
Task.detached {
|
||||
updateLoginItems(newValue)
|
||||
}
|
||||
}
|
||||
Toggle("Show in Menu Bar", isOn: showMenuBarExtra)
|
||||
.onChange(of: showMenuBarExtra.wrappedValue) { newValue in
|
||||
Task.detached {
|
||||
SharedPreferences.showMenuBarExtra = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Section("Packet Tunnel") {
|
||||
Toggle("Disable Memory Limit", isOn: $disableMemoryLimit)
|
||||
.onChange(of: disableMemoryLimit) { newValue in
|
||||
Task.detached {
|
||||
SharedPreferences.disableMemoryLimit = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
Section("Core") {
|
||||
FormTextItem("Version", version)
|
||||
FormTextItem("Data Size", dataSize)
|
||||
#if os(iOS)
|
||||
NavigationLink(destination: ServiceLogView()) {
|
||||
Text("View Service Log")
|
||||
}
|
||||
#elseif os(macOS)
|
||||
Button("View Service Log") {
|
||||
openWindow(id: ServiceLogView.windowID)
|
||||
}
|
||||
#endif
|
||||
Button("Clear Working Directory") {
|
||||
Task.detached {
|
||||
clearWorkingDirectory()
|
||||
}
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func updateLoginItems(_ startAtLogin: Bool) {
|
||||
do {
|
||||
if startAtLogin {
|
||||
if SMAppService.mainApp.status == .enabled {
|
||||
try? SMAppService.mainApp.unregister()
|
||||
}
|
||||
|
||||
try SMAppService.mainApp.register()
|
||||
} else {
|
||||
try SMAppService.mainApp.unregister()
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func loadSettings() async {
|
||||
#if os(macOS)
|
||||
startAtLogin = SMAppService.mainApp.status == .enabled
|
||||
#endif
|
||||
disableMemoryLimit = SharedPreferences.disableMemoryLimit
|
||||
version = LibboxVersion()
|
||||
dataSize = "Loading..."
|
||||
isLoading = false
|
||||
dataSize = (try? FilePath.workingDirectory.formattedSize()) ?? "Unknown"
|
||||
}
|
||||
|
||||
private func clearWorkingDirectory() {
|
||||
try? FileManager.default.removeItem(at: FilePath.workingDirectory)
|
||||
isLoading = true
|
||||
}
|
||||
}
|
||||
|
||||
private extension URL {
|
||||
func formattedSize() throws -> String? {
|
||||
guard let urls = FileManager.default.enumerator(at: self, includingPropertiesForKeys: nil)?.allObjects as? [URL] else {
|
||||
return nil
|
||||
}
|
||||
let size = try urls.lazy.reduce(0) {
|
||||
try ($1.resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize ?? 0) + $0
|
||||
}
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .file
|
||||
guard let byteCount = formatter.string(for: size) else {
|
||||
return nil
|
||||
}
|
||||
return byteCount
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
|
||||
extension LibboxStringIteratorProtocol {
|
||||
func toArray() -> [String] {
|
||||
var array: [String] = []
|
||||
while hasNext() {
|
||||
array.append(next())
|
||||
}
|
||||
return array
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import NetworkExtension
|
||||
|
||||
func runBlocking<T>(_ body: @escaping () async throws -> T) throws -> T {
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
let box = resultBox<T>()
|
||||
Task {
|
||||
do {
|
||||
let value = try await body()
|
||||
box.result = .success(value)
|
||||
} catch {
|
||||
box.result = .failure(error)
|
||||
}
|
||||
semaphore.signal()
|
||||
}
|
||||
semaphore.wait()
|
||||
return try box.result.get()
|
||||
}
|
||||
|
||||
private class resultBox<T> {
|
||||
var result: Result<T, Error>!
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?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>com.apple.developer.networking.networkextension</key>
|
||||
<array>
|
||||
<string>packet-tunnel-provider</string>
|
||||
</array>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.io.nekohasekai.sfa</string>
|
||||
</array>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,156 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import NetworkExtension
|
||||
|
||||
class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol {
|
||||
private let tunnel: NEPacketTunnelProvider
|
||||
private let commandServer: LibboxCommandServer
|
||||
|
||||
init(_ tunnel: NEPacketTunnelProvider, _ logServer: LibboxCommandServer) {
|
||||
self.tunnel = tunnel
|
||||
commandServer = logServer
|
||||
}
|
||||
|
||||
func openTun(_ options: LibboxTunOptionsProtocol?, ret0_: UnsafeMutablePointer<Int32>?) throws {
|
||||
guard let options else {
|
||||
throw NSError(domain: "nil options", code: 0)
|
||||
}
|
||||
guard let ret0_ else {
|
||||
throw NSError(domain: "nil return pointer", code: 0)
|
||||
}
|
||||
|
||||
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1")
|
||||
if options.getAutoRoute() {
|
||||
settings.mtu = NSNumber(value: options.getMTU())
|
||||
|
||||
var error: NSError?
|
||||
let dnsServer = options.getDNSServerAddress(&error)
|
||||
if let error {
|
||||
throw error
|
||||
}
|
||||
settings.dnsSettings = NEDNSSettings(servers: [dnsServer])
|
||||
|
||||
var ipv4Address: [String] = []
|
||||
var ipv4Mask: [String] = []
|
||||
let ipv4AddressIterator = options.getInet4Address()!
|
||||
while ipv4AddressIterator.hasNext() {
|
||||
let ipv4Prefix = ipv4AddressIterator.next()!
|
||||
ipv4Address.append(ipv4Prefix.address)
|
||||
ipv4Mask.append(ipv4Prefix.mask())
|
||||
}
|
||||
let ipv4Settings = NEIPv4Settings(addresses: ipv4Address, subnetMasks: ipv4Mask)
|
||||
var ipv4Routes: [NEIPv4Route] = []
|
||||
let inet4RouteAddressIterator = options.getInet4RouteAddress()!
|
||||
if inet4RouteAddressIterator.hasNext() {
|
||||
while inet4RouteAddressIterator.hasNext() {
|
||||
let ipv4RoutePrefix = inet4RouteAddressIterator.next()!
|
||||
ipv4Routes.append(NEIPv4Route(destinationAddress: ipv4RoutePrefix.address, subnetMask: ipv4RoutePrefix.mask()))
|
||||
}
|
||||
} else {
|
||||
ipv4Routes.append(NEIPv4Route.default())
|
||||
}
|
||||
for (index, address) in ipv4Address.enumerated() {
|
||||
ipv4Routes.append(NEIPv4Route(destinationAddress: address, subnetMask: ipv4Mask[index]))
|
||||
}
|
||||
ipv4Settings.includedRoutes = ipv4Routes
|
||||
settings.ipv4Settings = ipv4Settings
|
||||
|
||||
var ipv6Address: [String] = []
|
||||
var ipv6Prefixes: [NSNumber] = []
|
||||
let ipv6AddressIterator = options.getInet6Address()!
|
||||
while ipv6AddressIterator.hasNext() {
|
||||
let ipv6Prefix = ipv6AddressIterator.next()!
|
||||
ipv6Address.append(ipv6Prefix.address)
|
||||
ipv6Prefixes.append(NSNumber(value: ipv6Prefix.prefix))
|
||||
}
|
||||
let ipv6Settings = NEIPv6Settings(addresses: ipv6Address, networkPrefixLengths: ipv6Prefixes)
|
||||
var ipv6Routes: [NEIPv6Route] = []
|
||||
let inet6RouteAddressIterator = options.getInet6RouteAddress()!
|
||||
if inet6RouteAddressIterator.hasNext() {
|
||||
while inet6RouteAddressIterator.hasNext() {
|
||||
let ipv6RoutePrefix = inet4RouteAddressIterator.next()!
|
||||
ipv6Routes.append(NEIPv6Route(destinationAddress: ipv6RoutePrefix.description, networkPrefixLength: NSNumber(value: ipv6RoutePrefix.prefix)))
|
||||
}
|
||||
} else {
|
||||
ipv6Routes.append(NEIPv6Route.default())
|
||||
}
|
||||
ipv6Settings.includedRoutes = ipv6Routes
|
||||
settings.ipv6Settings = ipv6Settings
|
||||
}
|
||||
|
||||
if options.isHTTPProxyEnabled() {
|
||||
let proxySettings = NEProxySettings()
|
||||
let proxyServer = NEProxyServer(address: options.getHTTPProxyServer(), port: Int(options.getHTTPProxyServerPort()))
|
||||
proxySettings.httpEnabled = true
|
||||
proxySettings.httpServer = proxyServer
|
||||
proxySettings.httpsEnabled = true
|
||||
proxySettings.httpsServer = proxyServer
|
||||
settings.proxySettings = proxySettings
|
||||
}
|
||||
|
||||
try runBlocking { [self] in
|
||||
try await tunnel.setTunnelNetworkSettings(settings)
|
||||
}
|
||||
|
||||
if let tunFd = tunnel.packetFlow.value(forKeyPath: "socket.fileDescriptor") as? Int32 {
|
||||
ret0_.pointee = tunFd
|
||||
return
|
||||
}
|
||||
|
||||
let tunFdFromLoop = LibboxGetTunnelFileDescriptor()
|
||||
if tunFdFromLoop != -1 {
|
||||
ret0_.pointee = tunFdFromLoop
|
||||
} else {
|
||||
throw NSError(domain: "missing file descriptor", code: 0)
|
||||
}
|
||||
}
|
||||
|
||||
func usePlatformAutoDetectControl() -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func autoDetectControl(_: Int32) throws {}
|
||||
|
||||
func findConnectionOwner(_: Int32, sourceAddress _: String?, sourcePort _: Int32, destinationAddress _: String?, destinationPort _: Int32, ret0_ _: UnsafeMutablePointer<Int32>?) throws {
|
||||
throw NSError(domain: "not implemented", code: 0)
|
||||
}
|
||||
|
||||
func packageName(byUid _: Int32, error _: NSErrorPointer) -> String {
|
||||
""
|
||||
}
|
||||
|
||||
func uid(byPackageName _: String?, ret0_ _: UnsafeMutablePointer<Int32>?) throws {
|
||||
throw NSError(domain: "not implemented", code: 0)
|
||||
}
|
||||
|
||||
func useProcFS() -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
func writeLog(_ message: String?) {
|
||||
guard let message else {
|
||||
return
|
||||
}
|
||||
commandServer.writeMessage(message)
|
||||
}
|
||||
|
||||
func usePlatformDefaultInterfaceMonitor() -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
func startDefaultInterfaceMonitor(_: LibboxInterfaceUpdateListenerProtocol?) throws {}
|
||||
|
||||
func closeDefaultInterfaceMonitor(_: LibboxInterfaceUpdateListenerProtocol?) throws {}
|
||||
|
||||
func useGetter() -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
func getInterfaces() throws -> LibboxNetworkInterfaceIteratorProtocol {
|
||||
throw NSError(domain: "not implemented", code: 0)
|
||||
}
|
||||
|
||||
func underNetworkExtension() -> Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?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>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.networkextension.packet-tunnel</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,174 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
import NetworkExtension
|
||||
|
||||
class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
private var commandServer: LibboxCommandServer!
|
||||
private var boxService: LibboxBoxService!
|
||||
|
||||
override func startTunnel(options _: [String: NSObject]?) async throws {
|
||||
NSLog("Here I am")
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: FilePath.cacheDirectory, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
writeFatalError("(packet-tunnel) error: create cache directory: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
var error: NSError?
|
||||
LibboxRedirectStderr(FilePath.cacheDirectory.appendingPathComponent("stderr.log").relativePath, &error)
|
||||
if let error {
|
||||
writeError("(packet-tunnel) redirect stderr error: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
LibboxSetMemoryLimit(!SharedPreferences.disableMemoryLimit)
|
||||
|
||||
commandServer = LibboxNewCommandServer(FilePath.sharedDirectory.relativePath, serverInterface(self), Int32(SharedPreferences.maxLogLines))
|
||||
do {
|
||||
try commandServer.start()
|
||||
} catch {
|
||||
writeFatalError("(packet-tunnel): log server start error: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
writeMessage("(packet-tunnel) log server started")
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: FilePath.workingDirectory, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
writeFatalError("(packet-tunnel) error: create working directory: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
|
||||
LibboxSetup(FilePath.workingDirectory.relativePath, FilePath.cacheDirectory.relativePath, -1, -1)
|
||||
|
||||
startService()
|
||||
}
|
||||
|
||||
private func writeMessage(_ message: String) {
|
||||
if let commandServer {
|
||||
commandServer.writeMessage(message)
|
||||
} else {
|
||||
NSLog(message)
|
||||
}
|
||||
}
|
||||
|
||||
private func writeError(_ message: String) {
|
||||
writeMessage(message)
|
||||
#if os(iOS)
|
||||
ServiceNotification.postServiceNotification(title: "Service Error", message: message)
|
||||
#else
|
||||
displayMessage(message) { _ in
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func writeFatalError(_ message: String) {
|
||||
writeError(message)
|
||||
cancelTunnelWithError(NSError(domain: message, code: 0))
|
||||
}
|
||||
|
||||
private func startService() {
|
||||
let profile: Profile?
|
||||
do {
|
||||
profile = try ProfileManager.get(Int64(SharedPreferences.selectedProfileID))
|
||||
} catch {
|
||||
writeFatalError("(packet-tunnel) error: missing default profile: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
guard let profile else {
|
||||
writeFatalError("(packet-tunnel) error: missing default profile")
|
||||
return
|
||||
}
|
||||
let configContent: String
|
||||
do {
|
||||
configContent = try profile.read()
|
||||
} catch {
|
||||
writeFatalError("(packet-tunnel) error: read config file: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
var error: NSError?
|
||||
let service = LibboxNewService(configContent, ExtensionPlatformInterface(self, commandServer), &error)
|
||||
if let error {
|
||||
writeError("(packet-tunnel) error: create service: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
guard let service else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try service.start()
|
||||
} catch {
|
||||
writeError("(packet-tunnel) error: start service: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
boxService = service
|
||||
commandServer.setService(service)
|
||||
#if os(macOS)
|
||||
Task.detached {
|
||||
SharedPreferences.startedByUser = true
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func stopService() {
|
||||
if let service = boxService {
|
||||
do {
|
||||
try service.close()
|
||||
} catch {
|
||||
writeError("(packet-tunnel) error: stop service: \(error.localizedDescription)")
|
||||
}
|
||||
boxService = nil
|
||||
commandServer.setService(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadService() {
|
||||
writeMessage("(packet-tunnel) reloading service")
|
||||
reasserting = true
|
||||
defer {
|
||||
reasserting = false
|
||||
}
|
||||
stopService()
|
||||
startService()
|
||||
}
|
||||
|
||||
override func stopTunnel(with reason: NEProviderStopReason) async {
|
||||
writeMessage("(packet-tunnel) stopping, reason: \(reason)")
|
||||
stopService()
|
||||
if let server = commandServer {
|
||||
try? server.close()
|
||||
commandServer = nil
|
||||
}
|
||||
#if os(macOS)
|
||||
if reason == .userInitiated {
|
||||
SharedPreferences.startedByUser = reason == .userInitiated
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
override func handleAppMessage(_ messageData: Data) async -> Data? {
|
||||
messageData
|
||||
}
|
||||
|
||||
override func sleep() async {}
|
||||
|
||||
override func wake() {}
|
||||
|
||||
private class serverInterface: NSObject, LibboxCommandServerHandlerProtocol {
|
||||
unowned let tunnel: PacketTunnelProvider
|
||||
|
||||
init(_ tunnel: PacketTunnelProvider) {
|
||||
self.tunnel = tunnel
|
||||
super.init()
|
||||
}
|
||||
|
||||
func serviceReload() throws {
|
||||
tunnel.reloadService()
|
||||
}
|
||||
|
||||
func serviceStop() throws {
|
||||
tunnel.stopService()
|
||||
tunnel.writeMessage("(packet-tunnel) debug: service stopped")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?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>EXAppExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>EXExtensionPointIdentifier</key>
|
||||
<string>com.apple.appintents-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,197 @@
|
||||
import AppIntents
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
|
||||
struct StartServiceIntent: AppIntent {
|
||||
public static var title: LocalizedStringResource = "Start sing-box"
|
||||
|
||||
static var description =
|
||||
IntentDescription("Start or reload sing-box servie with specified profile")
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Start sing-box service with profile \(\.$profile).")
|
||||
}
|
||||
|
||||
@Parameter(title: "Profile", optionsProvider: ProfileProvider())
|
||||
var profile: String
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard let extensionProfile = try await (ExtensionProfile.load()) else {
|
||||
throw NSError(domain: "NetworkExtension not installed", code: 0)
|
||||
}
|
||||
let profileList = try ProfileManager.list()
|
||||
let specifiedProfile = profileList.first { $0.name == profile }
|
||||
var profileChanged = false
|
||||
if let specifiedProfile {
|
||||
if SharedPreferences.selectedProfileID != specifiedProfile.id! {
|
||||
SharedPreferences.selectedProfileID = specifiedProfile.id!
|
||||
profileChanged = true
|
||||
}
|
||||
} else if profile != "default" {
|
||||
throw NSError(domain: "Specified profile not found: \(profile)", code: 0)
|
||||
}
|
||||
if extensionProfile.status == .connected {
|
||||
if !profileChanged {
|
||||
return .result()
|
||||
}
|
||||
try LibboxNewStandaloneCommandClient(FilePath.sharedDirectory.relativePath)?.serviceReload()
|
||||
} else if extensionProfile.status.isConnected {
|
||||
extensionProfile.stop()
|
||||
try await Task.sleep(nanoseconds: UInt64(100 * Double(NSEC_PER_MSEC)))
|
||||
try await extensionProfile.start()
|
||||
} else {
|
||||
try await extensionProfile.start()
|
||||
}
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct RestartServiceIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Restart sing-box"
|
||||
|
||||
static var description =
|
||||
IntentDescription("Restart sing-box service")
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Restart sing-box service")
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard let extensionProfile = try await (ExtensionProfile.load()) else {
|
||||
return .result()
|
||||
}
|
||||
if extensionProfile.status == .connected {
|
||||
try LibboxNewStandaloneCommandClient(FilePath.sharedDirectory.relativePath)?.serviceReload()
|
||||
} else if extensionProfile.status.isConnected {
|
||||
extensionProfile.stop()
|
||||
try await Task.sleep(nanoseconds: UInt64(100 * Double(NSEC_PER_MSEC)))
|
||||
try await extensionProfile.start()
|
||||
} else {
|
||||
try await extensionProfile.start()
|
||||
}
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct StopServiceIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Stop sing-box"
|
||||
|
||||
static var description =
|
||||
IntentDescription("Stop sing-box service")
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Stop sing-box service")
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard let extensionProfile = try await (ExtensionProfile.load()) else {
|
||||
return .result()
|
||||
}
|
||||
extensionProfile.stop()
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct ToggleServiceIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Toggle sing-box"
|
||||
|
||||
static var description =
|
||||
IntentDescription("Toggle sing-box service")
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Toggle sing-box service")
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard let extensionProfile = try await (ExtensionProfile.load()) else {
|
||||
return .result(value: false)
|
||||
}
|
||||
if extensionProfile.status.isConnected {
|
||||
extensionProfile.stop()
|
||||
return .result(value: false)
|
||||
|
||||
} else {
|
||||
try await extensionProfile.start()
|
||||
return .result(value: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GetServiceStatus: AppIntent {
|
||||
static var title: LocalizedStringResource = "Get is sing-box service started"
|
||||
|
||||
static var description =
|
||||
IntentDescription("Get is sing-box service started")
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Get is sing-box service started")
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard let extensionProfile = try await (ExtensionProfile.load()) else {
|
||||
return .result(value: false)
|
||||
}
|
||||
return .result(value: extensionProfile.status.isConnected)
|
||||
}
|
||||
}
|
||||
|
||||
struct GetCurrentProfile: AppIntent {
|
||||
static var title: LocalizedStringResource = "Get current sing-box profile"
|
||||
|
||||
static var description =
|
||||
IntentDescription("Get current sing-box profile")
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Get current sing-box profile")
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard let profile = try ProfileManager.get(SharedPreferences.selectedProfileID) else {
|
||||
throw NSError(domain: "No profile selected", code: 0)
|
||||
}
|
||||
return .result(value: profile.name)
|
||||
}
|
||||
}
|
||||
|
||||
struct UpdateProfileIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Update sing-box profile"
|
||||
|
||||
static var description =
|
||||
IntentDescription("Update specified sing-box profile")
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Update sing-box profile \(\.$profile).")
|
||||
}
|
||||
|
||||
@Parameter(title: "Profile", optionsProvider: RemoteProfileProvider())
|
||||
var profile: String
|
||||
|
||||
init() {}
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard let profile = try ProfileManager.get(by: profile) else {
|
||||
throw NSError(domain: "Specified profile not found: \(profile)", code: 0)
|
||||
}
|
||||
if profile.type != .remote {
|
||||
throw NSError(domain: "Specified profile is not a remote profile", code: 0)
|
||||
}
|
||||
try profile.updateRemoteProfile()
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileProvider: DynamicOptionsProvider {
|
||||
func results() async throws -> [String] {
|
||||
var profileNames = try ProfileManager.list().map(\.name)
|
||||
if !profileNames.contains("default") {
|
||||
profileNames.insert("default", at: 0)
|
||||
}
|
||||
return profileNames
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteProfileProvider: DynamicOptionsProvider {
|
||||
func results() async throws -> [String] {
|
||||
try ProfileManager.listRemote().map(\.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?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>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.io.nekohasekai.sfa</string>
|
||||
</array>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,4 @@
|
||||
import AppIntents
|
||||
|
||||
@main
|
||||
struct IntentsExtension: AppIntentsExtension {}
|
||||
@@ -0,0 +1,14 @@
|
||||
Copyright (C) 2022 by nekohasekai <contact-sagernet@sekai.icu>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
import GRDB
|
||||
|
||||
class Database {
|
||||
private static var writer: (any DatabaseWriter)?
|
||||
|
||||
static func sharedWriter() throws -> any DatabaseWriter {
|
||||
if let writer {
|
||||
return writer
|
||||
}
|
||||
let database = try DatabasePool(path: FilePath.sharedDirectory.appendingPathComponent("settings.db").relativePath)
|
||||
var migrator = DatabaseMigrator().disablingDeferredForeignKeyChecks()
|
||||
migrator.eraseDatabaseOnSchemaChange = true
|
||||
|
||||
migrator.registerMigration("initialize") { db in
|
||||
try db.create(table: "profiles") { t in
|
||||
t.autoIncrementedPrimaryKey("id")
|
||||
t.column("name", .text).notNull()
|
||||
t.column("order", .integer).notNull()
|
||||
t.column("type", .integer).notNull().defaults(to: ProfileType.local.rawValue)
|
||||
t.column("path", .text).notNull()
|
||||
t.column("remoteURL", .text)
|
||||
t.column("autoUpdate", .boolean).notNull().defaults(to: false)
|
||||
t.column("lastUpdated", .datetime)
|
||||
}
|
||||
try db.create(table: "preferences") { t in
|
||||
t.primaryKey("name", .text, onConflict: .replace).notNull()
|
||||
t.column("data", .blob)
|
||||
}
|
||||
}
|
||||
|
||||
try migrator.migrate(database)
|
||||
writer = database
|
||||
return database
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// Profile+Date.swift
|
||||
// Library
|
||||
//
|
||||
// Created by 世界 on 2023/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Profile {
|
||||
var lastUpdatedString: String {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
return dateFormatter.string(from: lastUpdated!)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
extension Profile: Hashable {
|
||||
public static func == (lhs: Profile, rhs: Profile) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
public extension Profile {
|
||||
func read() throws -> String {
|
||||
switch type {
|
||||
case .local, .remote:
|
||||
return try String(contentsOfFile: path)
|
||||
case .icloud:
|
||||
let saveURL = FilePath.iCloudDirectory.appendingPathComponent(path)
|
||||
_ = saveURL.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
saveURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
return try String(contentsOf: saveURL)
|
||||
}
|
||||
}
|
||||
|
||||
func write(_ content: String) throws {
|
||||
switch type {
|
||||
case .local, .remote:
|
||||
try content.write(toFile: path, atomically: true, encoding: .utf8)
|
||||
case .icloud:
|
||||
let saveURL = FilePath.iCloudDirectory.appendingPathComponent(path)
|
||||
_ = saveURL.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
saveURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
try content.write(to: saveURL, atomically: true, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
import GRDB
|
||||
import Libbox
|
||||
|
||||
public extension Profile {
|
||||
func updateRemoteProfile() throws {
|
||||
if type != .remote {
|
||||
return
|
||||
}
|
||||
let remoteContent = try HTTPClient().getString(remoteURL)
|
||||
var error: NSError?
|
||||
LibboxCheckConfig(remoteContent, &error)
|
||||
if let error {
|
||||
throw error
|
||||
}
|
||||
try write(remoteContent)
|
||||
lastUpdated = Date()
|
||||
try ProfileManager.update(self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
import GRDB
|
||||
|
||||
public class Profile: Record, Identifiable, ObservableObject {
|
||||
public var id: Int64?
|
||||
public var mustID: Int64 {
|
||||
id!
|
||||
}
|
||||
|
||||
@Published public var name: String
|
||||
public var order: UInt32
|
||||
public var type: ProfileType
|
||||
public var path: String
|
||||
@Published public var remoteURL: String?
|
||||
@Published public var autoUpdate: Bool
|
||||
public var lastUpdated: Date?
|
||||
|
||||
public init(id: Int64? = nil, name: String, order: UInt32 = 0, type: ProfileType, path: String, remoteURL: String? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.order = order
|
||||
self.type = type
|
||||
self.path = path
|
||||
self.remoteURL = remoteURL
|
||||
|
||||
autoUpdate = false
|
||||
lastUpdated = nil
|
||||
if type == .remote {
|
||||
lastUpdated = Date()
|
||||
}
|
||||
super.init()
|
||||
}
|
||||
|
||||
override public class var databaseTableName: String {
|
||||
"profiles"
|
||||
}
|
||||
|
||||
enum Columns: String, ColumnExpression {
|
||||
case id, name, order, type, path, remoteURL, autoUpdate, lastUpdated, userAgent
|
||||
}
|
||||
|
||||
required init(row: Row) throws {
|
||||
id = row[Columns.id]
|
||||
name = row[Columns.name] ?? ""
|
||||
order = row[Columns.order] ?? 0
|
||||
type = ProfileType(rawValue: row[Columns.type] ?? ProfileType.local.rawValue)!
|
||||
path = row[Columns.path] ?? ""
|
||||
remoteURL = row[Columns.remoteURL] ?? ""
|
||||
autoUpdate = row[Columns.autoUpdate] ?? false
|
||||
lastUpdated = row[Columns.lastUpdated] ?? Date()
|
||||
try super.init(row: row)
|
||||
}
|
||||
|
||||
override public func encode(to container: inout PersistenceContainer) throws {
|
||||
container[Columns.id] = id
|
||||
container[Columns.name] = name
|
||||
container[Columns.order] = order
|
||||
container[Columns.type] = type.rawValue
|
||||
container[Columns.path] = path
|
||||
container[Columns.remoteURL] = remoteURL
|
||||
container[Columns.autoUpdate] = autoUpdate
|
||||
container[Columns.lastUpdated] = lastUpdated
|
||||
}
|
||||
|
||||
override public func didInsert(_ inserted: InsertionSuccess) {
|
||||
super.didInsert(inserted)
|
||||
id = inserted.rowID
|
||||
}
|
||||
}
|
||||
|
||||
public enum ProfileType: Int {
|
||||
case local = 0, icloud, remote
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
import GRDB
|
||||
|
||||
public enum ProfileManager {
|
||||
public static func create(_ profile: Profile) throws {
|
||||
profile.order = try nextOrder()
|
||||
try Database.sharedWriter().write { db in
|
||||
try profile.insert(db, onConflict: .fail)
|
||||
}
|
||||
}
|
||||
|
||||
public static func get(_ profileID: Int64) throws -> Profile? {
|
||||
try Database.sharedWriter().read { db in
|
||||
try Profile.fetchOne(db, id: profileID)
|
||||
}
|
||||
}
|
||||
|
||||
public static func get(by profileName: String) throws -> Profile? {
|
||||
try Database.sharedWriter().read { db in
|
||||
try Profile.filter(Column("name") == profileName).fetchOne(db)
|
||||
}
|
||||
}
|
||||
|
||||
public static func delete(_ profile: Profile) throws {
|
||||
_ = try Database.sharedWriter().write { db in
|
||||
try profile.delete(db)
|
||||
}
|
||||
}
|
||||
|
||||
public static func delete(by id: Int64) throws {
|
||||
_ = try Database.sharedWriter().write { db in
|
||||
try Profile.deleteOne(db, id: id)
|
||||
}
|
||||
}
|
||||
|
||||
public static func delete(_ profileList: [Profile]) throws -> Int {
|
||||
try Database.sharedWriter().write { db in
|
||||
try Profile.deleteAll(db, keys: profileList.map {
|
||||
["id": $0.id!]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public static func delete(by id: [Int64]) throws -> Int {
|
||||
try Database.sharedWriter().write { db in
|
||||
try Profile.deleteAll(db, ids: id)
|
||||
}
|
||||
}
|
||||
|
||||
public static func update(_ profile: Profile) throws {
|
||||
_ = try Database.sharedWriter().write { db in
|
||||
try profile.updateChanges(db)
|
||||
}
|
||||
}
|
||||
|
||||
public static func update(_ profileList: [Profile]) throws {
|
||||
// TODO: batch update
|
||||
try Database.sharedWriter().write { db in
|
||||
for profile in profileList {
|
||||
try profile.updateChanges(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func list() throws -> [Profile] {
|
||||
try Database.sharedWriter().read { db in
|
||||
try Profile.all().order(Column("order").asc).fetchAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
public static func listRemote() throws -> [Profile] {
|
||||
try Database.sharedWriter().read { db in
|
||||
try Profile.filter(Column("type") == ProfileType.remote.rawValue).order(Column("order").asc).fetchAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
public static func listAutoUpdateEnabled() throws -> [Profile] {
|
||||
try Database.sharedWriter().read { db in
|
||||
try Profile.filter(Column("autoUpdate") == true).order(Column("order").asc).fetchAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
public static func nextID() throws -> Int64 {
|
||||
try Database.sharedWriter().read { db in
|
||||
if let lastProfile = try Profile.select(Column("id")).order(Column("id").desc).fetchOne(db) {
|
||||
return lastProfile.id! + 1
|
||||
} else {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func nextOrder() throws -> UInt32 {
|
||||
try Database.sharedWriter().read { db in
|
||||
try UInt32(Profile.fetchCount(db))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import BinaryCodable
|
||||
import Foundation
|
||||
import GRDB
|
||||
|
||||
extension SharedPreferences {
|
||||
@propertyWrapper public class Preference<T: Codable> {
|
||||
private let name: String
|
||||
private let defaultValue: T
|
||||
|
||||
init(_ name: String, defaultValue: T) {
|
||||
self.name = name
|
||||
self.defaultValue = defaultValue
|
||||
}
|
||||
|
||||
public var wrappedValue: T {
|
||||
get {
|
||||
do {
|
||||
return try SharedPreferences.read(name) ?? defaultValue
|
||||
} catch {
|
||||
NSLog("read preferences error: \(error)")
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
set {
|
||||
do {
|
||||
try SharedPreferences.write(name, newValue)
|
||||
} catch {
|
||||
NSLog("write preferences error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@propertyWrapper public class NullablePreference<T: Codable> {
|
||||
private let name: String
|
||||
|
||||
init(_ name: String) {
|
||||
self.name = name
|
||||
}
|
||||
|
||||
public var wrappedValue: T? {
|
||||
get {
|
||||
do {
|
||||
return try SharedPreferences.read(name)
|
||||
} catch {
|
||||
NSLog("read preferences error: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
set {
|
||||
do {
|
||||
try SharedPreferences.write(name, newValue)
|
||||
} catch {
|
||||
NSLog("write preferences error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func read<T: Codable>(_ name: String) throws -> T? {
|
||||
guard let item = try (Database.sharedWriter().read { db in
|
||||
try Item.fetchOne(db, id: name)
|
||||
})
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return try BinaryDecoder().decode(from: item.data)
|
||||
}
|
||||
|
||||
private static func write(_ name: String, _ value: (some Codable)?) throws {
|
||||
if value == nil {
|
||||
_ = try Database.sharedWriter().write { db in
|
||||
try Item.deleteOne(db, id: name)
|
||||
}
|
||||
} else {
|
||||
let data = try BinaryEncoder().encode(value)
|
||||
try Database.sharedWriter().write { db in
|
||||
try Item(name: name, data: data).insert(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class Item: Record, Identifiable {
|
||||
public var id: String {
|
||||
name
|
||||
}
|
||||
|
||||
public var name: String
|
||||
public var data: Data
|
||||
|
||||
init(name: String, data: Data) {
|
||||
self.name = name
|
||||
self.data = data
|
||||
super.init()
|
||||
}
|
||||
|
||||
override public class var databaseTableName: String {
|
||||
"preferences"
|
||||
}
|
||||
|
||||
enum Columns: String, ColumnExpression {
|
||||
case name, data
|
||||
}
|
||||
|
||||
required init(row: Row) throws {
|
||||
name = row[Columns.name]
|
||||
data = row[Columns.data]
|
||||
try super.init(row: row)
|
||||
}
|
||||
|
||||
override public func encode(to container: inout PersistenceContainer) throws {
|
||||
container[Columns.name] = name
|
||||
container[Columns.data] = data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
public enum SharedPreferences {
|
||||
@Preference<Int64>("selected_profile_id", defaultValue: -1) public static var selectedProfileID
|
||||
|
||||
#if os(macOS)
|
||||
private static let disableMemoryLimitByDefault = true
|
||||
#else
|
||||
private static let disableMemoryLimitByDefault = false
|
||||
#endif
|
||||
@Preference<Bool>("disable_memory_limit", defaultValue: disableMemoryLimitByDefault) public static var disableMemoryLimit
|
||||
|
||||
@Preference<Int>("max_log_lines", defaultValue: 300) public static var maxLogLines
|
||||
|
||||
#if os(macOS)
|
||||
@Preference<Bool>("show_menu_bar_extra", defaultValue: true) public static var showMenuBarExtra
|
||||
@Preference<Bool>("started_by_user", defaultValue: false) public static var startedByUser
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import Foundation
|
||||
|
||||
public class Library {}
|
||||
@@ -0,0 +1,70 @@
|
||||
import Foundation
|
||||
import NetworkExtension
|
||||
|
||||
public class ExtensionProfile: ObservableObject {
|
||||
private let manager: NEVPNManager
|
||||
private var connection: NEVPNConnection
|
||||
private var observer: Any?
|
||||
|
||||
@Published public var status: NEVPNStatus
|
||||
|
||||
public init(_ manager: NEVPNManager) {
|
||||
self.manager = manager
|
||||
connection = manager.connection
|
||||
status = manager.connection.status
|
||||
}
|
||||
|
||||
deinit {
|
||||
unregister()
|
||||
}
|
||||
|
||||
public func register() {
|
||||
observer = NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name.NEVPNStatusDidChange,
|
||||
object: manager.connection,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.connection = notification.object as! NEVPNConnection
|
||||
self.status = self.connection.status
|
||||
}
|
||||
}
|
||||
|
||||
private func unregister() {
|
||||
if let observer {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
public func start() async throws {
|
||||
manager.isEnabled = true
|
||||
try await manager.saveToPreferences()
|
||||
try manager.connection.startVPNTunnel()
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
manager.connection.stopVPNTunnel()
|
||||
}
|
||||
|
||||
public static func load() async throws -> ExtensionProfile? {
|
||||
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
|
||||
if managers.isEmpty {
|
||||
return nil
|
||||
}
|
||||
let profile = ExtensionProfile(managers[0])
|
||||
return profile
|
||||
}
|
||||
|
||||
public static func install() async throws {
|
||||
let manager = NETunnelProviderManager()
|
||||
manager.localizedDescription = "utun interface"
|
||||
let tunnelProtocol = NETunnelProviderProtocol()
|
||||
tunnelProtocol.providerBundleIdentifier = "\(FilePath.packageName).extension"
|
||||
tunnelProtocol.serverAddress = "sing-box"
|
||||
manager.protocolConfiguration = tunnelProtocol
|
||||
manager.isEnabled = true
|
||||
try await manager.saveToPreferences()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
|
||||
public class HTTPClient {
|
||||
private static var userAgent: String {
|
||||
var userAgent = FilePath.httpClientName
|
||||
userAgent += "/"
|
||||
userAgent += Bundle.main.version
|
||||
userAgent += " (Build "
|
||||
userAgent += Bundle.main.versionNumber
|
||||
userAgent += "; sing-box "
|
||||
userAgent += LibboxVersion()
|
||||
userAgent += ")"
|
||||
return userAgent
|
||||
}
|
||||
|
||||
private let client: any LibboxHTTPClientProtocol
|
||||
|
||||
public init() {
|
||||
client = LibboxNewHTTPClient()!
|
||||
client.modernTLS()
|
||||
}
|
||||
|
||||
public func getString(_ url: String?) throws -> String {
|
||||
let request = client.newRequest()!
|
||||
request.setUserAgent(HTTPClient.userAgent)
|
||||
try request.setURL(url)
|
||||
let response = try request.execute()
|
||||
var error: NSError?
|
||||
let contentString = response.getContentString(&error)
|
||||
if let error {
|
||||
throw error
|
||||
}
|
||||
return contentString
|
||||
}
|
||||
|
||||
deinit {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
import NetworkExtension
|
||||
|
||||
public extension NEVPNStatus {
|
||||
var isEnabled: Bool {
|
||||
switch self {
|
||||
case .connected, .disconnected, .reasserting:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isSwitchable: Bool {
|
||||
switch self {
|
||||
case .connected, .disconnected:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isConnected: Bool {
|
||||
switch self {
|
||||
case .connecting, .connected, .disconnecting, .reasserting:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isConnectedStrict: Bool {
|
||||
switch self {
|
||||
case .connected, .reasserting:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
extension Bundle {
|
||||
var version: String {
|
||||
infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
||||
}
|
||||
|
||||
var versionNumber: String {
|
||||
infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
public enum FilePath {
|
||||
public static let packageName = "io.nekohasekai.sfa"
|
||||
#if os(iOS)
|
||||
public static let httpClientName = "SFI"
|
||||
#elseif os(macOS)
|
||||
public static let httpClientName = "SFM"
|
||||
#endif
|
||||
}
|
||||
|
||||
public extension FilePath {
|
||||
static let groupName = "group.\(packageName)"
|
||||
|
||||
static let sharedDirectory: URL! = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupName)
|
||||
|
||||
static let cacheDirectory = sharedDirectory
|
||||
.appendingPathComponent("Library", isDirectory: true)
|
||||
.appendingPathComponent("Caches", isDirectory: true)
|
||||
|
||||
static let workingDirectory = cacheDirectory.appendingPathComponent("Working", isDirectory: true)
|
||||
|
||||
static let iCloudDirectory = FileManager.default.url(forUbiquityContainerIdentifier: nil)!.appendingPathComponent("Documents", isDirectory: true)
|
||||
}
|
||||
|
||||
public extension URL {
|
||||
var fileName: String {
|
||||
var path = relativePath
|
||||
if let index = path.lastIndex(of: "/") {
|
||||
path = String(path[path.index(index, offsetBy: 1)...])
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
public enum ServiceNotification {
|
||||
private static let delegate = Delegate()
|
||||
|
||||
public static func register() {
|
||||
UNUserNotificationCenter.current().delegate = delegate
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) {
|
||||
_, _ in
|
||||
}
|
||||
}
|
||||
|
||||
private static var listener: ((UNNotificationContent) -> Void)?
|
||||
|
||||
public static func setServiceNotificationListener(listener: @escaping (UNNotificationContent) -> Void) {
|
||||
ServiceNotification.listener = listener
|
||||
}
|
||||
|
||||
public static func removeServiceNotificationListener() {
|
||||
ServiceNotification.listener = nil
|
||||
}
|
||||
|
||||
public static func postServiceNotification(content: UNNotificationContent) {
|
||||
UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: "service-notification", content: content, trigger: nil))
|
||||
}
|
||||
|
||||
public static func postServiceNotification(title: String, message: String) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = message
|
||||
postServiceNotification(content: content)
|
||||
}
|
||||
|
||||
private class Delegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
func userNotificationCenter(_: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
|
||||
NSLog("userNotificationCenter")
|
||||
if let listener = ServiceNotification.listener {
|
||||
listener(notification.request.content)
|
||||
return []
|
||||
} else {
|
||||
return [.alert]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# sing-box-for-apple
|
||||
|
||||
Experimental iOS/macOS client for sing-box, the universal proxy platform.
|
||||
|
||||
## Documentation
|
||||
|
||||
[SFI](https://sing-box.sagernet.org/installation/clients/sfi/) | [SFM](https://sing-box.sagernet.org/installation/clients/sfm/)
|
||||
|
||||
## License
|
||||
|
||||
```
|
||||
Copyright (C) 2022 by nekohasekai <contact-sagernet@sekai.icu>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
```
|
||||
@@ -0,0 +1,16 @@
|
||||
import ApplicationLibrary
|
||||
import Foundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
struct Application: App {
|
||||
@UIApplicationDelegateAdaptor private var appDelegate: ApplicationDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
MainView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import ApplicationLibrary
|
||||
import Foundation
|
||||
import Library
|
||||
import UIKit
|
||||
|
||||
class ApplicationDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
NSLog("Here I stand")
|
||||
ServiceNotification.register()
|
||||
Task.detached {
|
||||
do {
|
||||
try await UIProfileUpdateTask.setup()
|
||||
NSLog("setup background task success")
|
||||
} catch {
|
||||
NSLog("setup background task error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors": [
|
||||
{
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 906 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,324 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "40.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "60.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "29.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "58.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "87.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "80.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "120.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "57.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "1x",
|
||||
"size" : "57x57"
|
||||
},
|
||||
{
|
||||
"filename" : "114.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "57x57"
|
||||
},
|
||||
{
|
||||
"filename" : "120.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "180.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "20.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "40.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "29.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "58.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "40.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "80.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "50.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "50x50"
|
||||
},
|
||||
{
|
||||
"filename" : "100.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "50x50"
|
||||
},
|
||||
{
|
||||
"filename" : "72.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "72x72"
|
||||
},
|
||||
{
|
||||
"filename" : "144.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "72x72"
|
||||
},
|
||||
{
|
||||
"filename" : "76.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "152.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "167.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"filename" : "1024.png",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "notificationCenter",
|
||||
"scale" : "2x",
|
||||
"size" : "24x24",
|
||||
"subtype" : "38mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "notificationCenter",
|
||||
"scale" : "2x",
|
||||
"size" : "27.5x27.5",
|
||||
"subtype" : "42mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "companionSettings",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "companionSettings",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "notificationCenter",
|
||||
"scale" : "2x",
|
||||
"size" : "33x33",
|
||||
"subtype" : "45mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40",
|
||||
"subtype" : "38mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "44x44",
|
||||
"subtype" : "40mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "46x46",
|
||||
"subtype" : "41mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "50x50",
|
||||
"subtype" : "44mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "51x51",
|
||||
"subtype" : "45mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "54x54",
|
||||
"subtype" : "49mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "86x86",
|
||||
"subtype" : "38mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "98x98",
|
||||
"subtype" : "42mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "108x108",
|
||||
"subtype" : "44mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "117x117",
|
||||
"subtype" : "45mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "129x129",
|
||||
"subtype" : "49mm"
|
||||
},
|
||||
{
|
||||
"filename" : "1024.png",
|
||||
"idiom" : "watch-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import ApplicationLibrary
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.selection) private var selection
|
||||
@Environment(\.extensionProfile) private var extensionProfile
|
||||
|
||||
var body: some View {
|
||||
viewBuilder {
|
||||
if let profile = extensionProfile.wrappedValue {
|
||||
ContentView0().environmentObject(profile)
|
||||
} else {
|
||||
ContentView1()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView0: View {
|
||||
@Environment(\.selection) private var selection
|
||||
@EnvironmentObject private var extensionProfile: ExtensionProfile
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: selection) {
|
||||
ForEach(NavigationPage.allCases.filter { it in
|
||||
it.visible(extensionProfile)
|
||||
}, id: \.self) { page in
|
||||
NavigationView {
|
||||
page.contentView
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
.tag(page)
|
||||
.tabItem { page.label }
|
||||
}
|
||||
}.onChange(of: extensionProfile.status) { _ in
|
||||
if !selection.wrappedValue.visible(extensionProfile) {
|
||||
selection.wrappedValue = NavigationPage.dashboard
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView1: View {
|
||||
@Environment(\.selection) private var selection
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: selection) {
|
||||
ForEach(NavigationPage.allCases.filter { it in
|
||||
it.visible(nil)
|
||||
}, id: \.self) { page in
|
||||
NavigationView {
|
||||
page.contentView
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
.tag(page)
|
||||
.tabItem { page.label }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>io.nekohasekai.sfa.update_profiles</string>
|
||||
</array>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSUbiquitousContainers</key>
|
||||
<dict>
|
||||
<key>iCloud.io.nekohasekai.sfa</key>
|
||||
<dict>
|
||||
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
|
||||
<true/>
|
||||
<key>NSUbiquitousContainerName</key>
|
||||
<string>sing-box</string>
|
||||
<key>NSUbiquitousContainerSupportedFolderLevels</key>
|
||||
<string>Any</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,84 @@
|
||||
import ApplicationLibrary
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
struct MainView: View {
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
|
||||
@State private var selection = NavigationPage.dashboard
|
||||
@State private var extensionProfile: ExtensionProfile?
|
||||
@State private var profileLoading = true
|
||||
@State private var logClient: LogClient!
|
||||
|
||||
@State private var serviceNotificationTitle = ""
|
||||
@State private var serviceNotificationContent = ""
|
||||
@State private var serviceNotificationPresented = false
|
||||
|
||||
var body: some View {
|
||||
viewBuilder {
|
||||
if profileLoading {
|
||||
ProgressView().onAppear {
|
||||
Task.detached {
|
||||
logClient = LogClient(SharedPreferences.maxLogLines)
|
||||
await loadProfile()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $serviceNotificationPresented, content: {
|
||||
Alert(
|
||||
title: Text(serviceNotificationTitle),
|
||||
message: Text(serviceNotificationContent),
|
||||
dismissButton: .default(Text("Ok"))
|
||||
)
|
||||
})
|
||||
.onAppear {
|
||||
ServiceNotification.setServiceNotificationListener { notification in
|
||||
serviceNotificationTitle = notification.title
|
||||
serviceNotificationContent = notification.body
|
||||
serviceNotificationPresented = true
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
ServiceNotification.removeServiceNotificationListener()
|
||||
}
|
||||
.onChange(of: scenePhase, perform: { newValue in
|
||||
if newValue == .active {
|
||||
Task.detached {
|
||||
await loadProfile()
|
||||
}
|
||||
}
|
||||
})
|
||||
.environment(\.selection, $selection)
|
||||
.environment(\.extensionProfile, $extensionProfile)
|
||||
.environment(\.logClient, $logClient)
|
||||
}
|
||||
|
||||
private func loadProfile() async {
|
||||
defer {
|
||||
profileLoading = false
|
||||
}
|
||||
if let newProfile = try? await ExtensionProfile.load() {
|
||||
if extensionProfile == nil || extensionProfile?.status == .invalid {
|
||||
newProfile.register()
|
||||
extensionProfile = newProfile
|
||||
}
|
||||
} else {
|
||||
extensionProfile = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func connectLog() {
|
||||
guard let profile = extensionProfile else {
|
||||
return
|
||||
}
|
||||
guard let logClient else {
|
||||
return
|
||||
}
|
||||
if profile.status.isConnected, !logClient.isConnected {
|
||||
logClient.reconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?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>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.io.nekohasekai.sfa</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudDocuments</string>
|
||||
</array>
|
||||
<key>com.apple.developer.networking.networkextension</key>
|
||||
<array>
|
||||
<string>packet-tunnel-provider</string>
|
||||
</array>
|
||||
<key>com.apple.developer.ubiquity-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.io.nekohasekai.sfa</string>
|
||||
</array>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.io.nekohasekai.sfa</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,80 @@
|
||||
import ApplicationLibrary
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct Application: App {
|
||||
@NSApplicationDelegateAdaptor private var appDelegate: ApplicationDelegate
|
||||
|
||||
@State private var showMenuBarExtra = false
|
||||
@State private var isMenuPresented = false
|
||||
|
||||
var body: some Scene {
|
||||
Window("sing-box", id: "main", content: {
|
||||
MainView()
|
||||
.onAppear {
|
||||
Task.detached {
|
||||
await initialize()
|
||||
}
|
||||
}
|
||||
.environment(\.showMenuBarExtra, $showMenuBarExtra)
|
||||
})
|
||||
.commands {
|
||||
if showMenuBarExtra {
|
||||
CommandGroup(replacing: .appTermination) {
|
||||
Button("Quit sing-box") {
|
||||
hide(closeApp: true)
|
||||
}
|
||||
.keyboardShortcut("q", modifiers: [.command])
|
||||
}
|
||||
CommandGroup(replacing: .saveItem) {
|
||||
Button("Close") {
|
||||
hide(closeApp: false)
|
||||
}
|
||||
.keyboardShortcut("w", modifiers: [.command])
|
||||
}
|
||||
}
|
||||
SidebarCommands()
|
||||
}
|
||||
|
||||
Window("New Profile", id: NewProfileView.windowID) {
|
||||
NewProfileView()
|
||||
}
|
||||
|
||||
WindowGroup("Edit Profile", id: EditProfileWindowView.windowID, for: Int64.self) { profileID in
|
||||
EditProfileWindowView(profileID.wrappedValue)
|
||||
}.commandsRemoved()
|
||||
|
||||
WindowGroup("Edit Content", id: EditProfileContentView.windowID, for: EditProfileContentView.Context.self) { context in
|
||||
EditProfileContentView(context.wrappedValue)
|
||||
}.commandsRemoved()
|
||||
|
||||
Window("Service Log", id: ServiceLogView.windowID) {
|
||||
ServiceLogView()
|
||||
}
|
||||
MenuBarExtra(isInserted: $showMenuBarExtra) {
|
||||
MenuView(isMenuPresented: $isMenuPresented)
|
||||
} label: {
|
||||
Image(systemName: "network.badge.shield.half.filled")
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
.menuBarExtraAccess(isPresented: $isMenuPresented)
|
||||
}
|
||||
|
||||
private func initialize() async {
|
||||
let initialShowMenuBarExtra = SharedPreferences.showMenuBarExtra
|
||||
await MainActor.run {
|
||||
showMenuBarExtra = initialShowMenuBarExtra
|
||||
}
|
||||
}
|
||||
|
||||
private func hide(closeApp: Bool) {
|
||||
if closeApp || NSApp.keyWindow?.identifier?.rawValue == "main" {
|
||||
let transformState = ProcessApplicationTransformState(kProcessTransformToUIElementApplication)
|
||||
var psn = ProcessSerialNumber(highLongOfPSN: 0, lowLongOfPSN: UInt32(kCurrentProcess))
|
||||
TransformProcessType(&psn, transformState)
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
}
|
||||
NSApp.keyWindow?.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import AppKit
|
||||
import ApplicationLibrary
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
|
||||
class ApplicationDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationDidFinishLaunching(_: Notification) {
|
||||
NSLog("Here I stand")
|
||||
// ServiceNotification.register() // Not work
|
||||
let event = NSAppleEventManager.shared().currentAppleEvent
|
||||
let launchedAsLogInItem =
|
||||
event?.eventID == kAEOpenApplication &&
|
||||
event?.paramDescriptor(forKeyword: keyAEPropData)?.enumCodeValue == keyAELaunchedAsLogInItem
|
||||
if !launchedAsLogInItem || !SharedPreferences.showMenuBarExtra {
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
} else {
|
||||
NSApp.windows.first?.close()
|
||||
}
|
||||
Task.detached {
|
||||
do {
|
||||
try await self.postStart(launchedAsLogInItem)
|
||||
} catch {
|
||||
NSLog("application setup error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func postStart(_ launchedAsLogInItem: Bool) async throws {
|
||||
try ProfileUpdateTask.setup()
|
||||
if launchedAsLogInItem {
|
||||
if SharedPreferences.startedByUser {
|
||||
if let profile = try await ExtensionProfile.load() {
|
||||
try await profile.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
|
||||
!SharedPreferences.showMenuBarExtra
|
||||
}
|
||||
|
||||
func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||
if !flag, NSApp.activationPolicy() == .accessory {
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors": [
|
||||
{
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "apple-16 1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "apple-16 2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "apple-32 1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "apple-32 2x 1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "apple-128 1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "apple-128 2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "apple-256 1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "apple-256 2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "apple-512 1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "apple-512 2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 274 KiB |