Init commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user