Add CI/CD configuration and API documentation

This commit is contained in:
2026-07-01 21:40:53 +08:00
commit c590135d68
4168 changed files with 740252 additions and 0 deletions
@@ -0,0 +1,6 @@
import Foundation
public class ApplicationLibrary {
public static let bundle = Bundle(for: ApplicationLibrary.self)
public static let inPreview = false
}
@@ -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,76 @@
import Foundation
import Library
public enum ProfileUpdateTask {
static let minUpdateInterval: TimeInterval = 15 * 60
static let defaultUpdateInterval: TimeInterval = 60 * 60
private static var timer: Timer?
public static func configure() async throws {
timer?.invalidate()
timer = nil
let profiles = try await ProfileManager.listAutoUpdateEnabled()
if profiles.isEmpty {
return
}
var updateInterval = profiles.map { it in
it.autoUpdateIntervalOrDefault
}.min()!
if updateInterval < minUpdateInterval {
updateInterval = minUpdateInterval
}
timer = Timer(fire: calculateEarliestBeginDate(profiles), interval: updateInterval, repeats: true) { _ in
Task {
await getAndupdateProfiles()
}
}
}
static func calculateEarliestBeginDate(_ profiles: [Profile]) -> Date {
let nowTime = Date.now
var earliestBeginDate = profiles.map { it in
it.lastUpdated!.addingTimeInterval(it.autoUpdateIntervalOrDefault)
}.min()!
if earliestBeginDate <= nowTime {
earliestBeginDate = nowTime
}
return earliestBeginDate
}
private nonisolated static func getAndupdateProfiles() async {
do {
_ = try await updateProfiles(ProfileManager.listAutoUpdateEnabled())
NSLog("profile update task succeed")
} catch {
NSLog("profile update task failed: \(error.localizedDescription)")
}
}
static func updateProfiles(_ profiles: [Profile]) async -> Bool {
var success = true
for profile in profiles {
if profile.lastUpdated! > Date(timeIntervalSinceNow: -profile.autoUpdateIntervalOrDefault) {
continue
}
do {
try await profile.updateRemoteProfile()
NSLog("Updated profile \(profile.name)")
} catch {
NSLog("Update profile \(profile.name) failed: \(error.localizedDescription)")
success = false
}
}
return success
}
}
extension Profile {
var autoUpdateIntervalOrDefault: TimeInterval {
if autoUpdateInterval > 0 {
return TimeInterval(autoUpdateInterval * 60)
} else {
return ProfileUpdateTask.defaultUpdateInterval
}
}
}
@@ -0,0 +1,139 @@
//
// File.swift
// LoginKit
//
// Created by Mac on 2024/10/12.
//
import Foundation
import Foundation
//MACOS
// UserManager UserDefaults
class StoreManager {
static let shared = StoreManager()
public let configURL = "https://api.gooapis.com/api/vpnconfig.php"
// UserDefaults keys
private let loginStatusKey = "isLoggedIn"
private let userEmailKey = "userEmail"
private let avatarurlKey = "avatar_url"
private let autodataKey = "autodata"
private let subURLDataKey = "subURLData"
private let baseURLKey = "baseURLKey"
// UserDefaults
private let defaults = UserDefaults.standard
func baseURL()-> String{
return UserDefaults.standard.string(forKey: "baseURLKey") ?? ""
}
func mainregisterURL()-> String{
UserDefaults.standard.string(forKey: "mainregisterURLKey") ?? ""
}
func paymentURL()-> String{
UserDefaults.standard.string(forKey: "paymentURLKey") ?? ""
}
func telegramUrl()-> String{
UserDefaults.standard.string(forKey: "telegramUrlKey") ?? ""
}
func kefuUrl()-> String{
UserDefaults.standard.string(forKey: "kefuUrlKey") ?? ""
}
func websiteURL()-> String{
UserDefaults.standard.string(forKey: "websiteURLKey") ?? ""
}
// URL
func storebaseURLData(data: String) {
defaults.set(data, forKey: "baseURLKey")
defaults.synchronize()
}
func storemainregisterURLData(data: String) {
defaults.set(data, forKey: "mainregisterURLKey")
defaults.synchronize()
}
func storepaymentURLData(data: String) {
defaults.set(data, forKey: "paymentURLKey")
defaults.synchronize()
}
func storetelegramUrlData(data: String) {
defaults.set(data, forKey: "telegramUrlKey")
defaults.synchronize()
}
func storekefuUrlData(data: String) {
defaults.set(data, forKey: "kefuUrlKey")
defaults.synchronize()
}
func storewebsiteURLData(data: String) {
defaults.set(data, forKey: "websiteURLKey")
defaults.synchronize()
}
//
func updateLoginStatus(_ loggedIn: Bool) {
defaults.set(loggedIn, forKey: loginStatusKey)
defaults.synchronize()
}
//
func isUserLoggedIn() -> Bool {
return defaults.bool(forKey: loginStatusKey)
}
//
func storeUserInfo(email: String, avator: String) {
defaults.set(email, forKey: userEmailKey)
defaults.set(avator, forKey: avatarurlKey) // info JSON
defaults.synchronize()
}
//
func getUserInfo() -> (email: String, avator: String) {
let email = defaults.string(forKey: userEmailKey) ?? ""
let avator = defaults.string(forKey: avatarurlKey) ?? ""
return (email, avator)
}
// autodata
func storeAutoData(data: String) {
defaults.set(data, forKey: autodataKey)
defaults.synchronize()
}
// autodata
func getSuburlData() -> String {
return defaults.string(forKey: subURLDataKey) ?? ""
}
// autodata
func storeSuburlData(data: String) {
defaults.set(data, forKey: subURLDataKey)
defaults.synchronize()
}
// autodata
func getAutoData() -> String {
return defaults.string(forKey: autodataKey) ?? ""
}
//
func clearUserData() {
defaults.removeObject(forKey: loginStatusKey)
defaults.removeObject(forKey: userEmailKey)
defaults.removeObject(forKey: avatarurlKey)
defaults.removeObject(forKey: autodataKey)
defaults.removeObject(forKey: subURLDataKey)
}
}
@@ -0,0 +1,79 @@
import BackgroundTasks
import Foundation
import Library
#if canImport(UIKit)
import UIKit
#endif
#if os(iOS) || os(tvOS)
public class UIProfileUpdateTask: BGAppRefreshTask {
private static let taskSchedulerPermittedIdentifier = "\(FilePath.packageName).update_profiles"
private static var registered = false
public static func configure() throws {
if !registered {
let success = BGTaskScheduler.shared.register(forTaskWithIdentifier: taskSchedulerPermittedIdentifier, using: nil) { task in
NSLog("profile update task started")
Task {
await UIProfileUpdateTask.getAndUpdateProfiles(task)
}
}
if !success {
throw NSError(domain: "register task failed", code: 0)
}
registered = true
}
Task {
BGTaskScheduler.shared.cancelAllTaskRequests()
let profiles = try await ProfileManager.listAutoUpdateEnabled()
if profiles.isEmpty {
return
}
try scheduleUpdate(ProfileUpdateTask.calculateEarliestBeginDate(profiles))
}
Task {
if await UIApplication.shared.backgroundRefreshStatus != .available {
await updateOnce()
}
}
}
private nonisolated static func updateOnce() async {
NSLog("update profiles at start since background refresh unavailable")
let profiles: [Profile]
do {
profiles = try await ProfileManager.listAutoUpdateEnabled()
} catch {
return
}
if profiles.isEmpty {
return
}
_ = await ProfileUpdateTask.updateProfiles(profiles)
}
private nonisolated static func getAndUpdateProfiles(_ task: BGTask) async {
let profiles: [Profile]
do {
profiles = try await ProfileManager.listAutoUpdateEnabled()
} catch {
return
}
if profiles.isEmpty {
return
}
let success = await ProfileUpdateTask.updateProfiles(profiles)
try? scheduleUpdate(ProfileUpdateTask.calculateEarliestBeginDate(profiles))
task.setTaskCompleted(success: success)
task.expirationHandler = {
try? scheduleUpdate(nil)
}
}
private static func scheduleUpdate(_ earliestBeginDate: Date?) throws {
let request = BGAppRefreshTaskRequest(identifier: taskSchedulerPermittedIdentifier)
request.earliestBeginDate = earliestBeginDate
try BGTaskScheduler.shared.submit(request)
}
}
#endif
@@ -0,0 +1,74 @@
import Foundation
import SwiftUI
public extension Alert {
init(_ error: Error, _ dismissAction: (() -> Void)? = nil) {
self.init(
errorMessage: error.localizedDescription,
dismissAction
)
}
init(errorMessage: String, _ dismissAction: (() -> Void)? = nil) {
self.init(
title: Text("错误提示"),
message: Text(errorMessage),
dismissButton: .default(Text("确定")) {
dismissAction?()
}
)
}
init(title: String = "提示", okMessage: String, _ dismissAction: (() -> Void)? = nil) {
self.init(
title: Text(title),
message: Text(okMessage),
dismissButton: .default(Text("确定")) {
dismissAction?()
}
)
}
init(OKORNOtitle: String = "提示", YesOrNOMessage: String, _ primaryAction: (() -> Void)? = nil) {
self.init(
title: Text(OKORNOtitle),
message: Text(YesOrNOMessage),
primaryButton: .default(Text("确定"), action: {
primaryAction?()
}),
secondaryButton: .cancel(Text("取消"), action: {
})
)
}
}
public extension View {
func alertBinding(_ binding: Binding<Alert?>) -> some View {
alert(isPresented: Binding(get: {
binding.wrappedValue != nil
}, set: { newValue, _ in
if !newValue {
binding.wrappedValue = nil
}
})) {
binding.wrappedValue!
}
}
func alertBinding(_ binding: Binding<Alert?>, _ isLoading: Binding<Bool>) -> some View {
alert(isPresented: Binding(get: {
binding.wrappedValue != nil
}, set: { newValue, _ in
if !newValue, !isLoading.wrappedValue {
binding.wrappedValue = nil
}
})) {
binding.wrappedValue!
}
}
}
@@ -0,0 +1,14 @@
import Foundation
import SwiftUI
public struct BackButton: View {
@Environment(\.dismiss) private var dismiss
public var body: some View {
Button {
dismiss()
} label: {
Image(systemName: "chevron.backward")
}
}
}
@@ -0,0 +1,12 @@
import Foundation
import SwiftUI
public extension Binding {
func withSetter(_ setter: @escaping (Value) -> Void) -> Binding<Value> {
Binding {
wrappedValue
} set: { [setter] newValue, _ in
setter(newValue)
}
}
}
@@ -0,0 +1,29 @@
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
})
}
}
public extension Binding where Value == Int32 {
func stringBinding(defaultValue: Int32) -> Binding<String> {
Binding<String> {
var intValue = wrappedValue
if intValue == 0 {
intValue = defaultValue
}
return String(intValue)
} set: { newValue in
var newIntValue = Int32(newValue) ?? defaultValue
if newIntValue == 0 {
newIntValue = defaultValue
}
wrappedValue = newIntValue
}
}
}
@@ -0,0 +1,46 @@
import Foundation
import SwiftUI
public struct DeleteButton<Label>: View where Label: View {
private let action: () async -> Void
private let label: Label
@State private var performDelete = false
@State private var timer: Timer?
@State private var isLoading = false
public init(action: @escaping () async -> Void, @ViewBuilder label: () -> Label) {
self.action = action
self.label = label()
}
public var body: some View {
Button(role: .destructive) {
isLoading = true
if let timer {
timer.invalidate()
}
if !performDelete {
performDelete = true
timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
timer = nil
performDelete = false
}
isLoading = true
} else {
Task {
await action()
isLoading = false
performDelete = false
}
}
} label: {
if !performDelete {
label
} else {
label.foregroundStyle(.red)
}
}
.disabled(isLoading)
}
}
@@ -0,0 +1,80 @@
import Foundation
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
public enum DeviceCensorship {
public static func isChinaDevice() -> Bool {
let bannedCharacter = "\u{1F1F9}\u{1F1FC}" as NSString
var imageData: Data
#if canImport(UIKit)
let attributes = [NSAttributedString.Key.font:
UIFont.systemFont(ofSize: 8)]
UIGraphicsBeginImageContext(bannedCharacter.size(withAttributes: attributes))
bannedCharacter.draw(at: CGPoint(x: 0, y: 0), withAttributes: attributes)
var imagePNG: Data?
if let charImage = UIGraphicsGetImageFromCurrentImageContext() {
imagePNG = charImage.pngData()
}
UIGraphicsEndImageContext()
guard let imagePNG else {
return false
}
guard let uiImage = UIImage(data: imagePNG) else {
return false
}
guard let cgImage = uiImage.cgImage else { return false }
guard let cgImageData = cgImage.dataProvider?.data as Data? else { return false }
imageData = cgImageData
#elseif canImport(AppKit)
let attributes = [NSAttributedString.Key.font:
NSFont.systemFont(ofSize: 8)]
let characterSize = bannedCharacter.size(withAttributes: attributes)
let characterRect = NSRect(origin: .zero, size: characterSize)
let characterBitmap = NSBitmapImageRep(bitmapDataPlanes: nil,
pixelsWide: Int(characterSize.width),
pixelsHigh: Int(characterSize.height),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: NSColorSpaceName.calibratedRGB,
bytesPerRow: 0,
bitsPerPixel: 0)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: characterBitmap!)
NSGraphicsContext.current?.imageInterpolation = .high
bannedCharacter.draw(in: characterRect, withAttributes: attributes)
NSGraphicsContext.restoreGraphicsState()
guard let imagePNG = characterBitmap?.representation(using: .png, properties: [:]) else {
return false
}
guard let nsImage = NSImage(data: imagePNG) else {
return false
}
guard let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
return false
}
guard let cgImageData = cgImage.dataProvider?.data as Data? else { return false }
imageData = cgImageData
#endif
let rawData: UnsafePointer<UInt8> = CFDataGetBytePtr(imageData as CFData)
for index in stride(from: 0, to: imageData.count, by: 4) {
let r = rawData[index]
let g = rawData[index + 1]
let b = rawData[index + 2]
if !(r == g && g == b) {
return false
}
}
return true
}
}
@@ -0,0 +1,122 @@
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: LocalizedStringKey, _ value: String) -> some View {
HStack {
Text(name)
Spacer()
Text(value)
.multilineTextAlignment(.trailing)
.font(Font.system(.caption, design: .monospaced))
#if os(iOS) || os(macOS)
.textSelection(.enabled)
#endif
}
}
public func FormTextItem(_ name: LocalizedStringKey, _ systemImage: String, @ViewBuilder _ value: () -> some View) -> some View {
HStack {
Label(name, systemImage: systemImage)
Spacer()
value()
.multilineTextAlignment(.trailing)
.font(Font.system(.caption, design: .monospaced))
#if os(iOS) || os(macOS)
.textSelection(.enabled)
#endif
}
}
public func FormItem(_ title: String, @ViewBuilder content: () -> some View) -> some View {
#if os(iOS) || os(tvOS)
HStack {
Text(title)
.lineLimit(1)
.layoutPriority(1)
Spacer()
Spacer()
content()
}
#elseif os(macOS)
content()
#endif
}
public func FormToggle(_ titleKey: LocalizedStringKey, _ subtitleKey: LocalizedStringKey, _ isOn: Binding<Bool>, _ action: @escaping (_ newValue: Bool) async -> Void) -> some View {
#if os(macOS)
Toggle(isOn: isOn) {
VStack(alignment: .leading) {
Text(titleKey)
Spacer()
Text(subtitleKey)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.onChangeCompat(of: isOn.wrappedValue) { newValue in
Task {
await action(newValue)
}
}
#else
Section {
Toggle(titleKey, isOn: isOn)
.onChangeCompat(of: isOn.wrappedValue) { newValue in
Task {
await action(newValue)
}
}
} footer: {
Text(subtitleKey)
.frame(maxWidth: .infinity, alignment: .leading)
}
#endif
}
public func FormButton(action: @escaping () -> Void, @ViewBuilder label: () -> some View) -> some View {
Button(action: action, label: label)
#if os(macOS)
.buttonStyle(.plain)
.foregroundColor(.accentColor)
#endif
}
public func FormButton(_ titleKey: some StringProtocol, action: @escaping () -> Void) -> some View {
Button(titleKey, action: action)
#if os(macOS)
.buttonStyle(.plain)
.foregroundColor(.accentColor)
#endif
}
public func FormButton(role: ButtonRole?, action: @escaping () -> Void, @ViewBuilder label: () -> some View) -> some View {
Button(role: role, action: action, label: label)
#if os(macOS)
.buttonStyle(.plain)
.foregroundColor(.accentColor)
#endif
}
public func FormNavigationLink(@ViewBuilder destination: () -> some View, @ViewBuilder label: () -> some View) -> some View {
#if !os(tvOS)
return NavigationLink(destination: destination, label: label)
#else
return NavigationLink(destination: {
destination()
.toolbar {
ToolbarItemGroup(placement: .topBarLeading) {
BackButton()
}
}
}, label: label)
#endif
}
@@ -0,0 +1,11 @@
import SwiftUI
func NavigationDestinationCompat(isPresented: Binding<Bool>, @ViewBuilder destination: () -> some View) -> some View {
NavigationLink(
destination: destination(),
isActive: isPresented,
label: {
EmptyView()
}
)
}
@@ -0,0 +1,17 @@
import SwiftUI
public func NavigationStackCompat(@ViewBuilder content: () -> some View) -> some View {
viewBuilder {
if #available(iOS 17.0, *) {
// view not updating in iOS 16, but why?
NavigationStack {
content()
}
} else {
NavigationView(content: content)
#if !os(macOS)
.navigationViewStyle(.stack)
#endif
}
}
}
@@ -0,0 +1,49 @@
#if !os(tvOS)
import StoreKit
import SwiftUI
public func RequestReviewButton(label: @escaping () -> some View) -> some View {
viewBuilder {
if #available(iOS 16.0, macOS 13.0, visionOS 1.0, *) {
RequestReviewButton0(label: label)
} else {
#if os(iOS)
RequestReviewButton1(label: label)
#else
EmptyView()
#endif
}
}
}
@available(iOS 16.0, macOS 13.0, visionOS 1.0, *)
struct RequestReviewButton0<Label: View>: View {
@Environment(\.requestReview) private var requestReview
private let label: () -> Label
init(label: @escaping () -> Label) {
self.label = label
}
var body: some View {
FormButton(action: {
requestReview()
}, label: label)
}
}
struct RequestReviewButton1<Label: View>: View {
private let label: () -> Label
init(label: @escaping () -> Label) {
self.label = label
}
var body: some View {
Button(action: {
SKStoreReviewController.requestReview()
}, label: label)
}
}
#endif
@@ -0,0 +1,147 @@
import Foundation
import Library
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
@MainActor
public struct ProfileShareButton<Label>: View where Label: View {
private let alert: Binding<Alert?>
private let profile: Profile
private let label: () -> Label
public init(_ alert: Binding<Alert?>, _ profile: Profile, label: @escaping () -> Label) {
self.alert = alert
self.profile = profile
self.label = label
}
public var body: some View {
#if os(iOS)
if #available(iOS 17.4, *) {
bodyCompat
} else if #available(iOS 16.0, *) {
ShareLink(item: profile, subject: Text(profile.name), preview: SharePreview("Share profile"), label: label)
} else if UIDevice.current.userInterfaceIdiom != .pad {
bodyCompat
}
#else
bodyCompat
#endif
}
private var bodyCompat: some View {
ShareButtonCompat(alert, label: label) {
try profile.toContent().generateShareFile()
}
}
}
public struct ShareButtonCompat<Label>: View where Label: View {
private let label: () -> Label
private let itemURL: () throws -> URL
@Binding private var alert: Alert?
#if os(macOS)
@State private var sharePresented = false
#endif
public init(_ alert: Binding<Alert?>, @ViewBuilder label: @escaping () -> Label, itemURL: @escaping () throws -> URL) {
_alert = alert
self.label = label
self.itemURL = itemURL
}
public var body: some View {
Button(action: shareItem, label: label)
#if os(macOS)
.background(SharingServicePicker($sharePresented, $alert, itemURL))
#endif
}
private func shareItem() {
#if os(iOS)
Task {
await shareItem0()
}
#elseif os(macOS)
sharePresented = true
#endif
}
#if os(iOS)
private nonisolated func shareItem0() async {
do {
let shareItem = try itemURL()
await MainActor.run {
shareItem1(shareItem)
}
} catch {
await MainActor.run {
alert = Alert(error)
}
}
}
private func shareItem1(_ item: URL) {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
windowScene.keyWindow?.rootViewController?.present(UIActivityViewController(activityItems: [item], applicationActivities: nil), animated: true, completion: nil)
}
}
#endif
}
#if os(macOS)
private struct SharingServicePicker: NSViewRepresentable {
@Binding private var isPresented: Bool
@Binding private var alert: Alert?
private let item: () throws -> URL
init(_ isPresented: Binding<Bool>, _ alert: Binding<Alert?>, _ item: @escaping () throws -> URL) {
_isPresented = isPresented
_alert = alert
self.item = item
}
func makeNSView(context _: Context) -> NSView {
let view = NSView()
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
if isPresented {
do {
let picker = try NSSharingServicePicker(items: [item()])
picker.delegate = context.coordinator
DispatchQueue.main.async {
picker.show(relativeTo: .zero, of: nsView, preferredEdge: .minY)
}
} catch {
alert = Alert(error)
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, NSSharingServicePickerDelegate {
private let parent: SharingServicePicker
init(_ parent: SharingServicePicker) {
self.parent = parent
}
func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker, didChoose _: NSSharingService?) {
sharingServicePicker.delegate = nil
parent.isPresented = false
}
}
}
#endif
@@ -0,0 +1,6 @@
import Foundation
import SwiftUI
public func viewBuilder(@ViewBuilder _ builder: () -> some View) -> some View {
builder()
}
@@ -0,0 +1,23 @@
import SwiftUI
public extension View {
func onChangeCompat(of value: some Equatable, _ action: @escaping () -> Void) -> some View {
// if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) {
// return onChange(of: value, action)
// } else {
onChange(of: value) { _ in
action()
}
// }
}
func onChangeCompat<V>(of value: V, _ action: @escaping (_ newValue: V) -> Void) -> some View where V: Equatable {
// if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) {
// return onChange(of: value) { _, newValue in
// action(newValue)
// }
// } else {
onChange(of: value, perform: action)
// }
}
}
@@ -0,0 +1,142 @@
import Foundation
import Libbox
import Library
import SwiftUI
@MainActor
public struct ActiveDashboardView: View {
@Environment(\.scenePhase) var scenePhase
@Environment(\.selection) private var parentSelection
@EnvironmentObject private var environments: ExtensionEnvironments
@EnvironmentObject private var profile: ExtensionProfile
@State private var isLoading = true
@State private var profileList: [ProfilePreview] = []
@State private var selectedProfileID: Int64 = 0
@State private var alert: Alert?
@State private var selection = DashboardPage.overview
@State private var systemProxyAvailable = false
@State private var systemProxyEnabled = false
public init() {}
public var body: some View {
if isLoading {
ProgressView().onAppear {
Task {
await doReload()
}
}
} else {
if ApplicationLibrary.inPreview {
body1
} else {
body1
.onAppear {
Task {
await doReloadSystemProxy()
}
}
.onChangeCompat(of: profile.status) { newStatus in
if newStatus == .connected {
Task {
await doReloadSystemProxy()
}
}
}
}
}
}
private var body1: some View {
VStack {
#if os(iOS) || os(tvOS)
if ApplicationLibrary.inPreview || profile.status.isConnectedStrict {
Picker("Page", selection: $selection) {
ForEach(DashboardPage.allCases) { page in
page.label
}
}
.pickerStyle(.segmented)
#if os(iOS)
.padding([.leading, .trailing])
.navigationBarTitleDisplayMode(.inline)
#endif
TabView(selection: $selection) {
ForEach(DashboardPage.allCases) { page in
page.contentView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled)
.tag(page)
}
}
.tabViewStyle(.page(indexDisplayMode: .always))
} else {
OverviewView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled)
}
#elseif os(macOS)
OverviewView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled)
#endif
}
.onReceive(environments.profileUpdate) { _ in
Task {
await doReload()
}
}
.onReceive(environments.selectedProfileUpdate) { _ in
Task {
selectedProfileID = await SharedPreferences.selectedProfileID.get()
if profile.status.isConnected {
await doReloadSystemProxy()
}
}
}
.alertBinding($alert)
}
private func doReload() async {
defer {
isLoading = false
}
if ApplicationLibrary.inPreview {
profileList = [
ProfilePreview(Profile(id: 0, name: "profile local", type: .local, path: "")),
ProfilePreview(Profile(id: 1, name: "profile remote", type: .remote, path: "", lastUpdated: Date(timeIntervalSince1970: 0))),
]
systemProxyAvailable = true
systemProxyEnabled = true
selectedProfileID = 0
} else {
do {
profileList = try await ProfileManager.list().map { ProfilePreview($0) }
if profileList.isEmpty {
return
}
selectedProfileID = await SharedPreferences.selectedProfileID.get()
if profileList.filter({ profile in
profile.id == selectedProfileID
})
.isEmpty {
selectedProfileID = profileList[0].id
await SharedPreferences.selectedProfileID.set(selectedProfileID)
}
} catch {
alert = Alert(error)
return
}
}
environments.emptyProfiles = profileList.isEmpty
}
private nonisolated func doReloadSystemProxy() async {
do {
let status = try LibboxNewStandaloneCommandClient()!.getSystemProxyStatus()
await MainActor.run {
systemProxyAvailable = status.available
systemProxyEnabled = status.enabled
}
} catch {
await MainActor.run {
alert = Alert(error)
}
}
}
}
@@ -0,0 +1,222 @@
//
// ActiveDashboardViewMACOS.swift
// ApplicationLibrary
//
// Created by Mac on 2024/10/22.
//
import Foundation
import Libbox
import Library
import SwiftUI
@MainActor
public struct ActiveDashboardViewMACOS: View {
@Environment(\.scenePhase) var scenePhase
@Environment(\.selection) private var parentSelection
@EnvironmentObject private var environments: ExtensionEnvironments
@EnvironmentObject private var profile: ExtensionProfile
@State private var isLoading = true
@State private var profileList: [ProfilePreview] = []
@State private var selectedProfileID: Int64 = 0
@State private var alert: Alert?
@State private var selection = DashboardPage.overview
@State private var systemProxyAvailable = false
@State private var systemProxyEnabled = false
public init() {}
public var body: some View {
if isLoading {
ProgressView().onAppear {
Task {
await doReload()
}
}
} else {
if ApplicationLibrary.inPreview {
body1
} else {
body1
.onAppear {
Task {
await doReloadSystemProxy()
}
}
.onChangeCompat(of: profile.status) { newStatus in
if newStatus == .connected {
Task {
await doReloadSystemProxy()
}
}
}
}
}
}
private var body1: some View {
VStack {
#if os(iOS) || os(tvOS)
if ApplicationLibrary.inPreview || profile.status.isConnectedStrict {
/* Picker("Page", selection: $selection) {
ForEach(DashboardPage.allCases) { page in
page.label
}
}
.pickerStyle(.segmented)
#if os(iOS)
.padding([.leading, .trailing])
.navigationBarTitleDisplayMode(.inline)
#endif
TabView(selection: $selection) {
ForEach(DashboardPage.allCases) { page in
page.contentView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled)
.tag(page)
}
}
.tabViewStyle(.page(indexDisplayMode: .always))
*/
OverviewView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled)
} else {
OverviewView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled)
}
#elseif os(macOS)
OverviewView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled)
#endif
}
.onReceive(environments.profileUpdate) { _ in
Task {
await doReload()
}
}
.onReceive(environments.selectedProfileUpdate) { _ in
Task {
selectedProfileID = await SharedPreferences.selectedProfileID.get()
if profile.status.isConnected {
await doReloadSystemProxy()
}
}
}
.alertBinding($alert)
}
private func doReload() async {
defer {
isLoading = false
}
if ApplicationLibrary.inPreview {
profileList = [
ProfilePreview(Profile(id: 0, name: "profile local", type: .local, path: "")),
ProfilePreview(Profile(id: 1, name: "profile remote", type: .remote, path: "", lastUpdated: Date(timeIntervalSince1970: 0))),
]
systemProxyAvailable = true
systemProxyEnabled = true
selectedProfileID = 0
} else {
do {
profileList = try await ProfileManager.list().map { ProfilePreview($0) }
if profileList.isEmpty {
//add profile remote
Task {
if StoreManager.shared.getSuburlData().count > 5 {
await createProfile()
}
}
await environments.reload()
return
}
selectedProfileID = await SharedPreferences.selectedProfileID.get()
if profileList.filter({ profile in
profile.id == selectedProfileID
})
.isEmpty {
selectedProfileID = profileList[0].id
await SharedPreferences.selectedProfileID.set(selectedProfileID)
}
// try await ProfileManager.list().forEach { p in
// await ProfileManager.update(p)
// }
} catch {
alert = Alert(error)
return
}
}
environments.emptyProfiles = profileList.isEmpty
}
private nonisolated func doReloadSystemProxy() async {
do {
let status = try LibboxNewStandaloneCommandClient()!.getSystemProxyStatus()
await MainActor.run {
systemProxyAvailable = status.available
systemProxyEnabled = status.enabled
}
} catch {
await MainActor.run {
alert = Alert(error)
}
}
}
private func createProfile() async {
do {
try await createProfileBackground()
} catch {
print(error)
}
environments.profileUpdate.send()
}
private nonisolated func createProfileBackground() async throws {
let nextProfileID = try await ProfileManager.nextID()
var savePath = ""
let remoteURL: String = StoreManager.shared.getSuburlData()
var lastUpdated: Date? = nil
let remoteContent = try HTTPClient().getString(remoteURL)
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
lastUpdated = .now
try await ProfileManager.create(Profile(
name: "VPN",
type: ProfileType.remote,
path: savePath,
remoteURL: remoteURL,
autoUpdate: true,
autoUpdateInterval: 60,
lastUpdated: lastUpdated
))
#if os(iOS) || os(tvOS)
try UIProfileUpdateTask.configure()
let list = try await ProfileManager.list()
list.forEach { profileOne in
print("\(profileOne.path) \n \(profileOne.remoteURL ?? "") \n \(profileOne.id ?? 0 )")
}
#else
try await ProfileUpdateTask.configure()
#endif
}
}
@@ -0,0 +1,114 @@
import Libbox
import Library
import SwiftUI
@MainActor
public struct ClashModeView: View {
@Environment(\.scenePhase) private var scenePhase
@StateObject private var commandClient = CommandClient(.clashMode)
@State private var clashMode = ""
@State private var alert: Alert?
public init() {}
public var body: some View {
VStack {
if commandClient.clashModeList.count > 1 {
Picker("", selection: Binding(get: {
clashMode
}, set: { newMode in
clashMode = newMode
Task {
await setClashMode(newMode)
await restartVPNServer()
}
}), content: {
ForEach(commandClient.clashModeList, id: \.self) { it in
/*if it.lowercased() == "rule" {
Text("")
}else if it.lowercased() == "direct" {
Text("")
}else if it.lowercased() == "global" {
Text("")
}else{
}*/
Text(it)
}
})
.pickerStyle(.segmented)
.padding([.top], 8)
}else{
//DEBUG
/*
let newclashModeList = ["","",""]
Picker("", selection: Binding(get: {
clashMode
}, set: { newMode in
}), content: {
ForEach(newclashModeList, id: \.self) { it in
/*if it.lowercased() == "rule" {
Text("")
}else if it.lowercased() == "direct" {
Text("")
}else if it.lowercased() == "global" {
Text("")
}else{
Text(it)
}*/
Text(it)
}
})
.pickerStyle(.segmented)
.padding([.top], 8)
*/
}
}
.onReceive(commandClient.$clashMode) { newMode in
clashMode = newMode
}
.padding([.leading, .trailing])
.onAppear {
commandClient.connect()
}
.onDisappear {
commandClient.disconnect()
}
.onChangeCompat(of: scenePhase) { newValue in
print("\(String(describing: commandClient.status))")
if newValue == .active {
commandClient.connect()
} else {
commandClient.disconnect()
}
}
.alertBinding($alert)
}
private nonisolated func setClashMode(_ newMode: String) async {
do {
try LibboxNewStandaloneCommandClient()!.setClashMode(newMode)
} catch {
await MainActor.run {
alert = Alert(error)
}
}
}
private nonisolated func restartVPNServer() async {
do {
try LibboxNewStandaloneCommandClient()!.serviceReload()
} catch {
await MainActor.run {
alert = Alert(error)
}
}
}
}
@@ -0,0 +1,46 @@
import Foundation
import Library
import SwiftUI
public enum DashboardPage: Int, CaseIterable, Identifiable {
public var id: Self {
self
}
case overview
case groups
}
public extension DashboardPage {
var title: String {
switch self {
case .overview:
return NSLocalizedString("Overview", comment: "")
case .groups:
return NSLocalizedString("Groups", comment: "")
}
}
var label: some View {
switch self {
case .overview:
return Label("Overview", systemImage: "text.and.command.macwindow")
case .groups:
return Label("Groups", systemImage: "rectangle.3.group.fill")
}
}
@MainActor
func contentView(_ profileList: Binding<[ProfilePreview]>, _ selectedProfileID: Binding<Int64>, _ systemProxyAvailable: Binding<Bool>, _ systemProxyEnabled: Binding<Bool>) -> some View {
viewBuilder {
switch self {
case .overview:
OverviewView(profileList, selectedProfileID, systemProxyAvailable, systemProxyEnabled)
case .groups:
GroupListView()
}
}
}
}
@@ -0,0 +1,246 @@
import Libbox
import Library
import SwiftUI
@MainActor
public struct DashboardView: View {
#if os(macOS)
@Environment(\.controlActiveState) private var controlActiveState
@State private var isLoading = true
@State private var systemExtensionInstalled = true
#endif
public init() {}
public var body: some View {
viewBuilder {
#if os(macOS)
HStack{
Button {
withAnimation{
// isUserViewActive.toggle()
}
} label: {
Image(systemName: "circle.grid.cross")
.font(.title2)
.padding(5)
.foregroundColor(.white)
.background(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(.white.opacity(0.25),lineWidth: 1)
)
}
Spacer()
/* Button {
withAnimation{
isSubscriptionActive.toggle()
}
} label: {
Image(systemName: "slider.horizontal.3")
.font(.title2)
.padding(12)
.background(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(.white.opacity(0.25),lineWidth: 1)
)
} */
// Text Bubble
Button {
withAnimation{
// isSubscriptionActive.toggle()
}
} label: {
Text("超值折扣")
.font(.footnote)
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(Color.orange)
)
.overlay(
TriangleShape()
.fill(Color.orange)
.frame(width: 10, height: 10)
.offset(x: 10)
, alignment: .trailing
)
// Panda Image with VIP Tag
HStack(spacing: 10){
Text(verbatim: "")
Image("applogo") // Replace with actual image asset
.resizable()
.frame(width: 30, height: 30)
.cornerRadius(15)
// Text("VIP")
// .font(.caption)
// .fontWeight(.bold)
// .foregroundColor(.yellow)
// .padding(4)
// .background(Color.orange)
// .cornerRadius(4)
}
}
.padding()
Button {
withAnimation{
// isKefuActive.toggle()
}
} label: {
Image(systemName: "person.fill.questionmark")
.font(.title2)
.padding(5)
.foregroundColor(.white)
.background(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(.white.opacity(0.25),lineWidth: 1)
)
Text("客服").foregroundColor(.white)
}
}
if Variant.useSystemExtension {
viewBuilder {
if !systemExtensionInstalled {
FormView {
InstallSystemExtensionButton {
await reload()
}
}
} else {
DashboardView0()
}
}.onAppear {
Task {
await reload()
}
}
} else {
DashboardView0()
}
#else
DashboardView0()
#endif
}
#if os(macOS)
.onChangeCompat(of: controlActiveState) { newValue in
if newValue != .inactive {
if Variant.useSystemExtension {
if !isLoading {
Task {
await reload()
}
}
}
}
}
#endif
}
struct TriangleShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
path.closeSubpath()
return path
}
}
#if os(macOS)
private nonisolated func reload() async {
let systemExtensionInstalled = await SystemExtension.isInstalled()
await MainActor.run {
self.systemExtensionInstalled = systemExtensionInstalled
isLoading = false
}
}
#endif
struct DashboardView0: View {
@EnvironmentObject private var environments: ExtensionEnvironments
var body: some View {
if ApplicationLibrary.inPreview {
// ActiveDashboardView()/
ActiveDashboardViewMACOS()
} else if environments.extensionProfileLoading {
ProgressView()
} else if let profile = environments.extensionProfile {
DashboardView1().environmentObject(profile)
} else {
FormView {
InstallProfileButton { error in
//add profile remote
await environments.reload()
}
}
}
}
}
struct DashboardView1: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@EnvironmentObject private var profile: ExtensionProfile
@State private var alert: Alert?
var body: some View {
VStack {
// ActiveDashboardView()
ActiveDashboardViewMACOS()
}
.alertBinding($alert)
.onChangeCompat(of: profile.status) { newValue in
if newValue == .disconnecting || newValue == .connected {
Task {
await checkServiceError()
}
}
}
}
private nonisolated func checkServiceError() async {
var error: NSError?
let message = LibboxReadServiceError(&error)
if error != nil {
return
}
await MainActor.run {
alert = Alert(title: Text("Service Error"), message: Text(message))
}
}
}
}
@@ -0,0 +1,134 @@
//
// DashboardViewMacOS.swift
// ApplicationLibrary
//
// Created by Mac on 2024/10/22.
//
import Libbox
import Library
import SwiftUI
@MainActor
public struct DashboardViewMacOS: View {
#if os(macOS)
@Environment(\.controlActiveState) private var controlActiveState
@State private var isLoading = true
@State private var systemExtensionInstalled = true
#endif
public init() {}
public var body: some View {
viewBuilder {
#if os(macOS)
if Variant.useSystemExtension {
viewBuilder {
if !systemExtensionInstalled {
FormView {
InstallSystemExtensionButton {
await reload()
}
}
} else {
DashboardView0()
}
}.onAppear {
Task {
await reload()
}
}
} else {
DashboardView0()
}
#else
DashboardView0()
#endif
}
#if os(macOS)
.onChangeCompat(of: controlActiveState) { newValue in
if newValue != .inactive {
if Variant.useSystemExtension {
if !isLoading {
Task {
await reload()
}
}
}
}
}
#endif
}
#if os(macOS)
private nonisolated func reload() async {
let systemExtensionInstalled = await SystemExtension.isInstalled()
await MainActor.run {
self.systemExtensionInstalled = systemExtensionInstalled
isLoading = false
}
}
#endif
struct DashboardView0: View {
@EnvironmentObject private var environments: ExtensionEnvironments
var body: some View {
if ApplicationLibrary.inPreview {
ActiveDashboardView()
} else if environments.extensionProfileLoading {
ProgressView()
} else if let profile = environments.extensionProfile {
DashboardView1().environmentObject(profile)
} else {
FormView {
InstallProfileButton {
//add profile remote
await environments.reload()
}
}
}
}
}
struct DashboardView1: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@EnvironmentObject private var profile: ExtensionProfile
@State private var alert: Alert?
var body: some View {
VStack {
ActiveDashboardView()
}
.alertBinding($alert)
.onChangeCompat(of: profile.status) { newValue in
if newValue == .disconnecting || newValue == .connected {
Task {
await checkServiceError()
}
}
}
}
private nonisolated func checkServiceError() async {
var error: NSError?
let message = LibboxReadServiceError(&error)
if error != nil {
return
}
await MainActor.run {
alert = Alert(title: Text("Service Error"), message: Text(message))
}
}
}
}
@@ -0,0 +1,186 @@
import Libbox
import Library
import SwiftUI
public struct ExtensionStatusView: View {
@Environment(\.scenePhase) private var scenePhase
@StateObject private var commandClient = CommandClient(.status)
@State private var columnCount: Int = 4
@State private var alert: Alert?
private let infoFont = Font.system(.caption, design: .monospaced)
public init() {}
public var body: some View {
if columnCount == 1 {
ScrollView {
body0
}
} else {
body0
}
}
public var body0: some View {
viewBuilder {
VStack {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: columnCount), alignment: .leading) {
if ApplicationLibrary.inPreview {
StatusItem("Status") {
StatusLine("Memory", "6.4 MB")
StatusLine("Goroutines", "89")
}
StatusItem("Connections") {
StatusLine("Inbound", "34")
StatusLine("Outbound", "28")
}
StatusItem("Traffic") {
StatusLine("Uplink", "38 B/s")
StatusLine("Downlink", "249 MB/s")
}
StatusItem("TrafficTotal") {
StatusLine("Uplink", "52 MB")
StatusLine("Downlink", "5.6 GB")
}
} else if let message = commandClient.status {
// StatusItem("Status") {
// StatusLine("Memory", LibboxFormatMemoryBytes(message.memory))
// StatusLine("Goroutines", "\(message.goroutines)")
// }
// StatusItem("Connections") {
// StatusLine("Inbound", "\(message.connectionsIn)")
// StatusLine("Outbound", "\(message.connectionsOut)")
// }
if message.trafficAvailable {
// StatusItem("") {
// StatusLine("", "\(LibboxFormatBytes(message.uplink))/s")
// StatusLine("", "\(LibboxFormatBytes(message.downlink))/s")
// }
StatusItem("总流量") {
HStack(spacing: 10, content: {
StatusLine("上传", LibboxFormatBytes(message.uplinkTotal))
Spacer()
StatusLine("下载", LibboxFormatBytes(message.downlinkTotal))
})
}
}
} else {
StatusItem("总流量") {
HStack(spacing: 10, content: {
StatusLine("上传", ".../s")
Spacer()
StatusLine("下载", ".../s")
})
}
// StatusItem("Connections") {
// StatusLine("Inbound", "...")
// StatusLine("Outbound", "...")
// }
}
}.background {
GeometryReader { geometry in
Rectangle()
.fill(.clear)
.frame(height: 1)
.onChangeCompat(of: geometry.size.width) { newValue in
updateColumnCount(newValue)
}
.onAppear {
updateColumnCount(geometry.size.width)
}
}.padding()
}
}
.frame(alignment: .topLeading)
.padding([.top, .leading, .trailing])
}
.onAppear {
commandClient.connect()
}
.onDisappear {
commandClient.disconnect()
}
.onChangeCompat(of: scenePhase) { newValue in
if newValue == .active {
commandClient.connect()
} else {
commandClient.disconnect()
}
}
.alertBinding($alert)
}
private func updateColumnCount(_ width: Double) {
let v = Int(Int(width) / 155)
let new = v <= 1 ? 1 : (v > 4 ? 4 : (v % 2 == 0 ? v : v - 1))
if new != columnCount {
columnCount = new
}
//
print(columnCount)
columnCount = 1
}
private struct StatusItem<T>: View where T: View {
private let title: String
@ViewBuilder private let content: () -> T
init(_ title: String, @ViewBuilder content: @escaping () -> T) {
self.title = title
self.content = content
}
var body: some View {
VStack(alignment: .leading) {
HStack {
Text(title)
.font(.subheadline)
Spacer()
}.padding(.bottom, 8)
content()
}
.frame(minWidth: 125, alignment: .topLeading)
#if os(tvOS)
.padding(EdgeInsets(top: 20, leading: 26, bottom: 20, trailing: 26))
#else
.padding(EdgeInsets(top: 10, leading: 13, bottom: 10, trailing: 13))
#endif
.background(backgroundColor.opacity(0.7))
.cornerRadius(10)
}
private var backgroundColor: Color {
#if os(iOS)
return Color(uiColor: .secondarySystemGroupedBackground)
#elseif os(macOS)
return Color(nsColor: .textBackgroundColor)
#elseif os(tvOS)
return Color(uiColor: .black)
#endif
}
}
private struct StatusLine: View {
private let name: String
private let value: String
init(_ name: String, _ value: String) {
self.name = name
self.value = value
}
var body: some View {
HStack {
Text(name)
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
Text(value)
.font(.subheadline)
}
}
}
}
@@ -0,0 +1,79 @@
import Library
import SwiftUI
@MainActor
public struct InstallProfileButton: View {
private let callback: (Error?) async -> Void
public init(_ callback: @escaping ((Error?) async -> Void)) {
self.callback = callback
}
public var body: some View {
parsePowerButton()
/* FormButton {
Task {
await installProfile()
}
} label: {
Label("VPN", systemImage: "lock.doc.fill")
.font(.title3)
.padding(15)
.foregroundColor(.red)
.background(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(.white.opacity(0.25),lineWidth: 1)
)
}
.alertBinding($alert)*/
}
@ViewBuilder
func parsePowerButton()->some View{
Button {
Task {
await installProfile()
}
} label: {
ZStack{
ZStack{
//
// LottieView(animationFileName: "1d2a0fe5" , loopMode: .loop)
LottieView(animationFileName: "8dfa14a6" , loopMode: .loop).scaleEffect(0.2)
Text("开启服务").bold().font(.title2).foregroundStyle(Color.white)
}
}
// Max Frame...
.frame(width: 190,height: 190)
}.padding()
}
private func installProfile() async {
print("installProfile...")
do {
try await ExtensionProfile.install()
await callback(nil)
} catch {
print("installProfile: \(error.localizedDescription)")
await callback(error)
// alert = Alert(error)
}
}
}
@@ -0,0 +1,39 @@
#if os(macOS)
import Library
import SwiftUI
@MainActor
public struct InstallSystemExtensionButton: View {
@State private var alert: Alert?
private let callback: () async -> Void
public init(_ callback: @escaping () async -> Void) {
self.callback = callback
}
public var body: some View {
FormButton {
Task {
await installSystemExtension()
}
} label: {
Label("Install System Extension", systemImage: "lock.doc.fill")
}
.alertBinding($alert)
}
private func installSystemExtension() async {
do {
if let result = try await SystemExtension.install() {
if result == .willCompleteAfterReboot {
alert = Alert(errorMessage: "Need Reboot")
}
}
await callback()
} catch {
alert = Alert(error)
}
}
}
#endif
@@ -0,0 +1,296 @@
import Foundation
import Libbox
import Library
import SwiftUI
@MainActor
public struct OverviewView: View {
@Environment(\.selection) private var selection
@EnvironmentObject private var environments: ExtensionEnvironments
@EnvironmentObject private var profile: ExtensionProfile
@Binding private var profileList: [ProfilePreview]
@Binding private var selectedProfileID: Int64
@Binding private var systemProxyAvailable: Bool
@Binding private var systemProxyEnabled: Bool
@State private var alert: Alert?
@State private var reasserting = false
private var selectedProfileIDLocal: Binding<Int64> {
$selectedProfileID.withSetter { newValue in
reasserting = true
Task { [self] in
await switchProfile(newValue)
}
}
}
public init(_ profileList: Binding<[ProfilePreview]>, _ selectedProfileID: Binding<Int64>, _ systemProxyAvailable: Binding<Bool>, _ systemProxyEnabled: Binding<Bool>) {
_profileList = profileList
_selectedProfileID = selectedProfileID
_systemProxyAvailable = systemProxyAvailable
_systemProxyEnabled = systemProxyEnabled
}
public var body: some View {
if profileList.isEmpty
{
VStack {
parsePowerButton()
}.alertBinding($alert)
}else{
VStack {
#if os(iOS) || os(tvOS)
StartStopButton()
#elseif os(macOS)
StartStopButton().buttonStyle(PlainButtonStyle()) //
#endif
if ApplicationLibrary.inPreview || profile.status.isConnected {
ClashModeView()
ExtensionStatusView()
Spacer()
}
}.alertBinding($alert)
.disabled(!ApplicationLibrary.inPreview && (!profile.status.isSwitchable || reasserting))
}
}
@ViewBuilder
func parsePowerButton()->some View{
VStack{
Button {
} label: {
ZStack{
ZStack{
LottieView(animationFileName: "1d2a0fe5" , loopMode: .playOnce).scaleEffect(0.2)
}
}
// Max Frame...
.frame(width: 190,height: 190)
.background(
ZStack{
// Rings....
Circle()
.trim(from: 0.3, to: 0.5)
.stroke(
LinearGradient(colors: [
Color("gray"),
Color("gray")
.opacity(0.5),
Color("gray")
.opacity(0.3),
Color( "gray")
.opacity(0.1),
], startPoint: .leading, endPoint: .trailing),
style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round)
)
// Shadows...
.shadow(color: Color( "gray"), radius: 5, x: 1, y: -4)
Circle()
.trim(from: 0.3, to: 0.55)
.stroke(
LinearGradient(colors: [
Color( "gray2"),
Color( "gray2")
.opacity(0.5),
Color("gray2")
.opacity(0.3),
Color( "gray2")
.opacity(0.1),
], startPoint: .leading, endPoint: .trailing),
style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round)
)
// Shadows...
.shadow(color: Color( "gray2"), radius: 5, x: 1, y: -4)
.rotationEffect(.init(degrees: 160))
// Main Little Ring...
Circle()
.stroke(
Color("Ring1")
.opacity(0.01),
lineWidth: 11
)
// Toggling Shadow when button is Clicked...
.shadow(color: Color("Ring2").opacity( 0), radius: 5, x: 1, y: -4)
}
)
}.padding().disabled(true)
// .padding(.top,UIScreen.main.bounds.height < 750 ? 30 : 100)
Text("正在初始化...").font(.system(size: 18, weight: .semibold)).foregroundStyle(.white)
}
}
public var bodyOld: some View {
VStack {
if profileList.isEmpty {
VStack{
LottieView(animationFileName: "8c31d47d" , loopMode: .loop)
} .aspectRatio(contentMode: .fill)
.scaleEffect(0.1).frame(width: 190,height: 190).padding()
// VStack{
// LottieView(animationFileName: "51a05581" , loopMode: .loop)
// }.padding()
// Spacer()
//Text("Empty profiles")
//retry download profiles
} else {
#if os(iOS) || os(tvOS)
StartStopButton()
/**
Picker("Profile", selection: selectedProfileIDLocal, content: {
ForEach(profileList, id: \.self) { it in
Text(it.name).tag(it.id)
}
})
.pickerStyle(.segmented)
.padding([.top], 8)
*/
// Section("Profile") {
// Picker(selection: selectedProfileIDLocal) {
// ForEach(profileList, id: \.id) { profile in
// Text(profile.name).tag(profile.id)
// }
// } label: {}
// .pickerStyle(.inline)
// }
#elseif os(macOS)
StartStopButton().buttonStyle(PlainButtonStyle()) //
/*if ApplicationLibrary.inPreview || profile.status.isConnectedStrict, systemProxyAvailable {
Toggle("HTTP Proxy", isOn: $systemProxyEnabled)
.onChangeCompat(of: systemProxyEnabled) { newValue in
Task {
await setSystemProxyEnabled(newValue)
}
}
}
Section("Profile") {
ForEach(profileList, id: \.id) { profile in
Picker(profile.name, selection: selectedProfileIDLocal) {
Text("").tag(profile.id)
}
}
.pickerStyle(.radioGroup)
}*/
#endif
/*
FormView {
#if os(iOS) || os(tvOS)
StartStopButton()
if ApplicationLibrary.inPreview || profile.status.isConnectedStrict, systemProxyAvailable {
Toggle("HTTP Proxy", isOn: $systemProxyEnabled)
.onChangeCompat(of: systemProxyEnabled) { newValue in
Task {
await setSystemProxyEnabled(newValue)
}
}
}
/*
// Profile
Section("Profile") {
Picker(selection: selectedProfileIDLocal) {
ForEach(profileList, id: \.id) { profile in
Text(profile.name).tag(profile.id)
}
} label: {}
.pickerStyle(.inline)
}*/
#elseif os(macOS)
if ApplicationLibrary.inPreview || profile.status.isConnectedStrict, systemProxyAvailable {
Toggle("HTTP Proxy", isOn: $systemProxyEnabled)
.onChangeCompat(of: systemProxyEnabled) { newValue in
Task {
await setSystemProxyEnabled(newValue)
}
}
}
Section("Profile") {
ForEach(profileList, id: \.id) { profile in
Picker(profile.name, selection: selectedProfileIDLocal) {
Text("").tag(profile.id)
}
}
.pickerStyle(.radioGroup)
}
#endif
}
*/
if ApplicationLibrary.inPreview || profile.status.isConnected {
ClashModeView()
ExtensionStatusView()
}
}
}
.alertBinding($alert)
.disabled(!ApplicationLibrary.inPreview && (!profile.status.isSwitchable || reasserting))
}
private func switchProfile(_ newProfileID: Int64) async {
await SharedPreferences.selectedProfileID.set(newProfileID)
environments.selectedProfileUpdate.send()
if profile.status.isConnected {
do {
try await serviceReload()
} catch {
alert = Alert(error)
}
}
reasserting = false
}
private nonisolated func serviceReload() async throws {
try LibboxNewStandaloneCommandClient()!.serviceReload()
}
private nonisolated func setSystemProxyEnabled(_ isEnabled: Bool) async {
do {
try LibboxNewStandaloneCommandClient()!.setSystemProxyEnabled(isEnabled)
await SharedPreferences.systemProxyEnabled.set(isEnabled)
} catch {
await MainActor.run {
alert = Alert(error)
}
}
}
}
@@ -0,0 +1,335 @@
import Library
import Lottie
import NetworkExtension
import SwiftUI
import Libbox
import Library
import SwiftUI
@MainActor
public struct StartStopButton: View {
@EnvironmentObject private var environments: ExtensionEnvironments
public init() {}
public var body: some View {
viewBuilder {
if ApplicationLibrary.inPreview {
#if os(iOS) || os(tvOS)
Toggle(isOn: .constant(true)) {
Text("Enabled")
}
#elseif os(macOS)
Button {} label: {
Label("Stop", systemImage: "stop.fill")
}
#endif
} else if let profile = environments.extensionProfile {
Button0().environmentObject(profile)
} else {
#if os(iOS) || os(tvOS)
EmptyView()
#elseif os(macOS)
Button {} label: {
Label("Start", systemImage: "play.fill")
}
.disabled(true)
#endif
}
}
.disabled(environments.emptyProfiles)
}
private struct Button0: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@EnvironmentObject private var profile: ExtensionProfile
@State private var alert: Alert?
@State var vpnStatus : NEVPNStatus = .invalid
var body: some View {
viewBuilder {
#if os(iOS) || os(tvOS)
/* Toggle(isOn: Binding(get: {
profile.status.isConnected
}, set: { newValue, _ in
Task {
await switchProfile(newValue)
}
})) {
Text("Enabled")
}*/
VStack{
PowerButton()
Label {
if vpnStatus == .connected {
Text("已连接")
}else if vpnStatus == .disconnected {
Text("断开连接")
}else if vpnStatus == .connecting {
Text("连接中...")
}else if vpnStatus == .disconnecting {
Text("断开中...")
}else {
Text("切换中...")
}
} icon: {
Image(systemName: vpnStatus.isConnectedStrict ? "checkmark.shield" : "shield.slash")
}
.font(.system(size: 18, weight: .semibold))
// Spacer()
}
#elseif os(macOS)
VStack{
PowerButton().background(Color.clear)
Label {
if vpnStatus == .connected {
Text("已连接")
}else if vpnStatus == .disconnected {
Text("断开连接")
}else if vpnStatus == .connecting {
Text("连接中...")
}else if vpnStatus == .disconnecting {
Text("断开中...")
}else {
Text("切换中...")
}
} icon: {
Image(systemName: vpnStatus.isConnectedStrict ? "checkmark.shield" : "shield.slash")
}
.font(.system(size: 18, weight: .semibold))
//Spacer()
}
/*Button {
Task {
await switchProfile(!profile.status.isConnectedStrict)
}
} label: {
if !profile.status.isConnected {
Label("Start", systemImage: "play.fill")
} else {
Label("Stop", systemImage: "stop.fill")
}
}
*/
#endif
}
.disabled(!profile.status.isEnabled)
.alertBinding($alert)
.onAppear {
Task{
vpnStatus = profile.status //.isConnected
}
}.onChangeCompat(of: profile.status) { newStatus in
vpnStatus = newStatus
// if newStatus == .connected {
// Task {
// await doReloadSystemProxy()
// }
// }
}
}
@ViewBuilder
func PowerButton()->some View{
Button {
Task {
if self.vpnStatus.isConnected {
await switchProfile(false)
}else{
await switchProfile(true)
}
}
} label: {
ZStack{
ZStack{
#if os(iOS) || os(tvOS)
#endif
if vpnStatus == .connected {
LottieView(animationFileName: "51a05581" , loopMode: .playOnce).scaleEffect(0.5)
}else if (vpnStatus == .connecting || vpnStatus == .disconnecting) {
LottieView(animationFileName: "65ea130a" , loopMode: .loop)
} else{
LottieView(animationFileName: "1d2a0fe5" , loopMode: .playOnce).scaleEffect(0.2)
}
}
}
// Max Frame...
.frame(width: 190,height: 190)
.background(
ZStack{
// Rings....
Circle()
.trim(from: vpnStatus.isConnected ? 0 : 0.3, to: vpnStatus.isConnected ? 1 : 0.5)
.stroke(
LinearGradient(colors: [
Color(vpnStatus.isConnected ? "Ring1" : "gray"),
Color(vpnStatus.isConnected ? "Ring1" : "gray")
.opacity(0.5),
Color(vpnStatus.isConnected ? "Ring1" : "gray")
.opacity(0.3),
Color(vpnStatus.isConnected ? "Ring1" : "gray")
.opacity(0.1),
], startPoint: .leading, endPoint: .trailing),
style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round)
)
// Shadows...
.shadow(color: Color(vpnStatus.isConnected ? "Ring1" : "gray"), radius: 5, x: 1, y: -4)
Circle()
.trim(from: vpnStatus.isConnected ? 0 : 0.3, to: vpnStatus.isConnected ? 1 : 0.55)
.stroke(
LinearGradient(colors: [
Color(vpnStatus.isConnected ? "Ring2" : "gray2"),
Color(vpnStatus.isConnected ? "Ring2" : "gray2")
.opacity(0.5),
Color(vpnStatus.isConnected ? "Ring2" : "gray2")
.opacity(0.3),
Color(vpnStatus.isConnected ? "Ring2" : "gray2")
.opacity(0.1),
], startPoint: .leading, endPoint: .trailing),
style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round)
)
// Shadows...
.shadow(color: Color(vpnStatus.isConnected ? "Ring2" : "gray2"), radius: 5, x: 1, y: -4)
.rotationEffect(.init(degrees: 160))
// Main Little Ring...
Circle()
.stroke(
Color("Ring1")
.opacity(0.01),
lineWidth: 11
)
// Toggling Shadow when button is Clicked...
.shadow(color: Color("Ring2").opacity(vpnStatus.isConnected ? 0.04 : 0), radius: 5, x: 1, y: -4)
}
)
}.padding()
// .padding(.top,UIScreen.main.bounds.height < 750 ? 30 : 100)
}
private nonisolated func switchProfile(_ isEnabled: Bool) async {
do {
if isEnabled {
try await profile.start()
await environments.logClient.connect()
} else {
try await profile.stop()
}
} catch {
await MainActor.run {
alert = Alert(error)
}
}
}
/*private nonisolated func doReloadSystemProxy() async {
do {
let status = try LibboxNewStandaloneCommandClient()!.getSystemProxyStatus()
await MainActor.run {
}
} catch {
await MainActor.run {
alert = Alert(error)
}
}
}*/
}
}
#if os(iOS) || os(tvOS)
struct LottieView: UIViewRepresentable {
var animationFileName: String
let loopMode: LottieLoopMode
func updateUIView(_ uiView: UIViewType, context: Context) {
}
func makeUIView(context: Context) -> Lottie.LottieAnimationView {
let animationView = LottieAnimationView(name: animationFileName)
animationView.loopMode = loopMode
animationView.play()
animationView.contentMode = .scaleAspectFit
return animationView
}
}
#elseif os(macOS)
struct LottieView: NSViewRepresentable {
var animationFileName: String
let loopMode: LottieLoopMode
func makeNSView(context: Context) -> NSView {
let view = NSView()
let animationView = LottieAnimationView(name: animationFileName)
animationView.loopMode = loopMode
animationView.play()
animationView.contentMode = .scaleAspectFit
// Add the Lottie animation view as a subview of the NSView
animationView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(animationView)
// Set up constraints to fill the parent view
NSLayoutConstraint.activate([
animationView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
animationView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
animationView.topAnchor.constraint(equalTo: view.topAnchor),
animationView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
// Optionally implement updates to the NSView
}
}
#endif
@@ -0,0 +1,58 @@
//
// XufeiTixingButton.swift
// ApplicationLibrary
//
// Created by Mac on 2024/10/26.
//
import Foundation
//
import Library
import SwiftUI
@MainActor
public struct XufeiTixingButton: View {
private let callback: () async -> Void
public init(_ callback: @escaping (() async -> Void)) {
self.callback = callback
}
public var body: some View {
parsePowerButton()
}
@ViewBuilder
func parsePowerButton()->some View{
Button {
Task {
await callback()
}
} label: {
ZStack{
ZStack{
//
// LottieView(animationFileName: "1d2a0fe5" , loopMode: .loop)
LottieView(animationFileName: "8dfa14a6" , loopMode: .loop).scaleEffect(0.2)
Text("开启服务").bold().font(.title2).foregroundStyle(Color.white)
//LottieView(animationFileName: "8dfa14a6" , loopMode: .loop).scaleEffect(0.5)
}
}
// Max Frame...
.frame(width: 190,height: 190)
}.padding()
}
}
@@ -0,0 +1,58 @@
import Foundation
import Libbox
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 importRemoteProfileKey: EnvironmentKey {
static var defaultValue: Binding<LibboxImportRemoteProfile?> = .constant(nil)
}
var importRemoteProfile: Binding<LibboxImportRemoteProfile?> {
get {
self[importRemoteProfileKey.self]
}
set {
self[importRemoteProfileKey.self] = newValue
}
}
private struct importProfileKey: EnvironmentKey {
static var defaultValue: Binding<LibboxProfileContent?> = .constant(nil)
}
var importProfile: Binding<LibboxProfileContent?> {
get {
self[importProfileKey.self]
}
set {
self[importProfileKey.self] = newValue
}
}
}
@@ -0,0 +1,280 @@
import Libbox
import Network
import Library
import SwiftUI
@MainActor
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 alert: Alert?
/* old style
public var body: some View {
Button {
if group.selectable, group.selected != item.tag {
Task {
await selectOutbound()
UserDefaults.standard.setValue("\(item.tag)", forKey: "selectedNode")
}
}
} label: {
HStack {
VStack {
HStack {
Text(item.tag)
.font(.subheadline)
.foregroundStyle(.foreground)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 0)
}
Spacer(minLength: 8)
HStack {
Text(item.displayType)
.font(.caption)
.foregroundColor(.secondary)
Spacer(minLength: 0)
}
}
Spacer(minLength: 0)
VStack {
if group.selected == item.tag {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.accentColor)
}
Spacer(minLength: 0)
if item.urlTestDelay > 0 {
Text(item.delayString)
.font(.caption)
.foregroundColor(item.delayColor)
}
}
}
}
#if !os(tvOS)
.buttonStyle(.borderless)
.padding(EdgeInsets(top: 10, leading: 13, bottom: 10, trailing: 13))
.background(backgroundColor)
.cornerRadius(10)
#endif
.alertBinding($alert)
}
*/
func testPing(to server: String, port: Int, completion: @escaping (TimeInterval?) -> Void) {
let startTime = Date()
let connection = NWConnection(host: NWEndpoint.Host(server), port: NWEndpoint.Port(rawValue: UInt16(port))!, using: .tcp)
connection.stateUpdateHandler = { state in
switch state {
case .ready:
//
let pingTime = Date().timeIntervalSince(startTime)
completion(pingTime)
connection.cancel() //
case .failed(_):
completion(nil)
connection.cancel()
default:
break
}
}
//
connection.start(queue: .global())
}
public var body: some View {
Button {
print("\(group.selectable) group.selected - item.tag: >>> "+group.selected + " " + item.tag)
// print(item.toString)
if group.selectable, group.selected != item.tag {
// var item = _group.wrappedValue.items[index] // Make a mutable copy of the item
Task {
if let server = item.server ,let port = item.server_port{
//
testPing(to: server, port: port) { pingTime in
if let ping = pingTime {
print("Ping 时间: \(ping)")
Task{
await pingOutbound(tag: item.tag,pingtime: UInt16((pingTime ?? 0)*1000))
}
} else {
print("连接超时")
}
}
UserDefaults.standard.setValue("\(item.tag)", forKey: "selectedNode")
await selectOutbound()
}else{
await selectOutbound()
UserDefaults.standard.setValue("\(item.tag)", forKey: "selectedNode")
//
await restartVPNServer()
}
}
}
} label: {
VStack(spacing: 2) {
HStack {
VStack(alignment: .leading, spacing: 8) {
HStack {
// Image(server.name)
// .resizable()
// .aspectRatio(contentMode: .fit)
// .frame(width: 20, height: 20)
Text(item.tag)
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.white)
}
Label {
Text(item.type)
// Text(item.urlTestDelay <= 3000 ? "线" : "")
} icon: {
// Image(systemName: item.urlTestDelay <= 3000 ? "checkmark" : "xmark")
} .foregroundColor(.gray)
// .foregroundColor(item.urlTestDelay <= 3000 ?.green : .red)
.font(.caption2)
}
Spacer(minLength: 10)
// Change Server Button...
VStack {
if group.selected == item.tag {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.accentColor)
}
Spacer(minLength: 0)
if item.urlTestDelay > 0 {
Text(item.delayString)
.font(.caption)
.foregroundColor(item.delayColor)
}else{
//
Text("...")
.font(.caption)
.foregroundColor(.gray)
}
}
}
.frame(height: 50)
.padding(.horizontal)
Divider()
}
}
#if !os(tvOS)
.buttonStyle(.borderless)
// .padding(EdgeInsets(top: 10, leading: 13, bottom: 10, trailing: 13))
// .background(backgroundColor)
// .cornerRadius(10)
#endif
.alertBinding($alert)
}
private nonisolated func pingOutbound(tag: String,pingtime: UInt16) async {
let newGroup = await group
let newitems = newGroup.items.map { item in
var mutableItem = item
if (item.tag == tag) {
mutableItem.urlTestDelay = pingtime
}
return mutableItem
}
let newOut = OutboundGroup(tag: newGroup.tag, type: newGroup.type, selected:tag, selectable: newGroup.selectable, isExpand: newGroup.isExpand, items: newitems)
await MainActor.run { [newOut] in
_group.wrappedValue = newOut
}
}
private nonisolated func selectOutbound() async {
do {
try await LibboxNewStandaloneCommandClient()!.selectOutbound(group.tag, outboundTag: item.tag)
var newGroup = await group
newGroup.selected = item.tag
await MainActor.run { [newGroup] in
_group.wrappedValue = newGroup
}
} catch {
await MainActor.run {
alert = Alert(error)
}
}
}
private nonisolated func setSystemProxyEnabled(_ isEnabled: Bool) async {
do {
try LibboxNewStandaloneCommandClient()!.setSystemProxyEnabled(isEnabled)
await SharedPreferences.systemProxyEnabled.set(isEnabled)
} catch {
await MainActor.run {
alert = Alert(error)
}
}
}
private nonisolated func restartVPNServer() async {
do {
try LibboxNewStandaloneCommandClient()!.serviceReload()
} catch {
await MainActor.run {
alert = Alert(error)
}
}
}
private var backgroundColor: Color {
#if os(iOS)
return Color(uiColor: .secondarySystemGroupedBackground)
#elseif os(macOS)
return Color(nsColor: .textBackgroundColor)
#elseif os(tvOS)
return Color.black
#endif
}
}
@@ -0,0 +1,124 @@
import Libbox
import Library
import SwiftUI
public struct GroupListView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var isLoading = true
@StateObject private var commandClient = CommandClient(.groups)
@State private var groups: [OutboundGroup] = []
public init() {
}
public var body: some View {
VStack {
if isLoading {
Spacer().frame(height: 20)
Text("请开启VPN后查看节点数据").foregroundColor(.red)
} else if !groups.isEmpty {
ScrollView {
VStack {
ForEach(groups, id: \.hashValue) { it in
GroupView(it)
}
}.padding() //.background(Color.gray.opacity(0.1))
}
} else {
Spacer().frame(height: 20)
ProgressView()
}
}
.onAppear {
connect()
}
.onDisappear {
commandClient.disconnect()
}
.onChangeCompat(of: scenePhase) { newValue in
if newValue == .active {
commandClient.connect()
} else {
commandClient.disconnect()
}
}
.onReceive(commandClient.$groups, perform: { groups in
if let groups {
setGroups(groups)
}
})
}
private func connect() {
if ApplicationLibrary.inPreview {
groups = [
OutboundGroup(tag: "my_group", type: "selector", selected: "server", selectable: true, isExpand: true, items: [
OutboundGroupItem(tag: "server", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: 12),
OutboundGroupItem(tag: "server2", type: "WireGuard", urlTestTime: .now, urlTestDelay: 34),
OutboundGroupItem(tag: "auto", type: "URLTest", urlTestTime: .now, urlTestDelay: 100),
]),
OutboundGroup(tag: "group2", type: "urltest", selected: "client", selectable: true, isExpand: false, items:
(0 ..< 234).map { index in
OutboundGroupItem(tag: "client\(index)", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: UInt16(100 + index * 10))
}),
]
isLoading = false
} else {
#if targetEnvironment(simulator)
groups = [
OutboundGroup(tag: "my_group", type: "selector", selected: "server", selectable: true, isExpand: true, items: [
OutboundGroupItem(tag: "🇭🇰🇭🇰Hong Kong 01", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: 12),
OutboundGroupItem(tag: "🇭🇰🇭🇰Hong Kong 03", type: "WireGuard", urlTestTime: .now, urlTestDelay: 34),
OutboundGroupItem(tag: "🇭🇰🇭🇰Hong Kong 04", type: "URLTest", urlTestTime: .now, urlTestDelay: 100),
OutboundGroupItem(tag: "🇯🇵🇯🇵🇯🇵Japan 01", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: 12),
OutboundGroupItem(tag: "🇯🇵🇯🇵Japan Kong 01", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: 112),
OutboundGroupItem(tag: "🇯🇵🇯🇵Japan Kong 02", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: 1222),
OutboundGroupItem(tag: "🇯🇵🇯🇵Japan Kong 03", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: 4412),
OutboundGroupItem(tag: "🇯🇵🇯🇵Japan Kong 04", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: 512),
]),
]
isLoading = false
#endif
commandClient.connect()
}
}
private func setGroups(_ goGroups: [LibboxOutboundGroup]) {
var groups = [OutboundGroup]()
if let goGroup = goGroups.first {
goGroup.isExpand = true
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, isExpand: goGroup.isExpand, items: items))
UserDefaults.standard.setValue("\(goGroup.tag)", forKey: "goGrouptag")
}
/*
var groups = [OutboundGroup]()
for goGroup in goGroups {
print(goGroup.tag)
//
goGroup.isExpand = true
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, isExpand: goGroup.isExpand, items: items))
}*/
self.groups = groups
isLoading = false
}
}
@@ -0,0 +1,176 @@
import Libbox
import Library
import SwiftUI
@MainActor
public struct GroupView: View {
@State private var group: OutboundGroup
@State private var geometryWidth: CGFloat = 300
@State private var alert: Alert?
public init(_ group: OutboundGroup) {
_group = State(initialValue: group)
}
private var title: some View {
HStack {
// Text(group.tag)
// .font(.headline)
//Text()
Text(group.tag)
.font(.subheadline)
.foregroundColor(.secondary)
Text("\(group.items.count)")
.font(.subheadline)
.padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4))
.background(Color.gray.opacity(0.5))
.cornerRadius(4)
Button {
group.isExpand = !group.isExpand
Task {
await setGroupExpand()
}
} label: {
HStack{
if group.isExpand {
Image(systemName: "arrow.down.to.line")
Text("收起").font(.subheadline)
} else {
Image(systemName: "arrow.up.to.line")
Text("展开").font(.subheadline)
}
}
}
#if os(macOS) || os(tvOS)
.buttonStyle(.plain)
#endif
Button {
Task {
await doURLTest()
}
} label: {
Image(systemName: "bolt.fill")
Text("测速").font(.subheadline)
}
#if os(macOS) || os(tvOS)
.buttonStyle(.plain)
#endif
}
.alertBinding($alert)
.padding([.top, .bottom], 10)
}
public var body: some View {
Section {
if group.isExpand {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()),
count: explandColumnCount()))
{
ForEach(group.items, id: \.tag) { it in
GroupItemView($group, it)
}
}
} else {
VStack(spacing: 5) {
ForEach(Array(itemGroups.enumerated()), id: \.offset) { items in
HStack(spacing: 5) {
ForEach(items.element, id: \.tag) { it in
ZStack {
Rectangle()
.fill(it.delayColor)
if it.tag == group.selected {
Rectangle()
.fill(Color.white)
#if !os(tvOS)
.frame(width: 5, height: 5)
#else
.frame(width: 15, height: 15)
#endif
}
}
#if !os(tvOS)
.frame(width: 10, height: 10)
#else
.frame(width: 30, height: 30)
#endif
}
}.frame(maxWidth: .infinity, alignment: .topLeading)
}
}
}
} header: {
//title .frame(maxWidth: .infinity, alignment: .topLeading)
}
.background {
/* GeometryReader { geometry in
Rectangle()
.fill(.clear)
.frame(height: 1)
.onChangeCompat(of: geometry.size.width) { newValue in
geometryWidth = newValue
}
.onAppear {
geometryWidth = geometry.size.width
}
}.padding() */
}.padding(.leading,-18.0)
}
private var itemGroups: [[OutboundGroupItem]] {
let count: Int
#if os(tvOS)
count = Int(Int(geometryWidth) / 40)
#else
count = Int(Int(geometryWidth) / 20)
#endif
if count == 0 {
return [group.items]
} else {
return group.items.chunked(
into: count
)
}
}
private func explandColumnCount() -> Int {
let standardCount = Int(Int(geometryWidth) / 180)
#if os(iOS)
return standardCount < 2 ? 1 : standardCount
#elseif os(tvOS)
return 4
#else
return standardCount < 1 ? 1 : standardCount
#endif
}
private nonisolated func doURLTest() async {
do {
try await LibboxNewStandaloneCommandClient()!.urlTest(group.tag)
} catch {
await MainActor.run {
alert = Alert(error)
}
}
}
private nonisolated func setGroupExpand() async {
do {
try await LibboxNewStandaloneCommandClient()!.setGroupExpand(group.tag, isExpand: group.isExpand)
} catch {
await MainActor.run {
alert = Alert(error)
}
}
}
}
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,12 @@
//
// NodesListView.swift
// ApplicationLibrary
//
// Created by Mac on 2024/10/26.
//
import Foundation
import SwiftUI
//NodesListView
@@ -0,0 +1,55 @@
import Foundation
import Libbox
import SwiftUI
public struct OutboundGroup: Codable {
let tag: String
let type: String
var selected: String
let selectable: Bool
var isExpand: 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 init(tag: String, type: String, selected: String, selectable: Bool,isExpand: Bool,items: [OutboundGroupItem] ) {
self.tag = tag
self.type = type
self.selected = selected
self.selectable = selectable
self.isExpand = isExpand
self.items = items
}
}
public extension OutboundGroup {
var newhashValue: 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 {
LibboxProxyDisplayType(type)
}
}
@@ -0,0 +1,63 @@
import Foundation
import Libbox
import SwiftUI
public struct OutboundGroupItem: Codable {
public let tag: String
public let type: String
public let urlTestTime: Date
public var urlTestDelay: UInt16
public var server: String? = ""
public var server_port: Int? = 0
public var method: String? = ""
public var password: String? = ""
//server: String?,server_port: String?,method: String?,serverpassword String?
//
public init(tag: String, type: String, urlTestTime: Date, urlTestDelay: UInt16) {
self.tag = tag
self.type = type
self.urlTestTime = urlTestTime
self.urlTestDelay = urlTestDelay
}
}
public extension OutboundGroupItem {
mutating func setpingms(pingDelay: UInt16){
urlTestDelay = pingDelay
}
var toString: String {
"\(tag) - \(type) - \(server ?? "") - \(server_port ?? 0) - \(method ?? "") - \(password ?? "") "
}
var displayType: String {
LibboxProxyDisplayType(type)
}
var delayString: String {
"\(urlTestDelay)ms"
}
var delayColor: Color {
switch urlTestDelay {
case 0:
return .gray
case ..<400:
return .green
case ..<800:
return .yellow
case 800 ..< 1500:
return .orange
default:
return .red
}
}
}
@@ -0,0 +1,90 @@
import Library
import SwiftUI
public struct LogView: View {
@Environment(\.selection) private var selection
@EnvironmentObject private var environments: ExtensionEnvironments
public init() {}
public var body: some View {
LogView0().environmentObject(environments.logClient)
}
private struct LogView0: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@EnvironmentObject private var logClient: CommandClient
private let logFont = Font.system(.caption2, design: .monospaced)
var body: some View {
if ApplicationLibrary.inPreview {
let logList = [
"(packet-tunnel) log server started",
"INFO[0000] router: loaded geoip database: 250 codes",
"INFO[0000] router: loaded geosite database: 1400 codes",
"INFO[0000] router: updated default interface en0, index 11",
"inbound/tun[0]: started at utun3",
"sing-box started (1.666s)",
]
ScrollView {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(logList.enumerated()), id: \.offset) { it in
Text(it.element)
.font(logFont)
#if os(tvOS)
.focusable()
#endif
Spacer(minLength: 8)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding()
}
#if os(tvOS)
.focusEffectDisabled()
.focusSection()
#endif
} else if logClient.logList.isEmpty {
VStack {
if logClient.isConnected {
Text("Empty logs")
} else {
Text("Service not started").onAppear {
environments.connectLog()
}
}
}
} else {
ScrollViewReader { reader in
ScrollView {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(logClient.logList.enumerated()), id: \.offset) { it in
Text(it.element)
.font(logFont)
#if os(tvOS)
.focusable()
#endif
Spacer(minLength: 8)
}
.onChangeCompat(of: logClient.logList.count) { newCount in
withAnimation {
reader.scrollTo(newCount - 1)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding()
}
#if os(tvOS)
.focusEffectDisabled()
.focusSection()
#endif
.onAppear {
reader.scrollTo(logClient.logList.count - 1)
}
}
}
}
}
}
@@ -0,0 +1,109 @@
import Foundation
import Library
import SwiftUI
public enum NavigationPage: Int, CaseIterable, Identifiable {
public var id: Self {
self
}
case dashboard
#if os(macOS)
case groups
#endif
case logs
case profiles
case settings
}
public extension NavigationPage {
#if os(macOS)
static var macosDefaultPages: [NavigationPage] {
[.logs, .profiles, .settings]
}
#endif
var label: some View {
Label(title, systemImage: iconImage)
.tint(.textColor)
}
var title: String {
switch self {
case .dashboard:
return NSLocalizedString("Dashboard", comment: "")
#if os(macOS)
case .groups:
return NSLocalizedString("Groups", comment: "")
#endif
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"
#if os(macOS)
case .groups:
return "rectangle.3.group.fill"
#endif
case .logs:
return "doc.text.fill"
case .profiles:
return "list.bullet.rectangle.fill"
case .settings:
return "gear.circle.fill"
}
}
@MainActor
var contentView: some View {
viewBuilder {
switch self {
case .dashboard:
DashboardView()
#if os(macOS)
case .groups:
GroupListView()
#endif
case .logs:
LogView()
case .profiles:
ProfileView()
case .settings:
SettingView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
#if os(iOS)
.background(Color(uiColor: .systemGroupedBackground))
#endif
}
@MainActor
var contentViewMac: some View{
viewBuilder {
DashboardView().frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
//DashboardViewMac().frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
}
#if os(macOS)
func visible(_ profile: ExtensionProfile?) -> Bool {
switch self {
case .groups:
return profile?.status.isConnectedStrict == true
default:
return true
}
}
#endif
}
@@ -0,0 +1,154 @@
#if os(iOS) || os(macOS)
import Foundation
import Library
import SwiftUI
@MainActor
public struct EditProfileContentView: View {
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 = ""
@State private var isChanged = false
@State private var alert: Alert?
public var body: some View {
viewBuilder {
if isLoading {
ProgressView().onAppear {
Task {
await loadContent()
}
}
} else {
viewBuilder {
if readOnly {
TextEditor(text: .constant(profileContent))
} else {
TextEditor(text: $profileContent)
}
}
.font(Font.system(.caption2, design: .monospaced))
.autocorrectionDisabled(true)
// https://stackoverflow.com/questions/66721935/swiftui-how-to-disable-the-smart-quotes-in-texteditor
// https://stackoverflow.com/questions/74034171/textfield-with-autocorrectiondisabled-still-shows-predictive-text-bar
.textContentType(.init(rawValue: ""))
#if os(iOS)
.keyboardType(.asciiCapable)
.textInputAutocapitalization(.none)
.background(Color(UIColor.secondarySystemGroupedBackground))
#elseif os(macOS)
.padding()
#endif
.onChangeCompat(of: profileContent) {
isChanged = true
}
}
}
.alertBinding($alert)
.navigationTitle(navigationTitle)
#if os(macOS)
.toolbar {
ToolbarItemGroup(placement: .navigation) {
if !readOnly {
Button {
Task {
await saveContent()
}
} label: {
Label("Save", image: "save")
}
.disabled(!isChanged)
} else {
Button {
NSPasteboard.general.setString(profileContent, forType: .fileContents)
} label: {
Label("Copy", systemImage: "clipboard.fill")
}
}
}
}
#elseif os(iOS)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if !readOnly {
Button("Save") {
Task {
await saveContent()
}
}.disabled(!isChanged)
} else {
Button("Copy") {
UIPasteboard.general.string = profileContent
}
}
}
}
.navigationBarTitleDisplayMode(.inline)
#endif
}
private var navigationTitle: String {
if readOnly {
return "View Content"
} else {
return "Edit Content"
}
}
private func loadContent() async {
do {
try await loadContentBackground()
} catch {
alert = Alert(error)
}
isLoading = false
}
private nonisolated func loadContentBackground() async throws {
guard let profileID else {
throw NSError(domain: "Context destroyed", code: 0)
}
guard let profile = try await ProfileManager.get(profileID) else {
throw NSError(domain: "Profile missing", code: 0)
}
let profileContent = try profile.read()
await MainActor.run {
self.profile = profile
self.profileContent = profileContent
}
}
private func saveContent() async {
guard let profile else {
return
}
do {
try await saveContentBackground(profile)
} catch {
alert = Alert(error)
return
}
isChanged = false
}
private nonisolated func saveContentBackground(_ profile: Profile) async throws {
try await profile.write(profileContent)
}
}
#endif
@@ -0,0 +1,177 @@
import Libbox
import Library
import SwiftUI
@MainActor
public struct EditProfileView: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var profile: Profile
@State private var isLoading = false
@State private var isChanged = false
@State private var alert: Alert?
@State private var shareLinkPresented = false
@State private var shareLinkText: String?
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)
FormItem("Auto Update Interval") {
TextField("Auto Update Interval", text: $profile.autoUpdateInterval.stringBinding(defaultValue: 60), prompt: Text("In Minutes"))
.multilineTextAlignment(.trailing)
#if os(iOS)
.keyboardType(.numberPad)
#endif
}
}
if profile.type == .remote {
Section("Status") {
FormTextItem("Last Updated", profile.lastUpdatedString)
}
}
Section("Action") {
if profile.type != .remote {
#if os(iOS) || os(macOS)
FormNavigationLink {
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: false))
} label: {
Label("Edit Content", systemImage: "pencil")
.foregroundColor(.accentColor)
}
#endif
} else {
#if os(iOS) || os(macOS)
FormNavigationLink {
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
} label: {
Label("View Content", systemImage: "doc.fill")
.foregroundColor(.accentColor)
}
#endif
FormButton {
isLoading = true
Task {
await updateProfile()
}
} label: {
Label("Update", systemImage: "arrow.clockwise")
}
.foregroundColor(.accentColor)
.disabled(isLoading)
}
FormButton(role: .destructive) {
Task {
await deleteProfile()
}
} label: {
Label("Delete", systemImage: "trash.fill")
}
.foregroundColor(.red)
}
}
.onChangeCompat(of: profile.name) {
isChanged = true
}
.onChangeCompat(of: profile.remoteURL) {
isChanged = true
}
.onChangeCompat(of: profile.autoUpdate) {
isChanged = true
}
.disabled(isLoading)
#if os(macOS)
.toolbar {
ToolbarItemGroup(placement: .navigation) {
Button {
isLoading = true
Task {
await saveProfile()
}
} label: {
Image("save", bundle: ApplicationLibrary.bundle, label: Text("Save"))
}
.disabled(isLoading || !isChanged)
}
}
#elseif os(iOS)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
isLoading = true
Task {
await saveProfile()
}
}.disabled(!isChanged)
}
}
#endif
.alertBinding($alert)
.navigationTitle("Edit Profile")
}
private func updateProfile() async {
defer {
isLoading = false
}
do {
try await Task.sleep(nanoseconds: UInt64(100 * Double(NSEC_PER_MSEC)))
try await profile.updateRemoteProfile()
environments.profileUpdate.send()
} catch {
alert = Alert(error)
}
}
private func deleteProfile() async {
do {
try await ProfileManager.delete(profile)
} catch {
alert = Alert(error)
return
}
environments.profileUpdate.send()
dismiss()
}
private func saveProfile() async {
do {
_ = try await ProfileManager.update(profile)
#if os(iOS) || os(tvOS)
try await UIProfileUpdateTask.configure()
#else
try await ProfileUpdateTask.configure()
#endif
} catch {
alert = Alert(error)
return
}
isChanged = false
isLoading = false
environments.profileUpdate.send()
}
}
@@ -0,0 +1,210 @@
#if os(tvOS)
import DeviceDiscoveryUI
import Libbox
import Library
import SwiftUI
@MainActor
public struct ImportProfileView: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@Environment(\.dismiss) private var dismiss
@State private var isLoading = false
@State private var selected = false
@State private var alert: Alert?
@State private var connection: NWConnection?
@State private var socket: NWSocket?
@State private var profiles: [LibboxProfilePreview]?
@State private var isImporting = false
public init() {}
public var body: some View {
VStack(alignment: .center) {
if !selected {
Form {
Section {
EmptyView()
} footer: {
Text("To import configurations from your iPhone or iPad, make sure sing-box is the **same version** on both devices and **VPN is disabled**.")
}
DevicePicker(
.applicationService(name: "sing-box:profile"))
{ endpoint in
selected = true
Task {
await handleEndpoint(endpoint)
}
} label: {
Text("Select Device")
} fallback: {
EmptyView()
} parameters: {
.applicationService
}
}
} else if let profiles {
Form {
Section {
EmptyView()
} footer: {
Text("\(profiles.count) Profiles")
}
ForEach(profiles, id: \.profileID) { profile in
Button(profile.name) {
isLoading = true
Task {
selectProfile(profileID: profile.profileID)
isLoading = false
}
}.disabled(isLoading || isImporting)
}
}
} else {
Text("Connecting...")
}
}
.focusSection()
.alertBinding($alert)
.navigationTitle("Import Profile")
}
private func reset() {
if let connection {
connection.stateUpdateHandler = nil
connection.cancel()
self.connection = nil
}
if let socket {
socket.cancel()
self.socket = nil
}
selected = false
profiles = nil
}
private func handleEndpoint(_ endpoint: NWEndpoint) async {
let connection = NWConnection(to: endpoint, using: NWParameters.applicationService)
self.connection = connection
socket = NWSocket(connection)
connection.stateUpdateHandler = { state in
switch state {
case let .failed(error):
DispatchQueue.main.async { [self] in
reset()
alert = Alert(error)
}
default: break
}
}
connection.start(queue: .global())
do {
try await loopMessages()
} catch {
alert = Alert(error)
reset()
}
}
private nonisolated func loopMessages() async throws {
guard let socket = await socket else {
return
}
var message: Data
while true {
do {
message = try socket.read()
} catch {
throw NSError(domain: "read from connection: \(error.localizedDescription)", code: 0)
}
var error: NSError?
switch Int64(message[0]) {
case LibboxMessageTypeError:
let message = LibboxDecodeErrorMessage(message, &error)
if let error {
throw error
}
if let message {
throw NSError(domain: "remote error: \(message.message)", code: 0)
}
case LibboxMessageTypeProfileList:
let decoder = LibboxProfileDecoder()
try decoder.decode(message)
let iterator = decoder.iterator()!
var profiles = [LibboxProfilePreview]()
while iterator.hasNext() {
let profile = iterator.next()!
if profile.type == LibboxProfileTypeiCloud {
// not supported on tvOS
continue
}
profiles.append(profile)
}
await MainActor.run { [self, profiles] in
self.profiles = profiles
isImporting = false
}
case LibboxMessageTypeProfileContent:
let content = LibboxDecodeProfileContent(message, &error)
if let error {
throw error
}
try await importProfile(content!)
return
default:
throw NSError(domain: "unknown message type \(message[0])", code: 0)
}
}
}
private func selectProfile(profileID: Int64) {
guard let connection else {
return
}
guard let socket else {
return
}
connection.stateUpdateHandler = nil
let request = LibboxProfileContentRequest()
request.profileID = profileID
do {
try socket.write(request.encode())
isImporting = true
} catch {
alert = Alert(error)
reset()
}
}
private nonisolated func importProfile(_ content: LibboxProfileContent) async throws {
var type: ProfileType = .local
switch content.type {
case LibboxProfileTypeLocal:
type = .local
case LibboxProfileTypeiCloud:
type = .icloud
case LibboxProfileTypeRemote:
type = .remote
default:
break
}
let nextProfileID = try await ProfileManager.nextID()
let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true)
try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true)
let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json")
try content.config.write(to: profileConfig, atomically: true, encoding: .utf8)
var lastUpdated: Date?
if content.lastUpdated > 0 {
lastUpdated = Date(timeIntervalSince1970: Double(content.lastUpdated))
}
try await ProfileManager.create(Profile(name: content.name, type: type, path: profileConfig.relativePath, remoteURL: content.remotePath, autoUpdate: content.autoUpdate, lastUpdated: lastUpdated))
await reset()
await MainActor.run {
environments.profileUpdate.send()
dismiss()
}
}
}
#endif
@@ -0,0 +1,253 @@
import Foundation
import Libbox
import Library
import SwiftUI
@MainActor
public struct NewProfileView: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@Environment(\.dismiss) private var dismiss
@State private var isSaving = false
@State private var profileName = "Sub"
@State private var profileType = ProfileType.remote
@State private var fileImport = false
@State private var fileURL: URL!
@State private var remotePath = ""
@State private var autoUpdate = true
@State private var autoUpdateInterval: Int32 = 60
@State private var pickerPresented = false
@State private var alert: Alert?
public struct ImportRequest: Codable, Hashable {
public let name: String
public let url: String
}
public init(_ importRequest: ImportRequest? = nil) {
if let importRequest {
_profileName = .init(initialValue: importRequest.name)
_profileType = .init(initialValue: .remote)
_remotePath = .init(initialValue: importRequest.url)
}
}
public var body: some View {
FormView {
FormItem("Name") {
TextField("Name", text: $profileName, prompt: Text("Required"))
.multilineTextAlignment(.trailing)
}
Picker(selection: $profileType) {
#if !os(tvOS)
Text("Local").tag(ProfileType.local)
Text("iCloud").tag(ProfileType.icloud)
#endif
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")
}
#if os(tvOS)
.disabled(true)
#endif
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)
}
Toggle("Auto Update", isOn: $autoUpdate)
FormItem("Auto Update Interval") {
TextField("Auto Update Interval", text: $autoUpdateInterval.stringBinding(defaultValue: 60), prompt: Text("In Minutes"))
.multilineTextAlignment(.trailing)
#if os(iOS)
.keyboardType(.numberPad)
#endif
}
}
Section {
if !isSaving {
FormButton {
isSaving = true
Task {
await createProfile()
}
} label: {
Label("Create", systemImage: "doc.fill.badge.plus")
}
} else {
ProgressView()
}
}
}
.navigationTitle("New Profile")
.alertBinding($alert)
#if os(iOS) || os(macOS)
.fileImporter(
isPresented: $pickerPresented,
allowedContentTypes: [.json],
allowsMultipleSelection: false
) { result in
do {
let urls = try result.get()
if !urls.isEmpty {
fileURL = urls[0]
}
} catch {
alert = Alert(error)
return
}
}
#endif
}
private func createProfile() async {
defer {
isSaving = false
}
if profileName.isEmpty {
alert = Alert(errorMessage: "Missing profile name")
return
}
if remotePath.isEmpty {
if profileType == .icloud {
alert = Alert(errorMessage: "Missing path")
return
} else if profileType == .remote {
alert = Alert(errorMessage: "Missing URL")
return
}
}
do {
try await createProfileBackground()
} catch {
alert = Alert(error)
return
}
environments.profileUpdate.send()
dismiss()
#if os(macOS)
resetFields()
#endif
}
private func resetFields() {
profileName = ""
profileType = .local
fileImport = false
fileURL = nil
remotePath = ""
}
private nonisolated func createProfileBackground() async throws {
let nextProfileID = try await ProfileManager.nextID()
var savePath = ""
var remoteURL: String? = nil
var lastUpdated: Date? = nil
let profileName = await profileName
let profileType = await profileType
let fileImport = await fileImport
let fileURL = await fileURL
let remotePath = await remotePath
let autoUpdate = await autoUpdate
let autoUpdateInterval = await autoUpdateInterval
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 {
throw NSError(domain: "Missing file", code: 0)
}
if !fileURL.startAccessingSecurityScopedResource() {
throw NSError(domain: "Missing access to selected file", code: 0)
}
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)
print(remoteContent)
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
lastUpdated = .now
}
try await ProfileManager.create(Profile(
name: profileName,
type: profileType,
path: savePath,
remoteURL: remoteURL,
autoUpdate: autoUpdate,
autoUpdateInterval: autoUpdateInterval,
lastUpdated: lastUpdated
))
if profileType == .remote {
#if os(iOS) || os(tvOS)
try await UIProfileUpdateTask.configure()
#else
try await ProfileUpdateTask.configure()
#endif
}
}
}
@@ -0,0 +1,423 @@
import Foundation
import Libbox
import Library
import Network
import QRCode
import SwiftUI
@MainActor
public struct ProfileView: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@Environment(\.importProfile) private var importProfile
@Environment(\.importRemoteProfile) private var importRemoteProfile
@State private var importRemoteProfileRequest: NewProfileView.ImportRequest?
@State private var importRemoteProfilePresented = false
@State private var isLoading = true
@State private var isUpdating = false
@State private var alert: Alert?
@State private var profileList: [ProfilePreview] = []
#if os(iOS) || os(tvOS)
@State private var editMode = EditMode.inactive
#endif
#if os(tvOS)
@Environment(\.devicePickerSupports) private var devicePickerSupports
#endif
public init() {}
public var body: some View {
VStack {
if isLoading {
ProgressView().onAppear {
Task {
await doReload()
}
}
} else {
ZStack {
if let importRemoteProfileRequest {
NavigationDestinationCompat(isPresented: $importRemoteProfilePresented) {
NewProfileView(importRemoteProfileRequest)
}
}
FormView {
#if os(iOS)
FormNavigationLink {
NewProfileView()
} label: {
Text("New Profile").foregroundColor(.accentColor)
}
.disabled(editMode.isEditing)
#elseif os(macOS)
FormNavigationLink {
NewProfileView()
} label: {
Text("New Profile")
}
#elseif os(tvOS)
Section {
FormNavigationLink {
NewProfileView()
} label: {
Text("New Profile").foregroundColor(.accentColor)
}
if ApplicationLibrary.inPreview || devicePickerSupports(.applicationService(name: "sing-box"), parameters: { .applicationService }) {
FormNavigationLink {
ImportProfileView()
} label: {
Text("Import Profile").foregroundColor(.accentColor)
}
}
}
#endif
if profileList.isEmpty {
Text("Empty profiles")
} else {
List {
ForEach(profileList, id: \.id) { profile in
viewBuilder {
#if os(iOS) || os(tvOS)
if editMode.isEditing == true {
Text(profile.name)
} else {
ProfileItem(self, profile)
}
#else
ProfileItem(self, profile)
#endif
}
}
.onMove(perform: moveProfile)
.onDelete(perform: deleteProfile)
}
}
}
}
}
}
.disabled(isUpdating)
.alertBinding($alert, $isLoading)
.onAppear {
if let profile = importProfile.wrappedValue {
importProfile.wrappedValue = nil
createImportProfileDialog(profile)
}
if let remoteProfile = importRemoteProfile.wrappedValue {
importRemoteProfile.wrappedValue = nil
createImportRemoteProfileDialog(remoteProfile)
}
}
.onChangeCompat(of: importProfile.wrappedValue) { newValue in
if let newValue {
importProfile.wrappedValue = nil
createImportProfileDialog(newValue)
}
}
.onChangeCompat(of: importRemoteProfile.wrappedValue) { newValue in
if let newValue {
importRemoteProfile.wrappedValue = nil
createImportRemoteProfileDialog(newValue)
}
}
.onReceive(environments.profileUpdate) { _ in
Task {
await doReload()
}
}
#if os(iOS)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton().disabled(profileList.isEmpty)
}
}
#elseif os(tvOS)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if editMode == .inactive {
Button("Edit") {
editMode = .active
}
.disabled(profileList.isEmpty)
} else {
Button("Done") {
editMode = .inactive
}
}
}
}
#endif
#if os(iOS) || os(tvOS)
.environment(\.editMode, $editMode)
#endif
}
private func createImportProfileDialog(_ profile: LibboxProfileContent) {
alert = Alert(
title: Text("Import Profile"),
message: Text("Are you sure to import profile \(profile.name)?"),
primaryButton: .default(Text("Import")) {
Task {
do {
try await profile.importProfile()
} catch {
alert = Alert(error)
return
}
await doReload()
}
},
secondaryButton: .cancel()
)
}
private func createImportRemoteProfileDialog(_ newValue: LibboxImportRemoteProfile) {
importRemoteProfileRequest = .init(name: newValue.name, url: newValue.url)
alert = Alert(
title: Text("Import Remote Profile"),
message: Text("Are you sure to import remote profile \(newValue.name)? You will connect to \(newValue.host) to download the configuration."),
primaryButton: .default(Text("Import")) {
importRemoteProfilePresented = true
},
secondaryButton: .cancel()
)
}
private func doReload() async {
defer {
isLoading = false
}
if ApplicationLibrary.inPreview {
profileList = [
ProfilePreview(Profile(id: 0, name: "profile local", type: .local, path: "")),
ProfilePreview(Profile(id: 1, name: "profile remote", type: .remote, path: "", lastUpdated: Date(timeIntervalSince1970: 0))),
]
} else {
do {
profileList = try await ProfileManager.list().map { ProfilePreview($0) }
} catch {
alert = Alert(error)
return
}
}
environments.emptyProfiles = profileList.isEmpty
}
private func updateProfile(_ profile: Profile) async {
await updateProfileBackground(profile)
isUpdating = false
}
private nonisolated func updateProfileBackground(_ profile: Profile) async {
do {
_ = try await profile.updateRemoteProfile()
} catch {
await MainActor.run {
alert = Alert(error)
}
}
}
private func deleteProfile(_ profile: Profile) async {
do {
_ = try await ProfileManager.delete(profile)
} catch {
alert = Alert(error)
return
}
environments.profileUpdate.send()
}
private func moveProfile(from source: IndexSet, to destination: Int) {
profileList.move(fromOffsets: source, toOffset: destination)
for (index, profile) in profileList.enumerated() {
profileList[index].order = UInt32(index)
profile.origin.order = UInt32(index)
}
Task {
do {
try await ProfileManager.update(profileList.map(\.origin))
} catch {
alert = Alert(error)
}
environments.profileUpdate.send()
}
}
private func deleteProfile(where profileIndex: IndexSet) {
let profileToDelete = profileIndex.map { index in
profileList[index].origin
}
profileList.remove(atOffsets: profileIndex)
environments.emptyProfiles = profileList.isEmpty
Task {
do {
_ = try await ProfileManager.delete(profileToDelete)
} catch {
alert = Alert(error)
}
environments.profileUpdate.send()
}
}
@MainActor
public struct ProfileItem: View {
private let parent: ProfileView
@State private var profile: ProfilePreview
@State private var shareLinkPresented = false
public init(_ parent: ProfileView, _ profile: ProfilePreview) {
self.parent = parent
_profile = State(initialValue: profile)
}
public var body: some View {
#if os(iOS) || os(macOS)
if #available(iOS 16.0, macOS 13.0,*) {
body0.draggable(profile.origin)
} else {
body0
}
#else
body0
#endif
}
private var body0: some View {
viewBuilder {
#if !os(macOS)
FormNavigationLink {
EditProfileView().environmentObject(profile.origin)
} label: {
Text(profile.name)
}
.sheet(isPresented: $shareLinkPresented) {
shareLinkView.padding()
}
.contextMenu {
ProfileShareButton(parent.$alert, profile.origin) {
Label("Share", systemImage: "square.and.arrow.up.fill")
}
if profile.type == .remote {
Button {
shareLinkPresented = true
} label: {
Label("Share URL as QR Code", systemImage: "qrcode")
}
Button {
parent.isUpdating = true
Task {
await parent.updateProfile(profile.origin)
profile = ProfilePreview(profile.origin)
}
} label: {
Label("Update", systemImage: "arrow.clockwise")
}
}
Button(role: .destructive) {
Task {
await parent.deleteProfile(profile.origin)
}
} label: {
Label("Delete", systemImage: "trash.fill")
}
}
#else
FormNavigationLink {
EditProfileView().environmentObject(profile.origin)
} label: {
HStack {
VStack(alignment: .leading) {
Text(profile.name)
if profile.type == .remote {
Spacer(minLength: 4)
Text("Last Updated: \(profile.origin.lastUpdatedString)").font(.caption)
}
}
HStack {
if profile.type == .remote {
Button {
parent.isUpdating = true
Task {
await parent.updateProfile(profile.origin)
profile = ProfilePreview(profile.origin)
}
} label: {
Image(systemName: "arrow.clockwise")
}
.padding(.leading, 4)
Button {
shareLinkPresented = true
} label: {
Image(systemName: "qrcode")
}
.padding(.leading, 4)
.popover(isPresented: $shareLinkPresented, arrowEdge: .bottom) {
shareLinkView
}
}
ProfileShareButton(parent.$alert, profile.origin) {
Image(systemName: "square.and.arrow.up.fill")
}
.padding(.leading, 4)
Button {
Task {
await parent.deleteProfile(profile.origin)
}
} label: {
Image(systemName: "trash.fill")
}
.padding([.leading, .trailing], 4)
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity, alignment: .trailing)
}
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
}
#endif
}
}
private var shareLinkView: some View {
#if os(iOS)
viewBuilder {
if #available(iOS 16.0, *) {
shareLinkView0
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
} else {
shareLinkView0
}
}
#elseif os(macOS)
shareLinkView0
.frame(minWidth: 300, minHeight: 300)
#else
shareLinkView0
#endif
}
private var foregroundColor: CGColor {
#if canImport(UIKit)
return UIColor.label.cgColor
#elseif canImport(AppKit)
return NSColor.labelColor.cgColor
#endif
}
private var shareLinkView0: some View {
QRCodeViewUI(
content: LibboxGenerateRemoteProfileImportLink(profile.name, profile.remoteURL!),
errorCorrection: .low,
foregroundColor: foregroundColor,
backgroundColor: CGColor(gray: 1.0, alpha: 0.0)
)
}
}
}
@@ -0,0 +1,95 @@
import Libbox
import Library
import SwiftUI
public struct CoreView: View {
@State private var isLoading = true
@State private var version = ""
@State private var dataSize = ""
public init() {}
public var body: some View {
viewBuilder {
if isLoading {
ProgressView().onAppear {
Task {
await loadSettings()
}
}
} else {
FormView {
FormTextItem("Version", version)
FormTextItem("Data Size", dataSize)
Section("Working Directory") {
#if os(macOS)
FormButton {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: FilePath.workingDirectory.relativePath)
} label: {
Label("Open", systemImage: "macwindow.and.cursorarrow")
}
#endif
FormButton(role: .destructive) {
Task {
await destroyWorkingDirectory()
}
} label: {
Label("Destroy", systemImage: "trash.fill")
}
.foregroundColor(.red)
}
}
}
}
.navigationTitle("Core")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
private nonisolated func loadSettings() async {
if ApplicationLibrary.inPreview {
version = "<redacted>"
dataSize = LibboxFormatBytes(1000 * 1000 * 10)
isLoading = false
} else {
version = LibboxVersion()
dataSize = "Loading..."
isLoading = false
await loadSettingsBackground()
}
}
private nonisolated func loadSettingsBackground() async {
let dataSize = (try? FilePath.workingDirectory.formattedSize()) ?? "Unknown"
await MainActor.run {
self.dataSize = dataSize
}
}
private nonisolated func destroyWorkingDirectory() async {
try? FileManager.default.removeItem(at: FilePath.workingDirectory)
await MainActor.run {
isLoading = true
}
}
}
private extension URL {
func formattedSize() throws -> String? {
guard let urls = FileManager.default.enumerator(at: self, includingPropertiesForKeys: nil)?.allObjects as? [URL] else {
return nil
}
let size = try urls.lazy.reduce(0) {
try ($1.resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize ?? 0) + $0
}
let formatter = ByteCountFormatter()
formatter.countStyle = .file
guard let byteCount = formatter.string(for: size) else {
return nil
}
return byteCount
}
}
@@ -0,0 +1,152 @@
#if os(macOS)
import AppKit
import Library
import ServiceManagement
import SwiftUI
public struct MacAppView: View {
@State private var isLoading = true
@State private var startAtLogin = false
@Environment(\.showMenuBarExtra) private var showMenuBarExtra
@State private var menuBarExtraInBackground = false
@State private var alert: Alert?
public init() {}
public var body: some View {
viewBuilder {
if isLoading {
ProgressView().onAppear {
Task {
await loadSettings()
}
}
} else {
FormView {
FormToggle("Start At Login", "Launch the application when the system is logged in. If enabled at the same time as `Show in Menu Bar` and `Keep Menu Bar in Background`, the application interface will not be opened automatically.", $startAtLogin) { newValue in
updateLoginItems(newValue)
}
Toggle("Show in Menu Bar", isOn: showMenuBarExtra)
.onChangeCompat(of: showMenuBarExtra.wrappedValue) { newValue in
Task {
await SharedPreferences.showMenuBarExtra.set(newValue)
if !newValue {
menuBarExtraInBackground = false
}
}
}
if showMenuBarExtra.wrappedValue {
Toggle("Keep Menu Bar in Background", isOn: $menuBarExtraInBackground)
.onChangeCompat(of: menuBarExtraInBackground) { newValue in
Task {
await SharedPreferences.menuBarExtraInBackground.set(newValue)
}
}
}
if Variant.useSystemExtension {
Section("System Extension") {
FormButton {
Task {
await updateSystemExtension()
}
} label: {
Label("Update", systemImage: "arrow.down.doc.fill")
}
FormButton(role: .destructive) {
Task {
await uninstallSystemExtension()
}
} label: {
Label("Uninstall", systemImage: "trash.fill").foregroundColor(.red)
}
}
}
}
}
}
.alertBinding($alert)
.navigationTitle("App")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
private func loadSettings() async {
startAtLogin = SMAppService.mainApp.status == .enabled
menuBarExtraInBackground = await SharedPreferences.menuBarExtraInBackground.get()
isLoading = false
}
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 {
alert = Alert(error)
}
}
private func updateSystemExtension() async {
do {
if let result = try await SystemExtension.install(forceUpdate: true) {
switch result {
case .completed:
alert = Alert(
title: Text("Update"),
message: Text("System Extension updated."),
dismissButton: .default(Text("Ok")) {}
)
case .willCompleteAfterReboot:
alert = Alert(
title: Text("Update"),
message: Text("Reboot required."),
dismissButton: .default(Text("Ok")) {}
)
@unknown default:
fatalError()
}
}
} catch {
alert = Alert(error)
}
}
private func uninstallSystemExtension() async {
do {
if let result = try await SystemExtension.uninstall() {
switch result {
case .completed:
alert = Alert(
title: Text("Uninstall"),
message: Text("System Extension removed."),
dismissButton: .default(Text("Ok")) {}
)
case .willCompleteAfterReboot:
alert = Alert(
title: Text("Uninstall"),
message: Text("Reboot required."),
dismissButton: .default(Text("Ok")) {}
)
@unknown default:
fatalError()
}
}
} catch {
alert = Alert(error)
}
}
}
#endif
@@ -0,0 +1,50 @@
import Foundation
import Library
import SwiftUI
public struct OnDemandRulesView: View {
@State private var isLoading = true
@State private var alwaysOn = false
public init() {}
public var body: some View {
viewBuilder {
if isLoading {
ProgressView().onAppear {
Task.detached {
await loadSettings()
}
}
} else {
FormView {
FormToggle("Always On", """
Implement always-on via on-demand rules.
This should not be an intended use of the API, so you cannot disable VPN in system settings. To stop the service manually, use the in-app interface or simply delete the VPN profile.
""", $alwaysOn) { newValue in
await SharedPreferences.alwaysOn.set(newValue)
}
FormButton {
Task {
await SharedPreferences.resetOnDemandRules()
isLoading = true
}
} label: {
Label("Reset", systemImage: "eraser.fill")
}
.foregroundColor(.red)
}
}
}
.navigationTitle("On Demand Rules")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
private func loadSettings() async {
alwaysOn = await SharedPreferences.alwaysOn.get()
isLoading = false
}
}
@@ -0,0 +1,108 @@
import Library
import SwiftUI
public struct PacketTunnelView: View {
@State private var isLoading = true
@State private var ignoreMemoryLimit = false
@State private var includeAllNetworks = false
@State private var excludeAPNs = false
@State private var excludeCellularServices = false
@State private var excludeLocalNetworks = false
@State private var enforceRoutes = false
public init() {}
public var body: some View {
viewBuilder {
if isLoading {
ProgressView().onAppear {
Task.detached {
await loadSettings()
}
}
} else {
FormView {
FormToggle("Ignore Memory Limit", """
Do not enforce memory limits on sing-box. Will cause OOM on non-jailbroken iOS and tvOS devices.
""", $ignoreMemoryLimit) { newValue in
await SharedPreferences.ignoreMemoryLimit.set(newValue)
}
#if !os(tvOS)
FormToggle("includeAllNetworks", """
If this property is true, the system routes network traffic through the tunnel except traffic for designated system services necessary for maintaining expected device functionality. You can exclude some types of traffic using the **excludeAPNs**, **excludeLocalNetworks**, and **excludeCellularServices** properties in combination with this property.
when enabled, the default TUN stack is changed to `gvisor`, and the `system` and `mixed` stacks are not available.
[Apple Documentation](https://developer.apple.com/documentation/networkextension/nevpnprotocol/3131931-includeallnetworks)
""", $includeAllNetworks) { newValue in
await SharedPreferences.includeAllNetworks.set(newValue)
}
FormToggle("excludeAPNs", """
If this property is true, the system excludes Apple Push Notification services (APNs) traffic, but only when the **includeAllNetworks** property is also true.
[Apple Documentation](https://developer.apple.com/documentation/networkextension/nevpnprotocol/4140516-excludeapns)
""", $excludeAPNs) { newValue in
await SharedPreferences.excludeAPNs.set(newValue)
}
FormToggle("excludeCellularServices", """
If this property is true, the system excludes cellular services — such as Wi-Fi Calling, MMS, SMS, and Visual Voicemail — but only when the **includeAllNetworks** property is also true. This property doesnt impact services that use the cellular network only — such as VoLTE — which the system automatically excludes.
[Apple Documentation](https://developer.apple.com/documentation/networkextension/nevpnprotocol/4140517-excludecellularservices)
""", $excludeCellularServices) { newValue in
await SharedPreferences.excludeCellularServices.set(newValue)
}
FormToggle("excludeLocalNetworks", """
If this property is true, the system excludes network connections to hosts on the local network — such as AirPlay, AirDrop, and CarPlay — but only when the **includeAllNetworks** or **enforceRoutes** property is also true.
[Apple Documentation](https://developer.apple.com/documentation/networkextension/nevpnprotocol/3143658-excludelocalnetworks)
""", $excludeLocalNetworks) { newValue in
await SharedPreferences.excludeLocalNetworks.set(newValue)
}
FormToggle("enforceRoutes", """
If this property is true when the **includeAllNetworks** property is false, the system scopes the included routes to the VPN and the excluded routes to the current primary network interface. This property supersedes the system routing table and scoping operations by apps.
If you set both the **enforceRoutes** and **excludeLocalNetworks** properties to true, the system excludes network connections to hosts on the local network.
[Apple Documentation](https://developer.apple.com/documentation/networkextension/nevpnprotocol/3689459-enforceroutes)
""", $enforceRoutes) { newValue in
await SharedPreferences.enforceRoutes.set(newValue)
}
#endif
FormButton {
Task {
await SharedPreferences.resetPacketTunnel()
isLoading = true
}
} label: {
Label("Reset", systemImage: "eraser.fill")
}
.foregroundColor(.red)
}
}
}
.navigationTitle("Packet Tunnel")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
private func loadSettings() async {
ignoreMemoryLimit = await SharedPreferences.ignoreMemoryLimit.get()
#if !os(tvOS)
includeAllNetworks = await SharedPreferences.includeAllNetworks.get()
excludeAPNs = await SharedPreferences.excludeAPNs.get()
excludeCellularServices = await SharedPreferences.excludeCellularServices.get()
excludeLocalNetworks = await SharedPreferences.excludeLocalNetworks.get()
enforceRoutes = await SharedPreferences.enforceRoutes.get()
#endif
isLoading = false
}
}
@@ -0,0 +1,59 @@
import Library
import SwiftUI
public struct ProfileOverrideView: View {
@State private var isLoading = true
@State private var excludeDefaultRoute = false
@State private var autoRouteUseSubRangesByDefault = false
@State private var excludeAPNsRoute = false
public init() {}
public var body: some View {
viewBuilder {
if isLoading {
ProgressView().onAppear {
Task.detached {
await loadSettings()
}
}
} else {
FormView {
FormToggle("Hide VPN Icon", "Append `0.0.0.0/31` to `inet4_route_exclude_address` if not exists.", $excludeDefaultRoute) { newValue in
await SharedPreferences.excludeDefaultRoute.set(newValue)
}
FormToggle("No Default Route", """
By default, segment routing is used in `auto_route` instead of global routing. If `*_<route_address/route_exclude_address>` exists in the configuration, this item will not take effect on the corresponding network (commonly used to resolve HomeKit compatibility issues).
""", $autoRouteUseSubRangesByDefault) { newValue in
await SharedPreferences.autoRouteUseSubRangesByDefault.set(newValue)
}
FormToggle("Exclude APNs Route", "Append `push.apple.com` to `bypass_domain`, and `17.0.0.0/8` to `inet4_route_exclude_address`.", $excludeAPNsRoute) { newValue in
await SharedPreferences.excludeAPNsRoute.set(newValue)
}
FormButton {
Task {
await SharedPreferences.resetProfileOverride()
isLoading = true
}
} label: {
Label("Reset", systemImage: "eraser.fill")
}
.foregroundColor(.red)
}
}
}
.navigationTitle("Profile Override")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
private func loadSettings() async {
excludeDefaultRoute = await SharedPreferences.excludeDefaultRoute.get()
autoRouteUseSubRangesByDefault = await SharedPreferences.autoRouteUseSubRangesByDefault.get()
excludeAPNsRoute = await SharedPreferences.excludeAPNsRoute.get()
isLoading = false
}
}
@@ -0,0 +1,112 @@
import Foundation
import Library
import SwiftUI
@MainActor
public struct ServiceLogView: View {
@Environment(\.dismiss) private var dismiss
@State private var isLoading = true
@State private var content = ""
@State private var alert: Alert?
private let logFont = Font.system(.caption, design: .monospaced)
public init() {}
public var body: some View {
viewBuilder {
if isLoading {
ProgressView().onAppear {
Task {
await loadContent()
}
}
} else {
if content.isEmpty {
Text("Empty content")
} else {
ScrollView {
Text(content)
.font(logFont)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.padding()
}
}
}
.toolbar {
if !content.isEmpty {
#if !os(tvOS)
ShareButtonCompat($alert) {
Label("Export", systemImage: "square.and.arrow.up.fill")
} itemURL: {
try content.generateShareFile(name: "service.log")
}
#endif
Button(role: .destructive) {
Task {
await deleteContent()
}
} label: {
#if !os(tvOS)
Label("Delete", systemImage: "trash.fill")
#else
Image(systemName: "trash.fill")
#endif
}
}
}
.alertBinding($alert)
.navigationTitle("Service Log")
#if os(tvOS)
.focusable()
#endif
}
private nonisolated func loadContent() async {
var content = ""
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 {}
}
#if DEBUG
if content.isEmpty {
content = "Empty content"
}
#endif
if !content.isEmpty {
var systemInfo = utsname()
uname(&systemInfo)
let machineMirror = Mirror(reflecting: systemInfo.machine)
let machineName = machineMirror.children.reduce("") { identifier, element in
guard let value = element.value as? Int8, value != 0 else { return identifier }
return identifier + String(UnicodeScalar(UInt8(value)))
}
var deviceInfo = "Machine: " + machineName + "\n"
#if os(iOS)
await deviceInfo += "System: " + (UIDevice.current.systemName) + " " + (UIDevice.current.systemVersion) + "\n"
#elseif os(macOS)
deviceInfo += "System: macOS " + ProcessInfo().operatingSystemVersionString + "\n"
#endif
content = deviceInfo + "\n" + content
}
await MainActor.run { [content] in
self.content = content
isLoading = false
}
}
private nonisolated func deleteContent() async {
try? FileManager.default.removeItem(at: FilePath.cacheDirectory.appendingPathComponent("stderr.log"))
try? FileManager.default.removeItem(at: FilePath.cacheDirectory.appendingPathComponent("stderr.log.old"))
await MainActor.run {
dismiss()
isLoading = true
}
}
}
@@ -0,0 +1,154 @@
import Library
import SwiftUI
public struct SettingView: View {
private enum Tabs: Int, CaseIterable, Identifiable {
public var id: Self {
self
}
#if os(macOS)
case app
#endif
case core, packetTunnel, onDemandRules, profileOverride, sponsors
var label: some View {
Label(title, systemImage: iconImage)
}
var title: String {
switch self {
#if os(macOS)
case .app:
return NSLocalizedString("App", comment: "")
#endif
case .core:
return NSLocalizedString("Core", comment: "")
case .packetTunnel:
return NSLocalizedString("Packet Tunnel", comment: "")
case .onDemandRules:
return NSLocalizedString("On Demand Rules", comment: "")
case .profileOverride:
return NSLocalizedString("Profile Override", comment: "")
case .sponsors:
return NSLocalizedString("Sponsors", comment: "")
}
}
private var iconImage: String {
switch self {
#if os(macOS)
case .app:
return "app.badge.fill"
#endif
case .core:
return "shippingbox.fill"
case .packetTunnel:
return "aspectratio.fill"
case .onDemandRules:
return "filemenu.and.selection"
case .profileOverride:
return "square.dashed.inset.filled"
case .sponsors:
return "heart.fill"
}
}
@MainActor
var contentView: some View {
viewBuilder {
switch self {
#if os(macOS)
case .app:
MacAppView()
#endif
case .core:
CoreView()
case .packetTunnel:
PacketTunnelView()
case .onDemandRules:
OnDemandRulesView()
case .profileOverride:
ProfileOverrideView()
case .sponsors:
SponsorsView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
#if os(iOS)
.background(Color(uiColor: .systemGroupedBackground))
#endif
}
@MainActor
var navigationLink: some View {
FormNavigationLink {
contentView
} label: {
label
}
}
}
@State private var isLoading = true
@State private var taiwanFlagAvailable = false
public init() {}
public var body: some View {
FormView {
#if os(macOS)
Tabs.app.navigationLink
#endif
ForEach([Tabs.core, Tabs.packetTunnel, Tabs.onDemandRules, Tabs.profileOverride]) { it in
it.navigationLink
}
#if !os(tvOS)
Section("About") {
Link(destination: URL(string: "https://sing-box.sagernet.org/")!) {
Label("Documentation", systemImage: "doc.on.doc.fill")
}
.buttonStyle(.plain)
.foregroundColor(.accentColor)
RequestReviewButton {
Label("Rate on the App Store", systemImage: "text.bubble.fill")
}
#if os(macOS)
if Variant.useSystemExtension {
Tabs.sponsors.navigationLink
}
#endif
}
#endif
Section("Debug") {
FormNavigationLink {
ServiceLogView()
} label: {
Label("Service Log", systemImage: "doc.on.clipboard")
}
FormTextItem("Taiwan Flag Available", "touchid") {
if isLoading {
Text("Loading...")
.onAppear {
Task.detached {
let available: Bool
if ApplicationLibrary.inPreview {
available = true
} else {
available = !DeviceCensorship.isChinaDevice()
}
await MainActor.run {
taiwanFlagAvailable = available
isLoading = false
}
}
}
} else {
Text(taiwanFlagAvailable.description)
}
}
}
}
}
}
@@ -0,0 +1,29 @@
import Foundation
import SwiftUI
public struct SponsorsView: View {
@Environment(\.openURL) private var openURL
public init() {}
public var body: some View {
FormView {
Section {
EmptyView()
} footer: {
Text("**If Ive defended your modern life, please consider sponsoring me.**")
.frame(maxWidth: .infinity, alignment: .leading)
}
FormButton("GitHub Sponsor (recommended)") {
openURL(URL(string: "https://github.com/sponsors/nekohasekai")!)
}
FormButton("Other methods") {
openURL(URL(string: "https://sekai.icu/sponsors/")!)
}
}
.navigationTitle("Sponsors")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
@@ -0,0 +1,63 @@
//
// ActivityIndicatorModifier.swift
// ApplicationLibrary
//
// Created by Mac on 2024/10/23.
//
import Foundation
import SwiftUI
struct ActivityIndicator: UIViewRepresentable {
@Binding var isAnimating: Bool
let style: UIActivityIndicatorView.Style
func makeUIView(context: UIViewRepresentableContext<ActivityIndicator>) -> UIActivityIndicatorView {
return UIActivityIndicatorView(style: style)
}
func updateUIView(_ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext<ActivityIndicator>) {
isAnimating ? uiView.startAnimating() : uiView.stopAnimating()
}
}
public struct ActivityIndicatorModifier: AnimatableModifier {
var isLoading: Bool
public init(isLoading: Bool, color: Color = .primary, lineWidth: CGFloat = 3) {
self.isLoading = isLoading
}
var animatableData: Bool {
get { isLoading }
set { isLoading = newValue }
}
public func body(content: Content) -> some View {
ZStack {
if isLoading {
GeometryReader { geometry in
ZStack(alignment: .center) {
content
.disabled(self.isLoading)
.blur(radius: self.isLoading ? 3 : 0)
VStack {
// Text("...").font(.subheadline)
ActivityIndicator(isAnimating: .constant(true), style: .large)
}
.frame(width: geometry.size.width / 2,
height: geometry.size.height / 5)
.background(Color.secondary.colorInvert())
.foregroundColor(Color.primary)
.cornerRadius(20)
.opacity(self.isLoading ? 1 : 0)
.position(x: geometry.frame(in: .local).midX, y: geometry.frame(in: .local).midY)
}
}
} else {
content
}
}
}
}