add
@@ -1,6 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public class ApplicationLibrary {
|
||||
public static let bundle = Bundle(for: ApplicationLibrary.self)
|
||||
public static let inPreview = false
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
},
|
||||
"symbols": [
|
||||
{
|
||||
"filename": "save-save_symbol.svg",
|
||||
"idiom": "universal"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 38 KiB |
@@ -1,76 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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
|
||||
@@ -1,74 +0,0 @@
|
||||
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!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public extension Binding {
|
||||
func withSetter(_ setter: @escaping (Value) -> Void) -> Binding<Value> {
|
||||
Binding {
|
||||
wrappedValue
|
||||
} set: { [setter] newValue, _ in
|
||||
setter(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
func NavigationDestinationCompat(isPresented: Binding<Bool>, @ViewBuilder destination: () -> some View) -> some View {
|
||||
NavigationLink(
|
||||
destination: destination(),
|
||||
isActive: isPresented,
|
||||
label: {
|
||||
EmptyView()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
#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
|
||||
@@ -1,147 +0,0 @@
|
||||
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
|
||||
@@ -1,6 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public func viewBuilder(@ViewBuilder _ builder: () -> some View) -> some View {
|
||||
builder()
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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)
|
||||
// }
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
//
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
#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
|
||||
@@ -1,296 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
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
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
//
|
||||
// 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()
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
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)])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
//
|
||||
// NodesListView.swift
|
||||
// ApplicationLibrary
|
||||
//
|
||||
// Created by Mac on 2024/10/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
//NodesListView
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
#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
|
||||
@@ -1,177 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
#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
|
||||
@@ -1,253 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
#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
|
||||
@@ -1,50 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
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 doesn’t 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
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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 I’ve 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
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.com.uuvpn.apple</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudDocuments</string>
|
||||
</array>
|
||||
<key>com.apple.developer.networking.networkextension</key>
|
||||
<array>
|
||||
<string>packet-tunnel-provider</string>
|
||||
</array>
|
||||
<key>com.apple.developer.networking.wifi-info</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.ubiquity-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.com.uuvpn.apple</string>
|
||||
</array>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.uuvpn.apple</string>
|
||||
</array>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.networkextension.packet-tunnel</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,4 +0,0 @@
|
||||
import Foundation
|
||||
import Library
|
||||
|
||||
class PacketTunnelProvider: ExtensionProvider {}
|
||||
@@ -1 +0,0 @@
|
||||
github: [groue]
|
||||
@@ -1,31 +0,0 @@
|
||||
<!--
|
||||
ℹ We use Github issues for bug reports, feature requests, general support, and questions about the library.
|
||||
|
||||
Please fill out this template when filing an issue.
|
||||
-->
|
||||
|
||||
### What did you do?
|
||||
|
||||
<!-- Please replace this with what you did. -->
|
||||
|
||||
### What did you expect to happen?
|
||||
|
||||
<!-- Please replace this with what you expected to happen. -->
|
||||
|
||||
### What happened instead?
|
||||
|
||||
<!-- Please replace this with of what happened instead. -->
|
||||
|
||||
### Environment
|
||||
|
||||
**GRDB flavor(s):** (GRDB, SQLCipher, Custom SQLite build?)
|
||||
**GRDB version:**
|
||||
**Installation method:** (CocoaPods, SPM, manual?)
|
||||
**Xcode version:**
|
||||
**Swift version:**
|
||||
**Platform(s) running GRDB:** (iOS, macOS, watchOS?)
|
||||
**macOS version running Xcode:**
|
||||
|
||||
### Demo Project
|
||||
|
||||
<!-- Please link to or upload a project we can download that reproduces the issue. -->
|
||||
@@ -1,14 +0,0 @@
|
||||
<!-- Please describe your pull request here. -->
|
||||
|
||||
### Pull Request Checklist
|
||||
|
||||
<!--
|
||||
Please check the boxes that apply to your pull request:
|
||||
-->
|
||||
|
||||
- [ ] CONTRIBUTING: You have read https://github.com/groue/GRDB.swift/blob/master/CONTRIBUTING.md
|
||||
- [ ] BRANCH: This pull request is submitted against the `development` branch.
|
||||
- [ ] DOCUMENTATION: Inline documentation has been updated.
|
||||
- [ ] DOCUMENTATION: README.md or another dedicated guide has been updated.
|
||||
- [ ] TESTS: Changes are tested.
|
||||
- [ ] TESTS: The `make smokeTest` terminal command runs without failure.
|
||||
@@ -1,6 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -1,189 +0,0 @@
|
||||
# https://github.com/actions/runner-images/blob/main/images/macos/macos-12-Readme.md
|
||||
|
||||
name: "GRDB CI"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- development
|
||||
paths:
|
||||
- 'GRDB/**'
|
||||
- 'Tests/**'
|
||||
- '.github/workflows/**'
|
||||
- 'Makefile'
|
||||
- 'Package.swift'
|
||||
- 'SQLiteCustom/src'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'GRDB/**'
|
||||
- 'Tests/**'
|
||||
- '.github/workflows/**'
|
||||
- 'Makefile'
|
||||
- 'Package.swift'
|
||||
- 'SQLiteCustom/src'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
Framework:
|
||||
name: Framework
|
||||
runs-on: ${{ matrix.runsOn }}
|
||||
env:
|
||||
DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}/Contents/Developer"
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- xcode: "Xcode_14.3.1.app"
|
||||
runsOn: macOS-13
|
||||
destination: "platform=macOS"
|
||||
name: "macOS"
|
||||
- xcode: "Xcode_14.3.1.app"
|
||||
runsOn: macOS-13
|
||||
destination: "OS=16.4,name=iPhone 14 Pro"
|
||||
name: "iOS"
|
||||
- xcode: "Xcode_14.3.1.app"
|
||||
runsOn: macOS-13
|
||||
destination: "OS=16.4,name=Apple TV"
|
||||
name: "tvOS"
|
||||
- xcode: "Xcode_14.2.app"
|
||||
runsOn: macOS-13
|
||||
destination: "platform=macOS"
|
||||
name: "macOS"
|
||||
- xcode: "Xcode_14.2.app"
|
||||
runsOn: macOS-13
|
||||
destination: "OS=16.2,name=iPhone 14"
|
||||
name: "iOS"
|
||||
- xcode: "Xcode_14.1.app"
|
||||
runsOn: macOS-13
|
||||
destination: "platform=macOS"
|
||||
name: "macOS"
|
||||
- xcode: "Xcode_14.1.app"
|
||||
runsOn: macOS-13
|
||||
destination: "OS=16.1,name=iPhone 14"
|
||||
name: "iOS"
|
||||
- xcode: "Xcode_14.0.1.app"
|
||||
runsOn: macOS-12
|
||||
destination: "platform=macOS"
|
||||
name: "macOS"
|
||||
- xcode: "Xcode_14.0.1.app"
|
||||
runsOn: macOS-12
|
||||
destination: "OS=16.0,name=iPhone 14"
|
||||
name: "iOS"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: ${{ matrix.name }}
|
||||
run: set -o pipefail && env NSUnbufferedIO=YES xcodebuild -project GRDB.xcodeproj -scheme GRDB -destination "${{ matrix.destination }}" OTHER_SWIFT_FLAGS='$(inherited) -D SQLITE_ENABLE_FTS5 -D SQLITE_ENABLE_PREUPDATE_HOOK' GCC_PREPROCESSOR_DEFINITIONS='$(inherited) GRDB_SQLITE_ENABLE_PREUPDATE_HOOK=1' clean test
|
||||
SPM:
|
||||
name: SPM
|
||||
runs-on: ${{ matrix.runsOn }}
|
||||
env:
|
||||
DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}/Contents/Developer"
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- xcode: "Xcode_14.3.1.app"
|
||||
runsOn: macOS-13
|
||||
name: "Xcode 14.3.1"
|
||||
- xcode: "Xcode_14.2.app"
|
||||
runsOn: macOS-13
|
||||
name: "Xcode 14.2"
|
||||
- xcode: "Xcode_14.1.app"
|
||||
runsOn: macOS-13
|
||||
name: "Xcode 14.1"
|
||||
- xcode: "Xcode_14.0.1.app"
|
||||
runsOn: macOS-12
|
||||
name: "Xcode 14.0.1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: ${{ matrix.name }}
|
||||
run: make test_SPM test_install_SPM
|
||||
SQLCipher3:
|
||||
name: SQLCipher3
|
||||
runs-on: ${{ matrix.runsOn }}
|
||||
env:
|
||||
DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}/Contents/Developer"
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- xcode: "Xcode_14.3.1.app"
|
||||
runsOn: macOS-13
|
||||
name: "Xcode 14.3.1"
|
||||
- xcode: "Xcode_14.0.1.app"
|
||||
runsOn: macOS-12
|
||||
name: "Xcode 14.0.1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: ${{ matrix.name }}
|
||||
run: make test_framework_SQLCipher3Encrypted
|
||||
SQLCipher4:
|
||||
name: SQLCipher4
|
||||
runs-on: ${{ matrix.runsOn }}
|
||||
env:
|
||||
DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}/Contents/Developer"
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- xcode: "Xcode_14.3.1.app"
|
||||
runsOn: macOS-13
|
||||
name: "Xcode 14.3.1"
|
||||
- xcode: "Xcode_14.0.1.app"
|
||||
runsOn: macOS-12
|
||||
name: "Xcode 14.0.1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: ${{ matrix.name }}
|
||||
run: make test_framework_SQLCipher4Encrypted
|
||||
CustomSQLite:
|
||||
name: CustomSQLite
|
||||
runs-on: ${{ matrix.runsOn }}
|
||||
env:
|
||||
DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}/Contents/Developer"
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- xcode: "Xcode_14.3.1.app"
|
||||
runsOn: macOS-13
|
||||
name: "Xcode 14.3.1"
|
||||
- xcode: "Xcode_14.0.1.app"
|
||||
runsOn: macOS-12
|
||||
name: "Xcode 14.0.1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: ${{ matrix.name }}
|
||||
run: make test_framework_GRDBCustomSQLiteOSX
|
||||
XCFramework:
|
||||
name: XCFramework
|
||||
runs-on: ${{ matrix.runsOn }}
|
||||
env:
|
||||
DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}/Contents/Developer"
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- xcode: "Xcode_14.3.1.app"
|
||||
runsOn: macOS-13
|
||||
name: "Xcode 14.3.1"
|
||||
- xcode: "Xcode_14.0.1.app"
|
||||
runsOn: macOS-12
|
||||
name: "Xcode 14.0.1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: ${{ matrix.name }}
|
||||
run: make test_universal_xcframework
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
## https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
|
||||
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
## https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
|
||||
|
||||
# Windows image file caches
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
|
||||
# Folder config file
|
||||
Desktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
## https://github.com/github/gitignore/blob/master/Global/TextMate.gitignore
|
||||
|
||||
*.tmproj
|
||||
*.tmproject
|
||||
tmtags
|
||||
|
||||
## https://github.com/github/gitignore/blob/master/Global/Vim.gitignore
|
||||
|
||||
[._]*.s[a-w][a-z]
|
||||
[._]s[a-w][a-z]
|
||||
*.un~
|
||||
Session.vim
|
||||
.netrwhist
|
||||
*~
|
||||
|
||||
## https://github.com/github/gitignore/blob/master/Objective-C.gitignore
|
||||
|
||||
# Xcode
|
||||
#
|
||||
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
|
||||
|
||||
# Build generated
|
||||
build/
|
||||
DerivedData
|
||||
|
||||
# Various settings
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
*.mode1v3
|
||||
!default.mode1v3
|
||||
*.mode2v3
|
||||
!default.mode2v3
|
||||
*.perspectivev3
|
||||
!default.perspectivev3
|
||||
xcuserdata
|
||||
|
||||
# Other
|
||||
*.xccheckout
|
||||
*.moved-aside
|
||||
*.xcuserstate
|
||||
*.xcscmblueprint
|
||||
|
||||
# Obj-C/Swift specific
|
||||
*.hmap
|
||||
*.ipa
|
||||
|
||||
# Performance test baselines
|
||||
*.xcbaseline
|
||||
|
||||
# GRDBCustomSQLite USER configuration files
|
||||
SQLiteCustom/GRDBCustomSQLite-USER.h
|
||||
SQLiteCustom/GRDBCustomSQLite-USER.xcconfig
|
||||
|
||||
# CocoaPods test
|
||||
Pods
|
||||
Podfile.lock
|
||||
|
||||
# SPM
|
||||
.build
|
||||
GRDB.xcworkspace/xcshareddata/swiftpm
|
||||
#Package.resolved
|
||||
Tests/SPM/Packages
|
||||
|
||||
# Ruby
|
||||
.ruby-version
|
||||
|
||||
# Test products
|
||||
Tests/products
|
||||
@@ -1,3 +0,0 @@
|
||||
[submodule "SQLiteCustom/src"]
|
||||
path = SQLiteCustom/src
|
||||
url = https://github.com/swiftlyfalling/SQLiteLib.git
|
||||
@@ -1,4 +0,0 @@
|
||||
version: 1
|
||||
builder:
|
||||
configs:
|
||||
- documentation_targets: [GRDB]
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -1,134 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1410"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "GRDB_GRDBTests"
|
||||
BuildableName = "GRDB_GRDBTests"
|
||||
BlueprintName = "GRDB_GRDBTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "GRDB"
|
||||
BuildableName = "GRDB"
|
||||
BlueprintName = "GRDB"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "GRDB-dynamic"
|
||||
BuildableName = "GRDB-dynamic"
|
||||
BlueprintName = "GRDB-dynamic"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "NO"
|
||||
buildForArchiving = "NO"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "GRDBTests"
|
||||
BuildableName = "GRDBTests"
|
||||
BlueprintName = "GRDBTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CSQLite"
|
||||
BuildableName = "CSQLite"
|
||||
BlueprintName = "CSQLite"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "GRDBTests"
|
||||
BuildableName = "GRDBTests"
|
||||
BlueprintName = "GRDBTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "GRDB_GRDBTests"
|
||||
BuildableName = "GRDB_GRDBTests"
|
||||
BlueprintName = "GRDB_GRDBTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -1,161 +0,0 @@
|
||||
# The OS X Build Environment
|
||||
# https://docs.travis-ci.com/user/reference/osx/#xcode-version
|
||||
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
- development
|
||||
|
||||
language: objective-c
|
||||
xcode_project: GRDB.xcodeproj
|
||||
|
||||
# Caches
|
||||
cache:
|
||||
- bundler
|
||||
- cocoapods
|
||||
|
||||
# Custom CocoaPods installation so that Podfile.lock is honored
|
||||
install:
|
||||
- bundle install
|
||||
- bundle exec pod repo update
|
||||
|
||||
# Disable the default Travis-CI submodule logic
|
||||
# The various make commands ensure that the appropriate submodules are retrieved
|
||||
git:
|
||||
submodules: false
|
||||
|
||||
jobs:
|
||||
include:
|
||||
|
||||
###########################################
|
||||
## Test GRDB Xcode 11.4
|
||||
|
||||
- name: "Test GRDB Xcode 11.4 - GRDBiOS iOS maxTarget"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode11.4
|
||||
script: make test_framework_GRDBiOS_maxTarget_maxSwift
|
||||
|
||||
- name: "Test GRDB Xcode 11.4 - GRDBiOS iOS minTarget"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode11.4
|
||||
script: make test_framework_GRDBiOS_minTarget
|
||||
|
||||
###########################################
|
||||
## Test GRDB Xcode 12.5
|
||||
|
||||
- name: "Test GRDB Xcode 12.5 - GRDBOSX - Swift 5.3"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode12.5
|
||||
script: make test_framework_GRDBOSX_maxSwift
|
||||
|
||||
- name: "Test GRDB Xcode 12.5 - GRDBOSX - Swift 5.2"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode12.5
|
||||
script: make test_framework_GRDBOSX_minSwift
|
||||
|
||||
- name: "Test GRDB Xcode 12.5 - GRDBWatchOS"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode12.5
|
||||
script: make test_framework_GRDBWatchOS
|
||||
|
||||
- name: "Test GRDB Xcode 12.5 - GRDBiOS iOS maxTarget"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode12.5
|
||||
script: make test_framework_GRDBiOS_maxTarget_maxSwift
|
||||
|
||||
- name: "Test GRDB Xcode 12.5 - GRDBiOS iOS minTarget"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode12.5
|
||||
script: make test_framework_GRDBiOS_minTarget
|
||||
|
||||
- name: "Test GRDB Xcode 12.5 - GRDBtvOS maxTarget"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode12.5
|
||||
script: make test_framework_GRDBtvOS_maxTarget_maxSwift
|
||||
|
||||
- name: "Test GRDB Xcode 12.5 - GRDBtvOS minTarget"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode12.5
|
||||
script: make test_framework_GRDBtvOS_minTarget
|
||||
|
||||
- name: "Test GRDB Xcode 12.5 - SPM"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode12.5
|
||||
script: make test_SPM
|
||||
|
||||
###########################################
|
||||
## Test GRDBCustom Xcode 12.5
|
||||
|
||||
- name: "Test GRDBCustom Xcode 12.5 - GRDBOSX"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode12.5
|
||||
script: make test_framework_GRDBCustomSQLiteOSX
|
||||
|
||||
- name: "Test GRDBCustom Xcode 12.5 - GRDBiOS maxTarget"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode12.5
|
||||
script: make test_framework_GRDBCustomSQLiteiOS_maxTarget_maxSwift
|
||||
|
||||
- name: "Test GRDBCustom Xcode 12.5 - GRDBiOS minTarget"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode12.5
|
||||
script: make test_framework_GRDBCustomSQLiteiOS_minTarget
|
||||
|
||||
###########################################
|
||||
## Test SQLCipher Xcode 12.5
|
||||
|
||||
- name: "Test SQLCipher Xcode 12.5 - SQLCipher 3"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode12.5
|
||||
script: make test_framework_SQLCipher3
|
||||
|
||||
- name: "Test SQLCipher Xcode 12.5 - SQLCipher 4"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode12.5
|
||||
script: make test_framework_SQLCipher4
|
||||
|
||||
###########################################
|
||||
## Test Installation Xcode 12.5
|
||||
|
||||
# Manual Install
|
||||
- name: "Test Installation Xcode 12.5 - Manual Install"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode12.5
|
||||
script: make test_install_manual
|
||||
|
||||
# Travis is much too slow, and always fail this job
|
||||
# # CocoaPods Lint
|
||||
# - name: "Test Installation Xcode 12.5 - CocoaPods Lint"
|
||||
# gemfile: .ci/gemfiles/Gemfile.travis
|
||||
# osx_image: xcode12.5
|
||||
# script: make test_CocoaPodsLint_GRDB
|
||||
|
||||
# CocoaPods Install GRDB
|
||||
- name: "Test Installation Xcode 12.5 - CocoaPods Framework"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode12.5
|
||||
script: make test_install_GRDB_CocoaPods_framework
|
||||
|
||||
# CocoaPods Install GRDB
|
||||
- name: "Test Installation Xcode 12.5 - CocoaPods Static"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode12.5
|
||||
script: make test_install_GRDB_CocoaPods_static
|
||||
|
||||
# SPM Install
|
||||
- name: "Test Installation Xcode 12.5 - SPM Package"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode12.5
|
||||
script: make test_install_SPM_Package
|
||||
|
||||
# SPM Install
|
||||
- name: "Test Installation Xcode 12.5 - SPM Package in Xcode Project"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode12.5
|
||||
script: make test_install_SPM_Project
|
||||
|
||||
# Custom SQLite Install
|
||||
- name: "Test Installation Xcode 12.5 - Custom SQLite"
|
||||
gemfile: .ci/gemfiles/Gemfile.travis
|
||||
osx_image: xcode12.5
|
||||
script: make test_install_customSQLite
|
||||
@@ -1,181 +0,0 @@
|
||||
# Brag Document
|
||||
|
||||
Here is a list of people who were happy to meet GRDB :-)
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/DamienPetrilli/status/1623922310976786433
|
||||
|
||||
> I can't overstate how much more productive I am since I ditched the obsolete Core Data framework and started to use GRDB instead.
|
||||
>
|
||||
> The hard stuff is doable, and the simple stuff is even simpler, as it should be.
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/fullqueuedev/status/1603350969357684736
|
||||
|
||||
> Yes, I much prefer GRDB. I’ve tested many SQLite wrappers over the years 💜
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/cassiuspacheco/status/1574948903857770497
|
||||
|
||||
> SQLite using @groue’s GRDB, no brainer! Awesome documentation, support and beautifully designed.
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/simrandotdev/status/1552004471122587649
|
||||
|
||||
> We get Core Data from Apple which is the worst library to deal with.
|
||||
> Then there is Realm which is nice but again you do not get the RAW SQL power as it is its own type of Db.
|
||||
> Then comes the most beautiful library every written which is very close to Room called GRDB.
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/bellebcooper/status/1534070340850094080
|
||||
|
||||
> When I first started using GRDB I didn't know SQL or much at all about databases 😬 So I've always really liked the query API. But these days I really appreciate that you support raw SQL too, because it gives me more control and freedom when I need it. 👍👍👍
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/mikeash/status/1504810545429499909
|
||||
|
||||
> Another cool library using SQL interpolation!
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/swiftkarim/status/1354753451377483781
|
||||
|
||||
> I cannot recommend GRDB enough. It works well with Swift and eliminates whole classes of potential programming errors that can be made with Core Data. Also being able to use raw SQL queries if needed can be extremely useful!
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/peres/status/1352219836508729346
|
||||
|
||||
> Just the occasional reminder that GRDB and its documentation is the gold standard. That's it.
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/steipete/status/1275305583718129664
|
||||
|
||||
> This is the future of Core Data
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/_maxheld/status/1266509824994553860
|
||||
|
||||
> I tried out GRDB since I saw you mention it a few days ago and absolutely love it. It’s so nice and testing the database logic is so much nicer than CoreData
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/stephencelis/status/1263518304980918274
|
||||
|
||||
> GRDB.swift is great! (I say this as the person that started SQLite.swift long ago.) And @groue’s very helpful and friendly.
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/peres/status/1262682199482142720
|
||||
|
||||
> GRDB in already in my top3 when it comes to documentation. Clearly and pragmatically explained.
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/layoutsubviews/status/1249940744929816582
|
||||
|
||||
> Mixing SQLite / GRDB with Combine is my whole world right now, and it is delightful.
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/steipete/status/1231105339954802694
|
||||
|
||||
> It [Core Data] gets the job done, but folks made some really amazing Swift native sqlite wrappers.
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/BelleBCooper/status/1223148814980091904
|
||||
|
||||
> I LOVE GRDB and highly recommend it. If you don’t need it now, do keep it in mind for future because it’s great and the documentation and support are top notch.
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/layoutSubviews/status/1202092984822648832
|
||||
|
||||
> GRDB is quickly becoming the best package I have ever dealt with.
|
||||
> What an exceptional piece of work: the philosophy, the documentation, the API, the abstractions, the Swiftiness👌
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/fpillet/status/1193608995405209607
|
||||
|
||||
> GRDB keeps being the best-in-class interface to your database, and if you're not using it you're missing out. As always, amazing work from @groue !
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/lordmooch/status/1193537540583755776
|
||||
|
||||
> Best SQLite/Swift integration 😀👍🏻
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/BelleBCooper/status/1192195655743111168
|
||||
|
||||
> I’ve been working on a side project recently that uses SwiftUI (+UIKit for parent controllers and nav), Combine, GRDB, and GRDBCombine for automatic updating based on DB changes. I’m really liking this setup.
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/BelleBCooper/status/1186034269577048064
|
||||
|
||||
> Working on a new project today and using GRDB by @groue for the backend. This is my go-to for new Swift projects. Highly recommend it!
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/krzyzanowskim/status/1156568071303290882
|
||||
|
||||
> I think GRDB.swift is a godsend. So far sooooo good. (change my mind?)
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/iOSDevWeekly/status/1133780078398021637
|
||||
|
||||
> How had I never heard of GRDB before this week? Was I the only one missing out, or is this wonderful database library from @groue news to you all too? https://buff.ly/2X8yHGf #iosdev
|
||||
|
||||
----
|
||||
|
||||
https://twitter.com/micpringle/status/1132264501812891648
|
||||
|
||||
> Spent the last few weeks integrating it and it’s amazing! And this release brings a whole load more features—especially excited about eager loading of hasMany relationships.
|
||||
>
|
||||
> Quite possibly one of the best documented open source libs I’ve come across as well👌🏼
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/danielemargutti/status/1132217792902647808
|
||||
|
||||
> It’s a great successor for FMDB with tons of great stuff. Worth a look, we have choose it in our new project even if, actually, I preferred not using some of the ‘ORM’ like features.
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/lordmooch/status/1131664588494254086
|
||||
|
||||
> Woohoo! Absolutely superb Swift library for SQLite - I love it 😀
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/JadenGeller/status/1115483150564487168
|
||||
|
||||
> Wow, there are some *really* good looking SQLite frameworks in Swift.
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/strawb3rryx7/status/1073620070784516096
|
||||
|
||||
> If you looking for a SQLite wrapper in Swift, just take a look the GRDB library! 👇🏼 https://github.com/groue/GRDB.swift Well-maintained and documented! ✊🏼
|
||||
|
||||
---
|
||||
|
||||
https://twitter.com/BelleBCooper/status/1047017928112128000
|
||||
|
||||
> http://blog.bellebcooper.com/2018-10-02-16-39.html
|
||||
>
|
||||
> I love the way issues are handled in GRDB (SQLite for Swift). Groue is so friendly, and always goes above-and-beyond in helping to solve problems or explore new features for the library. It makes me happy to be a GRDB user.
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# Contributor Code of Conduct
|
||||
|
||||
As contributors and maintainers of this project, and in the interest of
|
||||
fostering an open and welcoming community, we pledge to respect all people who
|
||||
contribute through reporting issues, posting feature requests, updating
|
||||
documentation, submitting pull requests or patches, and other activities.
|
||||
|
||||
We are committed to making participation in this project a harassment-free
|
||||
experience for everyone, regardless of level of experience, gender, gender
|
||||
identity and expression, sexual orientation, disability, personal appearance,
|
||||
body size, race, ethnicity, age, religion, or nationality.
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
- The use of sexualized language or imagery
|
||||
- Personal attacks
|
||||
- Trolling or insulting/derogatory comments
|
||||
- Public or private harassment
|
||||
- Publishing other's private information, such as physical or electronic
|
||||
addresses, without explicit permission
|
||||
- Other unethical or unprofessional conduct
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
By adopting this Code of Conduct, project maintainers commit themselves to
|
||||
fairly and consistently applying these principles to every aspect of managing
|
||||
this project. Project maintainers who do not follow or enforce the Code of
|
||||
Conduct may be permanently removed from the project team.
|
||||
|
||||
This code of conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting a project maintainer at [gr@pierlis.com](mailto:gr@pierlis.com).
|
||||
|
||||
All complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. Maintainers are
|
||||
obligated to maintain confidentiality with regard to the reporter of an
|
||||
incident.
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 1.3.0, available at
|
||||
[http://contributor-covenant.org/version/1/3/0/][version]
|
||||
|
||||
[homepage]: http://contributor-covenant.org
|
||||
[version]: http://contributor-covenant.org/version/1/3/0/
|
||||
@@ -1,99 +0,0 @@
|
||||
How you can Contribute
|
||||
======================
|
||||
|
||||
- [Contribute to the GRDB codebase]
|
||||
- [Answer issues and contribute to discussions]
|
||||
- [Financial support and sponsoring]
|
||||
|
||||
## Contribute to the GRDB codebase
|
||||
|
||||
1. **Install** the following tools:
|
||||
|
||||
- For running tests:
|
||||
- Ruby
|
||||
- [CocoaPods](https://cocoapods.org) (gem)
|
||||
- [xcpretty](https://github.com/xcpretty/xcpretty) (gem)
|
||||
- For contributing code:
|
||||
- [Swiftlint](https://github.com/realm/SwiftLint) (present in the `$PATH` or in `/opt/homebrew/bin/`)
|
||||
|
||||
2. **Clone the `development` branch on your machine.**
|
||||
|
||||
3. **Run `make SQLiteCustom` in the terminal**, at the root of the repository.
|
||||
|
||||
This step is not strictly necessary if you do not plan to work on custom SQLite builds. But it will avoid a lot of warnings in Xcode.
|
||||
|
||||
4. **Open `GRDB.xcworkspace`.** This workspace contains six projects:
|
||||
|
||||
- `GRDB.xcodeproj`: the project which contains the GRDB framework and test targets. The sources are organized in two groups:
|
||||
- `GRDB`: GRDB sources
|
||||
- `Tests`: GRDB tests
|
||||
- `GRDBCustom.xcodeproj`: the project which builds GRDB with a custom SQLite build.
|
||||
- `GRDBProfiling.xcodeproj`: a project which helps profiling GRDB with Instruments. No code here is really precious.
|
||||
- `GRDBDemoiOS.xcodeproj`, `GRDBCombineDemo.xcodeproj`, `GRDBAsyncDemo.xcodeproj`: the demo apps.
|
||||
|
||||
To work with `GRDBCustom.xcodeproj`:
|
||||
|
||||
- Make sure Xcode is installed in `/Applications`.
|
||||
- Run `make SQLiteCustom` in the terminal so that SQLite sources are present.
|
||||
|
||||
5. **Run tests**
|
||||
|
||||
You can run tests from `GRDB.xcworkspace`, after selecting your scheme (`GRDB` from `GRDB.xcodeproj` or `GRDBCustom` from `GRDBCustom.xcodeproj`).
|
||||
|
||||
Before submitting a pull request, please run in the terminal:
|
||||
|
||||
```sh
|
||||
make smokeTest
|
||||
```
|
||||
|
||||
The "smoke tests" perform minimal testing of the system SQLite, SQLCipher, custom SQLite builds, as well as SPM integration.
|
||||
|
||||
Before a release, the full test suite must pass:
|
||||
|
||||
```sh
|
||||
make test
|
||||
```
|
||||
|
||||
The full test suite performs many more checks, such as the ability to archive an XCFramework, various installation methods, the demo apps, etc.
|
||||
|
||||
6. **Please respect the existing coding style**
|
||||
|
||||
- Get familiar with the [Swift API Design Guidelines](https://www.swift.org/documentation/api-design-guidelines/).
|
||||
- Spaces, not tabs.
|
||||
- Whitespace-only lines are not trimmed.
|
||||
- Documentation comments are hard-wrapped at column 76 (Xcode > Preferences > Text Editing > Display > [X] Page guide at column: 76).
|
||||
- No Swiftlint warning after a build.
|
||||
|
||||
|
||||
7. **Please provide documentation for your changes**
|
||||
|
||||
GRDB documentation is provided as a DocC reference, and guides ([README.md](README.md) and the [Documentation](Documentation) folder).
|
||||
|
||||
Please keep the reference and the guides up-to-date. To control the quality of your DocC reference documentation, close the workspace, open `Package.swift` in Xcode, and use Product > Build Documentation.
|
||||
|
||||
GRDB is "documentation-driven", which means that nothing ships until it is supported by documentation that makes sense. Documentation makes sense when someone who is not you is able to figure out what is the purpose of your contribution, how to use it, and what are its eventual caveats and corner cases. When the documentation is hard to write, or reveals too many caveats, it is the sign that the api needs to be fixed.
|
||||
|
||||
8. **Open a pull request with your changes (targeting the `development` branch)!**
|
||||
|
||||
9. **If you are granted a push access to the repository**, check [ReleaseProcess.md](Documentation/ReleaseProcess.md) in order to publish a new GRDB version.
|
||||
|
||||
|
||||
## Answer issues and contribute to discussions
|
||||
|
||||
Answering [issues](https://github.com/groue/GRDB.swift/issues), participating in [discussions](https://github.com/groue/GRDB.swift/discussions) and in the [forum](https://forums.swift.org/c/related-projects/grdb/36) is a great way to help, get familiar with the library, and shape its direction.
|
||||
|
||||
|
||||
## Financial support and sponsoring
|
||||
|
||||
GRDB is free, and openly developed by its contributors, on their free time, according to their will, needs, and availability. It is not controlled by any company.
|
||||
|
||||
Support is free as well, as long as it happens publicly, and is available to everyone as well as internet search engines. This excludes the private channels of social networks, closed environments such as Slack, etc.
|
||||
|
||||
When you have specific development or support needs, please send an email to [Gwendal Roué](mailto:gr@pierlis.com) so that we can enter a regular business relationship through the [Pierlis](http://pierlis.com/) company, based in Paris, France.
|
||||
|
||||
You can also [sponsor @groue via GitHub](https://github.com/sponsors/groue).
|
||||
|
||||
|
||||
[Contribute to the GRDB codebase]: #contribute-to-the-grdb-codebase
|
||||
[Answer issues and contribute to discussions]: #answer-issues-and-contribute-to-discussions
|
||||
[Financial support and sponsoring]: #financial-support-and-sponsoring
|
||||
@@ -1,4 +0,0 @@
|
||||
Sharing a Database in an App Group Container
|
||||
============================================
|
||||
|
||||
This guide [has moved](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databasesharing).
|
||||
@@ -1,404 +0,0 @@
|
||||
GRDB ❤️ Combine
|
||||
===============
|
||||
|
||||
**On systems supporting the Combine framework, GRDB offers the ability to publish database values and events using Combine's publishers.**
|
||||
|
||||
- [Usage]
|
||||
- [Demo Application]
|
||||
- [Asynchronous Database Access]
|
||||
- [Database Observation]
|
||||
- [Combine and Data Consistency]: take care when you combine database publishers together
|
||||
|
||||
## Usage
|
||||
|
||||
To connect to the database, please refer to [Database Connections].
|
||||
|
||||
<details>
|
||||
<summary><strong>Asynchronously read from the database</strong></summary>
|
||||
|
||||
This publisher reads a single value and delivers it.
|
||||
|
||||
```swift
|
||||
// DatabasePublishers.Read<[Player]>
|
||||
let players = dbQueue.readPublisher { db in
|
||||
try Player.fetchAll(db)
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Asynchronously write in the database</strong></summary>
|
||||
|
||||
This publisher updates the database and delivers a single value.
|
||||
|
||||
```swift
|
||||
// DatabasePublishers.Write<Void>
|
||||
let write = dbQueue.writePublisher { db in
|
||||
try Player(...).insert(db)
|
||||
}
|
||||
|
||||
// DatabasePublishers.Write<Int>
|
||||
let newPlayerCount = dbQueue.writePublisher { db -> Int in
|
||||
try Player(...).insert(db)
|
||||
return try Player.fetchCount(db)
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Asynchronously migrate the database</strong></summary>
|
||||
|
||||
This publisher migrates a database:
|
||||
|
||||
```swift
|
||||
// DatabasePublishers.Migrate
|
||||
let migrator: DatabaseMigrator = ...
|
||||
let publisher = migrator.migratePublisher(dbQueue)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Observe changes in database values</strong></summary>
|
||||
|
||||
This publisher delivers fresh values whenever the database changes:
|
||||
|
||||
```swift
|
||||
// A publisher with output [Player] and failure Error
|
||||
let publisher = ValueObservation
|
||||
.tracking { db in try Player.fetchAll(db) }
|
||||
.publisher(in: dbQueue)
|
||||
|
||||
// A publisher with output Int? and failure Error
|
||||
let publisher = ValueObservation
|
||||
.tracking { db in try Int.fetchOne(db, sql: "SELECT MAX(score) FROM player") }
|
||||
.publisher(in: dbQueue)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Observe database transactions</strong></summary>
|
||||
|
||||
This publisher delivers database connections whenever a database transaction has impacted an observed region:
|
||||
|
||||
```swift
|
||||
// A publisher with output Database and failure Error
|
||||
let publisher = DatabaseRegionObservation
|
||||
.tracking(Player.all())
|
||||
.publisher(in: dbQueue)
|
||||
|
||||
let cancellable = publisher.sink(
|
||||
receiveCompletion: { completion in ... },
|
||||
receiveValue: { (db: Database) in
|
||||
print("Exclusive write access to the database after players have been impacted")
|
||||
})
|
||||
|
||||
// A publisher with output Database and failure Error
|
||||
let publisher = DatabaseRegionObservation
|
||||
.tracking(SQLRequest<Int>(sql: "SELECT MAX(score) FROM player"))
|
||||
.publisher(in: dbQueue)
|
||||
|
||||
let cancellable = publisher.sink(
|
||||
receiveCompletion: { completion in ... },
|
||||
receiveValue: { (db: Database) in
|
||||
print("Exclusive write access to the database after maximum score has been impacted")
|
||||
})
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
# Asynchronous Database Access
|
||||
|
||||
GRDB provide publishers that perform asynchronous database accesses:
|
||||
|
||||
- [`readPublisher(receiveOn:value:)`]
|
||||
- [`writePublisher(receiveOn:updates:)`]
|
||||
- [`writePublisher(receiveOn:updates:thenRead:)`]
|
||||
- [`migratePublisher(_:receiveOn:)`]
|
||||
|
||||
|
||||
#### `DatabaseReader.readPublisher(receiveOn:value:)`
|
||||
|
||||
This methods returns a publisher that completes after database values have been asynchronously fetched.
|
||||
|
||||
```swift
|
||||
// DatabasePublishers.Read<[Player]>
|
||||
let players = dbQueue.readPublisher { db in
|
||||
try Player.fetchAll(db)
|
||||
}
|
||||
```
|
||||
|
||||
Any attempt at modifying the database completes subscriptions with an error.
|
||||
|
||||
When you use a [database queue] or a [database snapshot], the read has to wait for any eventual concurrent database access performed by this queue or snapshot to complete.
|
||||
|
||||
When you use a [database pool], reads are generally non-blocking, unless the maximum number of concurrent reads has been reached. In this case, a read has to wait for another read to complete. That maximum number can be [configured].
|
||||
|
||||
This publisher can be subscribed from any thread. A new database access starts on every subscription.
|
||||
|
||||
The fetched value is published on the main queue, unless you provide a specific [scheduler] to the `receiveOn` argument.
|
||||
|
||||
|
||||
#### `DatabaseWriter.writePublisher(receiveOn:updates:)`
|
||||
|
||||
This method returns a publisher that completes after database updates have been successfully executed inside a database transaction.
|
||||
|
||||
```swift
|
||||
// DatabasePublishers.Write<Void>
|
||||
let write = dbQueue.writePublisher { db in
|
||||
try Player(...).insert(db)
|
||||
}
|
||||
|
||||
// DatabasePublishers.Write<Int>
|
||||
let newPlayerCount = dbQueue.writePublisher { db -> Int in
|
||||
try Player(...).insert(db)
|
||||
return try Player.fetchCount(db)
|
||||
}
|
||||
```
|
||||
|
||||
This publisher can be subscribed from any thread. A new database access starts on every subscription.
|
||||
|
||||
It completes on the main queue, unless you provide a specific [scheduler] to the `receiveOn` argument.
|
||||
|
||||
When you use a [database pool], and your app executes some database updates followed by some slow fetches, you may profit from optimized scheduling with [`writePublisher(receiveOn:updates:thenRead:)`]. See below.
|
||||
|
||||
|
||||
#### `DatabaseWriter.writePublisher(receiveOn:updates:thenRead:)`
|
||||
|
||||
This method returns a publisher that completes after database updates have been successfully executed inside a database transaction, and values have been subsequently fetched:
|
||||
|
||||
```swift
|
||||
// DatabasePublishers.Write<Int>
|
||||
let newPlayerCount = dbQueue.writePublisher(
|
||||
updates: { db in try Player(...).insert(db) }
|
||||
thenRead: { db, _ in try Player.fetchCount(db) })
|
||||
}
|
||||
```
|
||||
|
||||
It publishes exactly the same values as [`writePublisher(receiveOn:updates:)`]:
|
||||
|
||||
```swift
|
||||
// DatabasePublishers.Write<Int>
|
||||
let newPlayerCount = dbQueue.writePublisher { db -> Int in
|
||||
try Player(...).insert(db)
|
||||
return try Player.fetchCount(db)
|
||||
}
|
||||
```
|
||||
|
||||
The difference is that the last fetches are performed in the `thenRead` function. This function accepts two arguments: a readonly database connection, and the result of the `updates` function. This allows you to pass information from a function to the other (it is ignored in the sample code above).
|
||||
|
||||
When you use a [database pool], this method applies a scheduling optimization: the `thenRead` function sees the database in the state left by the `updates` function, and yet does not block any concurrent writes. This can reduce database write contention.
|
||||
|
||||
When you use a [database queue], the results are guaranteed to be identical, but no scheduling optimization is applied.
|
||||
|
||||
This publisher can be subscribed from any thread. A new database access starts on every subscription.
|
||||
|
||||
It completes on the main queue, unless you provide a specific [scheduler] to the `receiveOn` argument.
|
||||
|
||||
|
||||
# Database Observation
|
||||
|
||||
Database Observation publishers are based on [ValueObservation] and [DatabaseRegionObservation]. Please refer to their documentation for more information. If your application needs change notifications that are not built as Combine publishers, check the general [Database Changes Observation] chapter.
|
||||
|
||||
- [`ValueObservation.publisher(in:scheduling:)`]
|
||||
- [`SharedValueObservation.publisher()`]
|
||||
- [`DatabaseRegionObservation.publisher(in:)`]
|
||||
|
||||
|
||||
#### `ValueObservation.publisher(in:scheduling:)`
|
||||
|
||||
[ValueObservation] tracks changes in database values. You can turn it into a Combine publisher:
|
||||
|
||||
```swift
|
||||
let observation = ValueObservation.tracking { db in
|
||||
try Player.fetchAll(db)
|
||||
}
|
||||
|
||||
// A publisher with output [Player] and failure Error
|
||||
let publisher = observation.publisher(in: dbQueue)
|
||||
```
|
||||
|
||||
This publisher has the same behavior as ValueObservation:
|
||||
|
||||
- It notifies an initial value before the eventual changes.
|
||||
- It may coalesce subsequent changes into a single notification.
|
||||
- It may notify consecutive identical values. You can filter out the undesired duplicates with the `removeDuplicates()` Combine operator, but we suggest you have a look at the [removeDuplicates()](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/valueobservation/removeduplicates()) GRDB operator also.
|
||||
- It only completes when it is cancelled.
|
||||
- By default, it notifies the initial value, as well as eventual changes and errors, on the main thread, asynchronously.
|
||||
|
||||
This can be configured with the `scheduling` argument. It does not accept a Combine scheduler, but a [ValueObservationScheduler](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/valueobservationscheduler).
|
||||
|
||||
For example, the `.immediate` scheduler makes sure the initial value is notified immediately when the publisher is subscribed. It can help your application update the user interface without having to wait for any asynchronous notifications:
|
||||
|
||||
```swift
|
||||
// Immediate notification of the initial value
|
||||
let cancellable = observation
|
||||
.publisher(
|
||||
in: dbQueue,
|
||||
scheduling: .immediate) // <-
|
||||
.sink(
|
||||
receiveCompletion: { completion in ... },
|
||||
receiveValue: { (players: [Player]) in print("Fresh players: \(players)") })
|
||||
// <- here "fresh players" is already printed.
|
||||
```
|
||||
|
||||
Note that the `.immediate` scheduler requires that the publisher is subscribed from the main thread. It raises a fatal error otherwise.
|
||||
|
||||
|
||||
#### `SharedValueObservation.publisher()`
|
||||
|
||||
[SharedValueObservation] tracks changes in database values. You can turn it into a Combine publisher:
|
||||
|
||||
```swift
|
||||
let sharedObservation = ValueObservation
|
||||
.tracking { db in try Player.fetchAll(db) }
|
||||
.shared(in: dbQueue)
|
||||
|
||||
// A publisher with output [Player] and failure Error
|
||||
let publisher = sharedObservation.publisher()
|
||||
```
|
||||
|
||||
This publisher has the same behavior as SharedValueObservation.
|
||||
|
||||
|
||||
#### `DatabaseRegionObservation.publisher(in:)`
|
||||
|
||||
[DatabaseRegionObservation] notifies all transactions that impact a tracked database region. You can turn it into a Combine publisher:
|
||||
|
||||
```swift
|
||||
let request = Player.all()
|
||||
let observation = DatabaseRegionObservation.tracking(request)
|
||||
|
||||
// A publisher with output Database and failure Error
|
||||
let publisher = observation.publisher(in: dbQueue)
|
||||
```
|
||||
|
||||
This publisher can be created and subscribed from any thread. It delivers database connections in a "protected dispatch queue", serialized with all database updates. It only completes when a database error happens.
|
||||
|
||||
```swift
|
||||
let request = Player.all()
|
||||
let cancellable = DatabaseRegionObservation
|
||||
.tracking(request)
|
||||
.publisher(in: dbQueue)
|
||||
.sink(
|
||||
receiveCompletion: { completion in ... },
|
||||
receiveValue: { (db: Database) in
|
||||
print("Players have changed.")
|
||||
})
|
||||
|
||||
try dbQueue.write { db in
|
||||
try Player(name: "Arthur").insert(db)
|
||||
try Player(name: "Barbara").insert(db)
|
||||
}
|
||||
// Prints "Players have changed."
|
||||
|
||||
try dbQueue.write { db in
|
||||
try Player.deleteAll(db)
|
||||
}
|
||||
// Prints "Players have changed."
|
||||
```
|
||||
|
||||
See [DatabaseRegionObservation] for more information.
|
||||
|
||||
|
||||
## Combine and Data Consistency
|
||||
|
||||
When you compose database publishers together with Combine operators such as `combineLatest` or `zip`, you lose all guarantees of [data consistency](https://en.wikipedia.org/wiki/Consistency_(database_systems)).
|
||||
|
||||
This is because each database publisher is isolated from others: each one of them sees its own state of the database. Whenever some database change is interleaved between publisher operations, publishers will process or publish values that may not fit well together.
|
||||
|
||||
In other words, whenever you need to perform some database access or observation that depends on some database invariant, make sure you define one and only one database publisher instead of combining several publishers. This is how you will prevent eventual concurrent database writes from messing with your app, and introduce bugs.
|
||||
|
||||
To this end, remember that *all database publishers can perform several requests*.
|
||||
|
||||
In the example below, we are totally sure that the published `HallOfFame` values will never contain inconsistent values, because it is produced by one and only one publisher:
|
||||
|
||||
```swift
|
||||
struct HallOfFame {
|
||||
// Invariant: bestPlayers.count <= totalPlayerCount
|
||||
var totalPlayerCount: Int
|
||||
var bestPlayers: [Player]
|
||||
}
|
||||
|
||||
// CORRECT: DATA CONSISTENCY GUARANTEED
|
||||
let hallOfFamePublisher = ValueObservation
|
||||
.tracking { db -> HallOfFame in
|
||||
// 1st request
|
||||
let totalPlayerCount = try Player.fetchCount(db)
|
||||
|
||||
// 2nd request
|
||||
let bestPlayers = try Player
|
||||
.order(Column("score").desc)
|
||||
.limit(10)
|
||||
.fetchAll(db)
|
||||
|
||||
// 100% guaranteed
|
||||
assert(bestPlayers.count <= totalPlayerCount)
|
||||
|
||||
// Merge results together
|
||||
return HallOfFame(
|
||||
totalPlayerCount: totalPlayerCount,
|
||||
bestPlayers: bestPlayers)
|
||||
}
|
||||
.publisher(in: dbQueue)
|
||||
```
|
||||
|
||||
Compare with the incorrect version that combines two database publishers together:
|
||||
|
||||
```swift
|
||||
// OK
|
||||
let totalPlayerCountPublisher = ValueObservation
|
||||
.tracking(Player.fetchCount)
|
||||
.publisher(in: dbQueue)
|
||||
|
||||
// OK
|
||||
let bestPlayerPublisher = ValueObservation
|
||||
.tracking(Player
|
||||
.order(Column("score").desc)
|
||||
.limit(10)
|
||||
.fetchAll)
|
||||
.publisher(in: dbQueue)
|
||||
|
||||
// NOT OK: DATA CONSISTENCY NOT GUARANTEED
|
||||
let hallOfFamePublisher = totalPlayerCountPublisher
|
||||
.combineLatest(bestPlayerPublisher)
|
||||
.map(HallOfFame.init(totalPlayerCount:bestPlayers))
|
||||
|
||||
let cancellable = hallOfFamePublisher.sink(
|
||||
receiveCompletion: { completion in ... },
|
||||
receiveValue: { hallOfFame in
|
||||
// ASSERTION MAY FAIL if some players are deleted
|
||||
// at the wrong time
|
||||
assert(hallOfFame.bestPlayers.count <= hallOfFame.totalPlayerCount)
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
[Database Connections]: ../README.md#database-connections
|
||||
[Usage]: #usage
|
||||
[Asynchronous Database Access]: #asynchronous-database-access
|
||||
[Combine]: https://developer.apple.com/documentation/combine
|
||||
[Database Changes Observation]: https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseobservation
|
||||
[Database Observation]: #database-observation
|
||||
[Combine and Data Consistency]: #combine-and-data-consistency
|
||||
[DatabaseRegionObservation]: https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseregionobservation
|
||||
[Demo Application]: DemoApps/GRDBCombineDemo/README.md
|
||||
[SQLite]: http://sqlite.org
|
||||
[ValueObservation]: https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/valueobservation
|
||||
[SharedValueObservation]: https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/sharedvalueobservation
|
||||
[`DatabaseRegionObservation.publisher(in:)`]: #databaseregionobservationpublisherin
|
||||
[`ValueObservation.publisher(in:scheduling:)`]: #valueobservationpublisherinscheduling
|
||||
[`SharedValueObservation.publisher()`]: #sharedvalueobservationpublisher
|
||||
[`readPublisher(receiveOn:value:)`]: #databasereaderreadpublisherreceiveonvalue
|
||||
[`writePublisher(receiveOn:updates:)`]: #databasewriterwritepublisherreceiveonupdates
|
||||
[`writePublisher(receiveOn:updates:thenRead:)`]: #databasewriterwritepublisherreceiveonupdatesthenread
|
||||
[`migratePublisher(_:receiveOn:)`]: https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databasemigrator/migratepublisher(_:receiveon:)
|
||||
[configured]: ../README.md#databasepool-configuration
|
||||
[database pool]: https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databasepool
|
||||
[database queue]: https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databasequeue
|
||||
[database snapshot]: https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databasesnapshot
|
||||
[scheduler]: https://developer.apple.com/documentation/combine/scheduler
|
||||
@@ -1,414 +0,0 @@
|
||||
Common Table Expressions
|
||||
========================
|
||||
|
||||
[**:fire: EXPERIMENTAL**](../README.md#what-are-experimental-features)
|
||||
|
||||
---
|
||||
|
||||
**Common table expressions** (CTEs) can generally be seen as *SQL views that you define on the fly*.
|
||||
|
||||
A certain level of familiarity with SQL databases is helpful before you dive into this guide. The starting point is obviously the [SQLite documentation](https://sqlite.org/lang_with.html). Many CTE tutorials exist online as well, including [this good one](https://blog.expensify.com/2015/09/25/the-simplest-sqlite-common-table-expression-tutorial/).
|
||||
|
||||
In this guide, you will learn how to:
|
||||
|
||||
- [Define Common Table Expressions]
|
||||
- [Embed Common Table Expressions in Requests]
|
||||
- [Fetch Values From Common Table Expressions]
|
||||
- Join CTEs with [Associations to Common Table Expressions]
|
||||
|
||||
> **Note**: most code examples will be trivial, and not very "useful". This is because the goal of this guide is to stay focused on the GRDB support for CTEs. Rich setup would just be distracting. So bring your own good ideas with you!
|
||||
|
||||
|
||||
## Define Common Table Expressions
|
||||
|
||||
You will create a `CommonTableExpression` definition first. Choose a **name**, and a **request** that provides the content of the common table expression.
|
||||
|
||||
The CTE name is like a regular table name: pick one that does not conflict with the names of existing tables.
|
||||
|
||||
The CTE request can be provided as a [query interface request]:
|
||||
|
||||
```swift
|
||||
// WITH playerName AS (SELECT name FROM player) ...
|
||||
let playerNameCTE = CommonTableExpression(
|
||||
named: "playerName",
|
||||
request: Player.select(Column("name")))
|
||||
```
|
||||
|
||||
You can feed a CTE with raw SQL as well (second and third examples use [SQL Interpolation]):
|
||||
|
||||
```swift
|
||||
let name = "O'Brien"
|
||||
|
||||
// WITH playerName AS (SELECT 'O''Brien') ...
|
||||
let playerNameCTE = CommonTableExpression(
|
||||
named: "playerName",
|
||||
sql: "SELECT ?", arguments: [name])
|
||||
|
||||
// WITH playerName AS (SELECT 'O''Brien') ...
|
||||
let playerNameCTE = CommonTableExpression(
|
||||
named: "playerName",
|
||||
literal: "SELECT \(name)")
|
||||
|
||||
// WITH playerName AS (SELECT 'O''Brien') ...
|
||||
let request = SQLRequest("SELECT \(name)")
|
||||
let playerNameCTE = CommonTableExpression(
|
||||
named: "playerName",
|
||||
request: request)
|
||||
```
|
||||
|
||||
All CTEs can be provided with explicit column names:
|
||||
|
||||
```swift
|
||||
// WITH pair(a, b) AS (SELECT 1, 2) ...
|
||||
let pairCTE = CommonTableExpression(
|
||||
named: "pair",
|
||||
columns: ["a", "b"],
|
||||
sql: "SELECT 1, 2")
|
||||
```
|
||||
|
||||
Recursive CTEs need the `recursive` flag. The example below selects all integers between 1 and 1000:
|
||||
|
||||
```swift
|
||||
// WITH RECURSIVE counter(x) AS
|
||||
// (VALUES(1) UNION ALL SELECT x+1 FROM counter WHERE x<1000)
|
||||
let counterCTE = CommonTableExpression(
|
||||
recursive: true,
|
||||
named: "counter",
|
||||
columns: ["x"],
|
||||
sql: """
|
||||
VALUES(1)
|
||||
UNION ALL
|
||||
SELECT x+1 FROM counter WHERE x<1000
|
||||
""")
|
||||
```
|
||||
|
||||
> **Note**: many recursive CTEs use the `UNION ALL` SQL operator. The query interface does not provide any Swift support for it, so you'll generally have to write SQL in your definitions of recursive CTEs.
|
||||
|
||||
|
||||
## Embed Common Table Expressions in Requests
|
||||
|
||||
A typical SQLite query that uses a common table expression first *defines* the CTE and then *uses* the CTE by mentioning its table name. We'll see below Swift apis that match those two steps.
|
||||
|
||||
We will use the (simple) query below as a target. It is the query we'll want to generate in this chapter. It defines a CTE, and uses it in a subquery:
|
||||
|
||||
```sql
|
||||
WITH playerName AS (SELECT 'O''Brien')
|
||||
SELECT * FROM player
|
||||
WHERE name = (SELECT * FROM playerName)
|
||||
```
|
||||
|
||||
We first build a `CommonTableExpression`:
|
||||
|
||||
```swift
|
||||
let name = "O'Brien"
|
||||
let playerNameCTE = CommonTableExpression(
|
||||
named: "playerName",
|
||||
literal: "SELECT \(name)")
|
||||
```
|
||||
|
||||
We can then embed the definition of the CTE in a [query interface request] by calling the `with(_:)` method:
|
||||
|
||||
```swift
|
||||
// WITH playerName AS (SELECT 'O''Brien')
|
||||
// SELECT * FROM player ...
|
||||
let request = Player
|
||||
.with(playerNameCTE)...
|
||||
```
|
||||
|
||||
And we can then filter the `player` table with a subquery:
|
||||
|
||||
```swift
|
||||
// WITH playerName AS (SELECT 'O''Brien')
|
||||
// SELECT * FROM player
|
||||
// WHERE name = (SELECT * FROM playerName)
|
||||
let request = Player
|
||||
.with(playerNameCTE)
|
||||
.filter(Column("name") == playerNameCTE.all())
|
||||
```
|
||||
|
||||
> **Note**: the `with(_:)` method can be called as many times as a there are common table expressions in your request.
|
||||
>
|
||||
> **Note**: the `with(_:)` method can be called at any time, as all request methods: `Player.with(...).filter(...).with(...)`.
|
||||
>
|
||||
> **Note**: the `with(_:)` method replaces any previously embedded CTE that has the same table name. This allows you to embed the same CTE several times if you feel like it.
|
||||
>
|
||||
> **Note**: the `CommonTableExpression.all()` method builds a regular [query interface request] for the content of the CTE (like `SELECT * FROM <cte name>`, not to be mismatched with the request that was used to define the CTE). You can filter this request, sort it, etc, like all query interface requests:
|
||||
>
|
||||
> ```swift
|
||||
> cte.all().select(...).filter(...).group(...).order(...)
|
||||
> ```
|
||||
|
||||
Common table expressions can also be embedded in [SQLRequest] with [SQL Interpolation]:
|
||||
|
||||
```swift
|
||||
// WITH playerName AS (SELECT 'O''Brien')
|
||||
// SELECT * FROM player
|
||||
// WHERE name = (SELECT * FROM playerName)
|
||||
let request: SQLRequest<Player> = """
|
||||
WITH \(definitionFor: playerNameCTE)
|
||||
SELECT * FROM player
|
||||
WHERE name = (SELECT * FROM \(playerNameCTE))
|
||||
"""
|
||||
|
||||
// WITH playerName AS (SELECT 'O''Brien')
|
||||
// SELECT * FROM player
|
||||
// WHERE name = (SELECT * FROM playerName)
|
||||
let request: SQLRequest<Player> = """
|
||||
WITH \(definitionFor: playerNameCTE)
|
||||
SELECT * FROM player
|
||||
WHERE name = (\(playerNameCTE.all()))
|
||||
"""
|
||||
```
|
||||
|
||||
Common table expressions can also be used as subqueries, when you update or delete rows in the database:
|
||||
|
||||
```swift
|
||||
// WITH playerName AS (SELECT 'O''Brien')
|
||||
// UPDATE player SET name = (SELECT * FROM playerName)
|
||||
try Player
|
||||
.with(playerNameCTE)
|
||||
.updateAll(db, Column("name").set(to: playerNameCTE.all()))
|
||||
|
||||
// WITH playerName AS (SELECT 'O''Brien')
|
||||
// DELETE FROM player WHERE name = (SELECT * FROM playerName)
|
||||
try Player
|
||||
.with(playerNameCTE)
|
||||
.filter(Column("name") == playerNameCTE.all())
|
||||
.deleteAll(db)
|
||||
```
|
||||
|
||||
|
||||
## Fetch Values From Common Table Expressions
|
||||
|
||||
In the previous chapter, a common table expression was embedded as a subquery, with the `CommonTableExpression.all()` method.
|
||||
|
||||
`cte.all()` builds a regular [query interface request] that you can filter, sort, etc, like all query interface requests.
|
||||
|
||||
You can also fetch from `cte.all()`, as long as the request is given the definition of the CTE: `cte.all().with(cte)`. In SQL, this would give: `WITH cte AS (...) SELECT * FROM cte`:
|
||||
|
||||
This request, of type `QueryInterfaceRequest<Row>`, can fetch raw database [rows](../README.md#row-queries):
|
||||
|
||||
```swift
|
||||
let cte = CommonTableExpression(...)
|
||||
let request = cte.all().with(cte)
|
||||
let rows = try request.fetchAll(db) // [Row]
|
||||
```
|
||||
|
||||
In order to fetch something else, such as simple [values](../README.md#value-queries), or custom [records](../README.md#records), you have two possible options:
|
||||
|
||||
1. Use the `asRequest(of:)` method:
|
||||
|
||||
```swift
|
||||
let cte = CommonTableExpression(...)
|
||||
let request = cte.all().with(cte).asRequest(of: Player.self)
|
||||
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
let players = try request.fetchAll(db) // [Player]
|
||||
```
|
||||
|
||||
2. Provide the fetched type to the cte itself:
|
||||
|
||||
```swift
|
||||
let cte = CommonTableExpression<Player>(...)
|
||||
// ~~~~~~~~
|
||||
let request = cte.all().with(cte)
|
||||
let players = try request.fetchAll(db) // [Player]
|
||||
```
|
||||
|
||||
## Associations to Common Table Expressions
|
||||
|
||||
GRDB [associations] define "to-one" and "to-many" relationships between two database tables. Here we will define associations between regular tables and common table expressions.
|
||||
|
||||
We recommend familiarity with the "joining methods", described in [Joining And Prefetching Associated Records]:
|
||||
|
||||
```swift
|
||||
// SELECT parent.* FROM parent LEFT JOIN child ON ...
|
||||
Parent.joining(optional: childAssociation)
|
||||
|
||||
// SELECT parent.* FROM parent JOIN child ON ...
|
||||
Parent.joining(required: childAssociation)
|
||||
|
||||
// SELECT parent.*, child.* FROM parent LEFT JOIN child ON ...
|
||||
Parent.including(optional: childAssociation)
|
||||
|
||||
// SELECT parent.*, child.* FROM parent JOIN child ON ...
|
||||
Parent.including(required: childAssociation)
|
||||
```
|
||||
|
||||
> **Note**: common table expressions currently only define "to-one" associations, so the `including(all:)` joining method is unavailable.
|
||||
|
||||
CTE associations are generally built with the `association(to:on:)` method, which needs:
|
||||
|
||||
- The two sides of the association: a `CommonTableExpression` instance, and another CTE or a type that conforms to the [TableRecord] protocol.
|
||||
- A function that returns the condition that joins the two sides of the association.
|
||||
|
||||
The condition function plays the same role as the **foreign key** that defines regular table [associations] such as **BelongsTo** or **HasMany**. It accepts two [TableAlias], from which you can build a joining expression:
|
||||
|
||||
For example:
|
||||
|
||||
```swift
|
||||
// An association from LeftRecord to rightCTE
|
||||
let rightCTE = ...
|
||||
let association = LeftRecord.association(
|
||||
to: rightCTE,
|
||||
on: { left, right in
|
||||
left[Column("x")] == right[Column("y")]
|
||||
})
|
||||
```
|
||||
|
||||
Now this association can be used with a joining method:
|
||||
|
||||
```swift
|
||||
// WITH rightCTE AS (...)
|
||||
// SELECT leftRecord.*, rightCTE.*
|
||||
// FROM leftRecord
|
||||
// JOIN rightCTE ON leftRecord.x = rightCTE.y
|
||||
LeftRecord
|
||||
.with(rightCTE)
|
||||
.including(required: association)
|
||||
```
|
||||
|
||||
|
||||
### CTE Association Example: a Chat App
|
||||
|
||||
As an example, let's build the classical main screen of a chat application: a list of all latest messages from all conversations.
|
||||
|
||||
The database schema of the chat app contains a `chat` and a `message` table. The application defines the following records:
|
||||
|
||||
```swift
|
||||
struct Chat: Codable, FetchableRecord, PersistableRecord {
|
||||
var id: Int64
|
||||
...
|
||||
}
|
||||
|
||||
struct Message: Codable, FetchableRecord, PersistableRecord {
|
||||
var chatID: Int64
|
||||
var date: Date
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
To feed the main app screen, we want to fetch a list of `ChatInfo` records:
|
||||
|
||||
```swift
|
||||
struct ChatInfo: Decodable, FetchableRecord {
|
||||
/// The chat
|
||||
var chat: Chat
|
||||
|
||||
/// The latest chat message, if any
|
||||
var latestMessage: Message?
|
||||
}
|
||||
```
|
||||
|
||||
The SQL request that we want to run is below. It uses an SQLite-specific [special processing](https://sqlite.org/lang_select.html) of `MAX()` that helps the selection of latest messages from all chats:
|
||||
|
||||
```sql
|
||||
WITH latestMessage AS
|
||||
(SELECT *, MAX(date) FROM message GROUP BY chatID)
|
||||
SELECT chat.*, latestMessage.*
|
||||
FROM chat
|
||||
LEFT JOIN latestMessage ON chat.id = latestMessage.chatID
|
||||
ORDER BY latestMessage.date DESC
|
||||
```
|
||||
|
||||
We start by defining the CTE request, which loads the latest messages of all chats:
|
||||
|
||||
```swift
|
||||
// SELECT *, MAX(date) FROM message GROUP BY chatID
|
||||
let latestMessageRequest = Message
|
||||
.annotated(with: max(Column("date")))
|
||||
.group(Column("chatID"))
|
||||
```
|
||||
|
||||
We can now define the CTE for the latest messages:
|
||||
|
||||
```swift
|
||||
// WITH latestMessage AS
|
||||
// (SELECT *, MAX(date) FROM message GROUP BY chatID)
|
||||
let latestMessageCTE = CommonTableExpression(
|
||||
named: "latestMessage",
|
||||
request: latestMessageRequest)
|
||||
```
|
||||
|
||||
The association from a chat to its latest message follows:
|
||||
|
||||
```swift
|
||||
|
||||
// ... JOIN latestMessage ON chat.id = latestMessage.chatID
|
||||
let latestMessage = Chat.association(
|
||||
to: latestMessageCTE,
|
||||
on: { chat, latestMessage in
|
||||
chat[Column("id")] == latestMessage[Column("chatID")]
|
||||
})
|
||||
.order(Column("date").desc)
|
||||
```
|
||||
|
||||
The final request can now be defined:
|
||||
|
||||
```swift
|
||||
|
||||
// WITH latestMessage AS
|
||||
// (SELECT *, MAX(date) FROM message GROUP BY chatID)
|
||||
// SELECT chat.*, latestMessage.*
|
||||
// FROM chat
|
||||
// LEFT JOIN latestMessage ON chat.id = latestMessage.chatID
|
||||
// ORDER BY latestMessage.date DESC
|
||||
let request = Chat
|
||||
.with(latestMessageCTE)
|
||||
.including(optional: latestMessage)
|
||||
.asRequest(of: ChatInfo.self)
|
||||
```
|
||||
|
||||
And we can fetch the data that feeds our application screen:
|
||||
|
||||
```swift
|
||||
|
||||
let chatInfos: [ChatInfos] = try dbQueue.read { db in
|
||||
try request.fetchAll(db)
|
||||
}
|
||||
```
|
||||
|
||||
> :bulb: **Tip**: the joining methods are generally type-safe: they won't allow you to join apples to oranges. This works when associations have a *precise* type. In this context, anonymous `CommonTableExpression` CTEs can work against type safety. When you want to define associations between several CTEs, and make sure the compiler will notice wrong uses of those associations, tag your common table expressions with an explicit type: `CommonTableExpression<SomeType>`.
|
||||
>
|
||||
> To do so, you can use an existing record type, or an ad-hoc enum. For example:
|
||||
>
|
||||
> ```swift
|
||||
> enum CTE1 { }
|
||||
> let cte1 = CommonTableExpression<CTE1>(...)
|
||||
>
|
||||
> enum CTE2 { }
|
||||
> let cte2 = CommonTableExpression<CTE2>(...)
|
||||
>
|
||||
> let assoc1 = BaseRecord.association(to: cte1, on: ...) // from BaseRecord to CTE1
|
||||
> let assoc2 = cte1.association(to: cte2, on: ...) // from CTE1 to CTE2
|
||||
> let assoc3 = cte2.association(to: FarRecord.self, on: ...) // from CTE2 to FarRecord
|
||||
>
|
||||
> // WITH ...
|
||||
> // SELECT base.* FROM base
|
||||
> // JOIN cte1 ON ...
|
||||
> // JOIN cte2 ON ...
|
||||
> // JOIN far ON ...
|
||||
> let request = BaseRecord
|
||||
> .with(cte1).with(cte2)
|
||||
> .joining(required: assoc1. // OK
|
||||
> .joining(required: assoc2. // OK
|
||||
> .joining(required: assoc3))) // OK
|
||||
>
|
||||
> // Compiler error
|
||||
> let request = BaseRecord
|
||||
> .joining(required: assoc2) // Not OK
|
||||
> .joining(required: assoc3) // Not OK
|
||||
> ```
|
||||
|
||||
[query interface request]: ../README.md#requests
|
||||
[query interface requests]: ../README.md#requests
|
||||
[SQLRequest]: ../README.md#custom-requests
|
||||
[SQLiteral]: SQLInterpolation.md
|
||||
[SQL Interpolation]: SQLInterpolation.md
|
||||
[associations]: AssociationsBasics.md
|
||||
[Joining And Prefetching Associated Records]: AssociationsBasics.md#joining-and-prefetching-associated-records
|
||||
[Define Common Table Expressions]: #define-common-table-expressions
|
||||
[Embed Common Table Expressions in Requests]: #embed-common-table-expressions-in-requests
|
||||
[Fetch Values From Common Table Expressions]: #fetch-values-from-common-table-expressions
|
||||
[Associations to Common Table Expressions]: #associations-to-common-table-expressions
|
||||
[TableRecord]: ../README.md#tablerecord-protocol
|
||||
[TableAlias]: AssociationsBasics.md#table-aliases
|
||||
@@ -1,4 +0,0 @@
|
||||
:twisted_rightwards_arrows: Concurrency
|
||||
=======================================
|
||||
|
||||
This guide [has moved](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/concurrency).
|
||||
@@ -1,136 +0,0 @@
|
||||
Custom SQLite Builds
|
||||
====================
|
||||
|
||||
By default, GRDB uses the version of SQLite that ships with the target operating system.
|
||||
|
||||
**You can build GRDB with a custom build of [SQLite 3.44.0](https://www.sqlite.org/changes.html).**
|
||||
|
||||
A custom SQLite build can activate extra SQLite features, and extra GRDB features as well, such as support for the [FTS5 full-text search engine](../../../#full-text-search), and [SQLite Pre-Update Hooks](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/transactionobserver).
|
||||
|
||||
GRDB builds SQLite with [swiftlyfalling/SQLiteLib](https://github.com/swiftlyfalling/SQLiteLib), which uses the same SQLite configuration as the one used by Apple in its operating systems, and lets you add extra compilation options that leverage the features you need.
|
||||
|
||||
**To install GRDB with a custom SQLite build:**
|
||||
|
||||
1. Clone the GRDB git repository, checkout the latest tagged version:
|
||||
|
||||
```sh
|
||||
cd [GRDB directory]
|
||||
git checkout [latest tag]
|
||||
git submodule update --init SQLiteCustom/src
|
||||
```
|
||||
|
||||
2. Choose your [extra compilation options](https://www.sqlite.org/compile.html). For example, `SQLITE_ENABLE_FTS5`, `SQLITE_ENABLE_PREUPDATE_HOOK`.
|
||||
|
||||
It is recommended that you enable the `SQLITE_ENABLE_SNAPSHOT` option. It allows GRDB to optimize [ValueObservation](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/valueobservation) when you use a [Database Pool](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databasepool).
|
||||
|
||||
3. Create a folder named `GRDBCustomSQLite` somewhere in your project directory.
|
||||
|
||||
4. Create four files in the `GRDBCustomSQLite` folder:
|
||||
|
||||
- `SQLiteLib-USER.xcconfig`: this file sets the extra SQLite compilation flags.
|
||||
|
||||
```xcconfig
|
||||
// As many -D options as there are custom SQLite compilation options
|
||||
// Note: there is no space between -D and the option name.
|
||||
CUSTOM_SQLLIBRARY_CFLAGS = -DSQLITE_ENABLE_SNAPSHOT -DSQLITE_ENABLE_FTS5
|
||||
```
|
||||
|
||||
- `GRDBCustomSQLite-USER.xcconfig`: this file lets GRDB know about extra compilation flags, and enables extra GRDB APIs.
|
||||
|
||||
```xcconfig
|
||||
// As many -D options as there are custom SQLite compilation options
|
||||
// Note: there is one space between -D and the option name.
|
||||
CUSTOM_OTHER_SWIFT_FLAGS = -D SQLITE_ENABLE_SNAPSHOT -D SQLITE_ENABLE_FTS5
|
||||
```
|
||||
|
||||
- `GRDBCustomSQLite-USER.h`: this file lets your application know about extra compilation flags.
|
||||
|
||||
```c
|
||||
// As many #define as there are custom SQLite compilation options
|
||||
#define SQLITE_ENABLE_SNAPSHOT
|
||||
#define SQLITE_ENABLE_FTS5
|
||||
```
|
||||
|
||||
- `GRDBCustomSQLite-INSTALL.sh`: this file installs the three other files.
|
||||
|
||||
```sh
|
||||
# License: MIT License
|
||||
# https://github.com/swiftlyfalling/SQLiteLib/blob/master/LICENSE
|
||||
#
|
||||
#######################################################
|
||||
# PROJECT PATHS
|
||||
# !! MODIFY THESE TO MATCH YOUR PROJECT HIERARCHY !!
|
||||
#######################################################
|
||||
|
||||
# The path to the folder containing GRDBCustom.xcodeproj:
|
||||
GRDB_SOURCE_PATH="${PROJECT_DIR}/GRDB"
|
||||
|
||||
# The path to your custom "SQLiteLib-USER.xcconfig":
|
||||
SQLITELIB_XCCONFIG_USER_PATH="${PROJECT_DIR}/GRDBCustomSQLite/SQLiteLib-USER.xcconfig"
|
||||
|
||||
# The path to your custom "GRDBCustomSQLite-USER.xcconfig":
|
||||
CUSTOMSQLITE_XCCONFIG_USER_PATH="${PROJECT_DIR}/GRDBCustomSQLite/GRDBCustomSQLite-USER.xcconfig"
|
||||
|
||||
# The path to your custom "GRDBCustomSQLite-USER.h":
|
||||
CUSTOMSQLITE_H_USER_PATH="${PROJECT_DIR}/GRDBCustomSQLite/GRDBCustomSQLite-USER.h"
|
||||
|
||||
#######################################################
|
||||
#
|
||||
#######################################################
|
||||
|
||||
|
||||
if [ ! -d "$GRDB_SOURCE_PATH" ];
|
||||
then
|
||||
echo "error: Path to GRDB source (GRDB_SOURCE_PATH) missing/incorrect: $GRDB_SOURCE_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SyncFileChanges () {
|
||||
SOURCE=$1
|
||||
DESTINATIONPATH=$2
|
||||
DESTINATIONFILENAME=$3
|
||||
DESTINATION="${DESTINATIONPATH}/${DESTINATIONFILENAME}"
|
||||
|
||||
if [ ! -f "$SOURCE" ];
|
||||
then
|
||||
echo "error: Source file missing: $SOURCE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rsync -a "$SOURCE" "$DESTINATION"
|
||||
}
|
||||
|
||||
SyncFileChanges $SQLITELIB_XCCONFIG_USER_PATH "${GRDB_SOURCE_PATH}/SQLiteCustom/src" "SQLiteLib-USER.xcconfig"
|
||||
SyncFileChanges $CUSTOMSQLITE_XCCONFIG_USER_PATH "${GRDB_SOURCE_PATH}/SQLiteCustom" "GRDBCustomSQLite-USER.xcconfig"
|
||||
SyncFileChanges $CUSTOMSQLITE_H_USER_PATH "${GRDB_SOURCE_PATH}/SQLiteCustom" "GRDBCustomSQLite-USER.h"
|
||||
|
||||
echo "Finished syncing"
|
||||
```
|
||||
|
||||
Modify the top of `GRDBCustomSQLite-INSTALL.sh` file so that it contains correct paths.
|
||||
|
||||
5. Embed the `GRDBCustom.xcodeproj` project in your own project.
|
||||
|
||||
6. Add the `GRDBCustom` target in the **Target Dependencies** section of the **Build Phases** tab of your **application target**.
|
||||
|
||||
7. Add the `GRDBCustom.framework` from the targeted platform to the **Embedded Binaries** section of the **General** tab of your **application target**.
|
||||
|
||||
8. Add a Run Script phase for your target in the **Pre-actions** section of the **Build** tab of your **application scheme**:
|
||||
|
||||
```sh
|
||||
source "${PROJECT_DIR}/GRDBCustomSQLite/GRDBCustomSQLite-INSTALL.sh"
|
||||
```
|
||||
|
||||
The path should be the path to your `GRDBCustomSQLite-INSTALL.sh` file.
|
||||
|
||||
Select your application target in the "Provide build settings from" menu.
|
||||
|
||||
9. Check the "Shared" checkbox of your application scheme (this lets you commit the pre-action in your Version Control System).
|
||||
|
||||
Now you can use GRDB with your custom SQLite build:
|
||||
|
||||
```swift
|
||||
import GRDB
|
||||
|
||||
let dbQueue = try DatabaseQueue(...)
|
||||
```
|
||||
@@ -1,611 +0,0 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
56026CAC25B8A7EF00D1DF3F /* PlayerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56026CAA25B8A7EF00D1DF3F /* PlayerTests.swift */; };
|
||||
56026CAD25B8A7EF00D1DF3F /* AppDatabaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56026CAB25B8A7EF00D1DF3F /* AppDatabaseTests.swift */; };
|
||||
56519DCA274FC8E900ED16D8 /* GRDBQuery in Frameworks */ = {isa = PBXBuildFile; productRef = 56519DC9274FC8E900ED16D8 /* GRDBQuery */; };
|
||||
5671723A261B23C800423B6F /* PlayerList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56717239261B23C800423B6F /* PlayerList.swift */; };
|
||||
56717252261B334D00423B6F /* PlayerRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56717251261B334D00423B6F /* PlayerRequestTests.swift */; };
|
||||
567C3E1A2520B6DE0011F6E9 /* GRDBAsyncDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E192520B6DE0011F6E9 /* GRDBAsyncDemoApp.swift */; };
|
||||
567C3E1E2520B6DF0011F6E9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 567C3E1D2520B6DF0011F6E9 /* Assets.xcassets */; };
|
||||
567C3E212520B6DF0011F6E9 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 567C3E202520B6DF0011F6E9 /* Preview Assets.xcassets */; };
|
||||
567C3E5D2520B75C0011F6E9 /* Player.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E532520B75C0011F6E9 /* Player.swift */; };
|
||||
567C3E5E2520B75C0011F6E9 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E542520B75C0011F6E9 /* Persistence.swift */; };
|
||||
567C3E612520B75D0011F6E9 /* PlayerFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E592520B75C0011F6E9 /* PlayerFormView.swift */; };
|
||||
567C3E622520B75D0011F6E9 /* AppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E5A2520B75C0011F6E9 /* AppView.swift */; };
|
||||
567C3E632520B75D0011F6E9 /* PlayerCreationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E5B2520B75C0011F6E9 /* PlayerCreationView.swift */; };
|
||||
567C3E642520B75D0011F6E9 /* PlayerEditionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E5C2520B75C0011F6E9 /* PlayerEditionView.swift */; };
|
||||
567C3E662520B7880011F6E9 /* AppDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E652520B7880011F6E9 /* AppDatabase.swift */; };
|
||||
567C3E792520BB650011F6E9 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 567C3E752520BB650011F6E9 /* Localizable.stringsdict */; };
|
||||
567C3E7A2520BB650011F6E9 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 567C3E772520BB650011F6E9 /* LaunchScreen.storyboard */; };
|
||||
56B6D1092619EC1B003CC455 /* PlayerRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B6D1082619EC1B003CC455 /* PlayerRequest.swift */; };
|
||||
56F8A13527359A5A0011ACBE /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 56F8A13427359A5A0011ACBE /* GRDB */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
56026C9D25B8A7D000D1DF3F /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 567C3E0E2520B6DE0011F6E9 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 567C3E152520B6DE0011F6E9;
|
||||
remoteInfo = GRDBAsyncDemo;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
567C3E502520B70E0011F6E9 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
56026C9825B8A7D000D1DF3F /* GRDBAsyncDemoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GRDBAsyncDemoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
56026C9C25B8A7D000D1DF3F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
56026CAA25B8A7EF00D1DF3F /* PlayerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerTests.swift; sourceTree = "<group>"; };
|
||||
56026CAB25B8A7EF00D1DF3F /* AppDatabaseTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDatabaseTests.swift; sourceTree = "<group>"; };
|
||||
56717239261B23C800423B6F /* PlayerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerList.swift; sourceTree = "<group>"; };
|
||||
56717251261B334D00423B6F /* PlayerRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRequestTests.swift; sourceTree = "<group>"; };
|
||||
567C3E162520B6DE0011F6E9 /* GRDBAsyncDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GRDBAsyncDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
567C3E192520B6DE0011F6E9 /* GRDBAsyncDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRDBAsyncDemoApp.swift; sourceTree = "<group>"; };
|
||||
567C3E1D2520B6DF0011F6E9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
567C3E202520B6DF0011F6E9 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||
567C3E222520B6DF0011F6E9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
567C3E532520B75C0011F6E9 /* Player.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Player.swift; sourceTree = "<group>"; };
|
||||
567C3E542520B75C0011F6E9 /* Persistence.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = "<group>"; };
|
||||
567C3E592520B75C0011F6E9 /* PlayerFormView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerFormView.swift; sourceTree = "<group>"; };
|
||||
567C3E5A2520B75C0011F6E9 /* AppView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppView.swift; sourceTree = "<group>"; };
|
||||
567C3E5B2520B75C0011F6E9 /* PlayerCreationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerCreationView.swift; sourceTree = "<group>"; };
|
||||
567C3E5C2520B75C0011F6E9 /* PlayerEditionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerEditionView.swift; sourceTree = "<group>"; };
|
||||
567C3E652520B7880011F6E9 /* AppDatabase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDatabase.swift; sourceTree = "<group>"; };
|
||||
567C3E762520BB650011F6E9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||
567C3E782520BB650011F6E9 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
56B6D1082619EC1B003CC455 /* PlayerRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRequest.swift; sourceTree = "<group>"; };
|
||||
56F8A12E27359A350011ACBE /* GRDB.swift */ = {isa = PBXFileReference; lastKnownFileType = folder; name = GRDB.swift; path = ../../..; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
56026C9525B8A7D000D1DF3F /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
567C3E132520B6DE0011F6E9 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
56519DCA274FC8E900ED16D8 /* GRDBQuery in Frameworks */,
|
||||
56F8A13527359A5A0011ACBE /* GRDB in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
56026C9925B8A7D000D1DF3F /* GRDBAsyncDemoTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
56026C9C25B8A7D000D1DF3F /* Info.plist */,
|
||||
56026CAB25B8A7EF00D1DF3F /* AppDatabaseTests.swift */,
|
||||
56717251261B334D00423B6F /* PlayerRequestTests.swift */,
|
||||
56026CAA25B8A7EF00D1DF3F /* PlayerTests.swift */,
|
||||
);
|
||||
path = GRDBAsyncDemoTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
56185BC125B8047D00B9C30F /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
567C3E1D2520B6DF0011F6E9 /* Assets.xcassets */,
|
||||
567C3E772520BB650011F6E9 /* LaunchScreen.storyboard */,
|
||||
567C3E752520BB650011F6E9 /* Localizable.stringsdict */,
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E0D2520B6DE0011F6E9 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
567C3E182520B6DE0011F6E9 /* GRDBAsyncDemo */,
|
||||
56026C9925B8A7D000D1DF3F /* GRDBAsyncDemoTests */,
|
||||
567C3E172520B6DE0011F6E9 /* Products */,
|
||||
567C3E4D2520B70E0011F6E9 /* Frameworks */,
|
||||
56F8A12E27359A350011ACBE /* GRDB.swift */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E172520B6DE0011F6E9 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
567C3E162520B6DE0011F6E9 /* GRDBAsyncDemo.app */,
|
||||
56026C9825B8A7D000D1DF3F /* GRDBAsyncDemoTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E182520B6DE0011F6E9 /* GRDBAsyncDemo */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
567C3E222520B6DF0011F6E9 /* Info.plist */,
|
||||
567C3E652520B7880011F6E9 /* AppDatabase.swift */,
|
||||
567C3E192520B6DE0011F6E9 /* GRDBAsyncDemoApp.swift */,
|
||||
567C3E542520B75C0011F6E9 /* Persistence.swift */,
|
||||
567C3E532520B75C0011F6E9 /* Player.swift */,
|
||||
56B6D1082619EC1B003CC455 /* PlayerRequest.swift */,
|
||||
567C3E1F2520B6DF0011F6E9 /* Preview Content */,
|
||||
56185BC125B8047D00B9C30F /* Resources */,
|
||||
567C3E582520B75C0011F6E9 /* Views */,
|
||||
);
|
||||
path = GRDBAsyncDemo;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E1F2520B6DF0011F6E9 /* Preview Content */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
567C3E202520B6DF0011F6E9 /* Preview Assets.xcassets */,
|
||||
);
|
||||
path = "Preview Content";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E4D2520B70E0011F6E9 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E582520B75C0011F6E9 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
567C3E5A2520B75C0011F6E9 /* AppView.swift */,
|
||||
567C3E5B2520B75C0011F6E9 /* PlayerCreationView.swift */,
|
||||
567C3E5C2520B75C0011F6E9 /* PlayerEditionView.swift */,
|
||||
567C3E592520B75C0011F6E9 /* PlayerFormView.swift */,
|
||||
56717239261B23C800423B6F /* PlayerList.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
56026C9725B8A7D000D1DF3F /* GRDBAsyncDemoTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 56026CA825B8A7D000D1DF3F /* Build configuration list for PBXNativeTarget "GRDBAsyncDemoTests" */;
|
||||
buildPhases = (
|
||||
56026C9425B8A7D000D1DF3F /* Sources */,
|
||||
56026C9525B8A7D000D1DF3F /* Frameworks */,
|
||||
56026C9625B8A7D000D1DF3F /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
56026C9E25B8A7D000D1DF3F /* PBXTargetDependency */,
|
||||
);
|
||||
name = GRDBAsyncDemoTests;
|
||||
productName = GRDBAsyncDemoTests;
|
||||
productReference = 56026C9825B8A7D000D1DF3F /* GRDBAsyncDemoTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
567C3E152520B6DE0011F6E9 /* GRDBAsyncDemo */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 567C3E252520B6DF0011F6E9 /* Build configuration list for PBXNativeTarget "GRDBAsyncDemo" */;
|
||||
buildPhases = (
|
||||
567C3E122520B6DE0011F6E9 /* Sources */,
|
||||
567C3E132520B6DE0011F6E9 /* Frameworks */,
|
||||
567C3E142520B6DE0011F6E9 /* Resources */,
|
||||
567C3E502520B70E0011F6E9 /* Embed Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
56F8A13127359A540011ACBE /* PBXTargetDependency */,
|
||||
);
|
||||
name = GRDBAsyncDemo;
|
||||
packageProductDependencies = (
|
||||
56F8A13427359A5A0011ACBE /* GRDB */,
|
||||
56519DC9274FC8E900ED16D8 /* GRDBQuery */,
|
||||
);
|
||||
productName = GRBCombineDemo;
|
||||
productReference = 567C3E162520B6DE0011F6E9 /* GRDBAsyncDemo.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
567C3E0E2520B6DE0011F6E9 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastSwiftUpdateCheck = 1250;
|
||||
LastUpgradeCheck = 1200;
|
||||
TargetAttributes = {
|
||||
56026C9725B8A7D000D1DF3F = {
|
||||
CreatedOnToolsVersion = 12.3;
|
||||
TestTargetID = 567C3E152520B6DE0011F6E9;
|
||||
};
|
||||
567C3E152520B6DE0011F6E9 = {
|
||||
CreatedOnToolsVersion = 12.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 567C3E112520B6DE0011F6E9 /* Build configuration list for PBXProject "GRDBAsyncDemo" */;
|
||||
compatibilityVersion = "Xcode 12.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 567C3E0D2520B6DE0011F6E9;
|
||||
packageReferences = (
|
||||
56519DC8274FC8E900ED16D8 /* XCRemoteSwiftPackageReference "GRDBQuery" */,
|
||||
);
|
||||
productRefGroup = 567C3E172520B6DE0011F6E9 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
567C3E152520B6DE0011F6E9 /* GRDBAsyncDemo */,
|
||||
56026C9725B8A7D000D1DF3F /* GRDBAsyncDemoTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
56026C9625B8A7D000D1DF3F /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
567C3E142520B6DE0011F6E9 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
567C3E212520B6DF0011F6E9 /* Preview Assets.xcassets in Resources */,
|
||||
567C3E7A2520BB650011F6E9 /* LaunchScreen.storyboard in Resources */,
|
||||
567C3E1E2520B6DF0011F6E9 /* Assets.xcassets in Resources */,
|
||||
567C3E792520BB650011F6E9 /* Localizable.stringsdict in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
56026C9425B8A7D000D1DF3F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
56026CAC25B8A7EF00D1DF3F /* PlayerTests.swift in Sources */,
|
||||
56026CAD25B8A7EF00D1DF3F /* AppDatabaseTests.swift in Sources */,
|
||||
56717252261B334D00423B6F /* PlayerRequestTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
567C3E122520B6DE0011F6E9 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
567C3E5E2520B75C0011F6E9 /* Persistence.swift in Sources */,
|
||||
567C3E5D2520B75C0011F6E9 /* Player.swift in Sources */,
|
||||
56B6D1092619EC1B003CC455 /* PlayerRequest.swift in Sources */,
|
||||
5671723A261B23C800423B6F /* PlayerList.swift in Sources */,
|
||||
567C3E612520B75D0011F6E9 /* PlayerFormView.swift in Sources */,
|
||||
567C3E632520B75D0011F6E9 /* PlayerCreationView.swift in Sources */,
|
||||
567C3E662520B7880011F6E9 /* AppDatabase.swift in Sources */,
|
||||
567C3E622520B75D0011F6E9 /* AppView.swift in Sources */,
|
||||
567C3E642520B75D0011F6E9 /* PlayerEditionView.swift in Sources */,
|
||||
567C3E1A2520B6DE0011F6E9 /* GRDBAsyncDemoApp.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
56026C9E25B8A7D000D1DF3F /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 567C3E152520B6DE0011F6E9 /* GRDBAsyncDemo */;
|
||||
targetProxy = 56026C9D25B8A7D000D1DF3F /* PBXContainerItemProxy */;
|
||||
};
|
||||
56F8A13127359A540011ACBE /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
productRef = 56F8A13027359A540011ACBE /* GRDB */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
567C3E752520BB650011F6E9 /* Localizable.stringsdict */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
567C3E762520BB650011F6E9 /* en */,
|
||||
);
|
||||
name = Localizable.stringsdict;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E772520BB650011F6E9 /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
567C3E782520BB650011F6E9 /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
56026C9F25B8A7D000D1DF3F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
INFOPLIST_FILE = GRDBAsyncDemoTests/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.github.groue.GRDBAsyncDemoTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/GRDBAsyncDemo.app/GRDBAsyncDemo";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
56026CA025B8A7D000D1DF3F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
INFOPLIST_FILE = GRDBAsyncDemoTests/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.github.groue.GRDBAsyncDemoTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/GRDBAsyncDemo.app/GRDBAsyncDemo";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
567C3E232520B6DF0011F6E9 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
567C3E242520B6DF0011F6E9 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
567C3E262520B6DF0011F6E9 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"GRDBAsyncDemo/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = GRDBAsyncDemo/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.github.groue.GRDBAsyncDemo;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
567C3E272520B6DF0011F6E9 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"GRDBAsyncDemo/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = GRDBAsyncDemo/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.github.groue.GRDBAsyncDemo;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
56026CA825B8A7D000D1DF3F /* Build configuration list for PBXNativeTarget "GRDBAsyncDemoTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
56026C9F25B8A7D000D1DF3F /* Debug */,
|
||||
56026CA025B8A7D000D1DF3F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
567C3E112520B6DE0011F6E9 /* Build configuration list for PBXProject "GRDBAsyncDemo" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
567C3E232520B6DF0011F6E9 /* Debug */,
|
||||
567C3E242520B6DF0011F6E9 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
567C3E252520B6DF0011F6E9 /* Build configuration list for PBXNativeTarget "GRDBAsyncDemo" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
567C3E262520B6DF0011F6E9 /* Debug */,
|
||||
567C3E272520B6DF0011F6E9 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
56519DC8274FC8E900ED16D8 /* XCRemoteSwiftPackageReference "GRDBQuery" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/groue/GRDBQuery";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.6.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
56519DC9274FC8E900ED16D8 /* GRDBQuery */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 56519DC8274FC8E900ED16D8 /* XCRemoteSwiftPackageReference "GRDBQuery" */;
|
||||
productName = GRDBQuery;
|
||||
};
|
||||
56F8A13027359A540011ACBE /* GRDB */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = GRDB;
|
||||
};
|
||||
56F8A13427359A5A0011ACBE /* GRDB */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = GRDB;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 567C3E0E2520B6DE0011F6E9 /* Project object */;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "grdbquery",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/groue/GRDBQuery",
|
||||
"state" : {
|
||||
"revision" : "a6c46dd38ecf11a5c37732870dc03a384d582fba",
|
||||
"version" : "0.9.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1400"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "567C3E152520B6DE0011F6E9"
|
||||
BuildableName = "GRDBAsyncDemo.app"
|
||||
BlueprintName = "GRDBAsyncDemo"
|
||||
ReferencedContainer = "container:GRDBAsyncDemo.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "56026C9725B8A7D000D1DF3F"
|
||||
BuildableName = "GRDBAsyncDemoTests.xctest"
|
||||
BlueprintName = "GRDBAsyncDemoTests"
|
||||
ReferencedContainer = "container:GRDBAsyncDemo.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "567C3E152520B6DE0011F6E9"
|
||||
BuildableName = "GRDBAsyncDemo.app"
|
||||
BlueprintName = "GRDBAsyncDemo"
|
||||
ReferencedContainer = "container:GRDBAsyncDemo.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "SQL_TRACE"
|
||||
value = "1"
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "567C3E152520B6DE0011F6E9"
|
||||
BuildableName = "GRDBAsyncDemo.app"
|
||||
BlueprintName = "GRDBAsyncDemo"
|
||||
ReferencedContainer = "container:GRDBAsyncDemo.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -1,235 +0,0 @@
|
||||
import Foundation
|
||||
import GRDB
|
||||
import os.log
|
||||
|
||||
/// A database of players.
|
||||
///
|
||||
/// You create an `AppDatabase` with a connection to an SQLite database
|
||||
/// (see <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections>).
|
||||
///
|
||||
/// Create those connections with a configuration returned from
|
||||
/// `AppDatabase/makeConfiguration(_:)`.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// // Create an in-memory AppDatabase
|
||||
/// let config = AppDatabase.makeConfiguration()
|
||||
/// let dbQueue = try DatabaseQueue(configuration: config)
|
||||
/// let appDatabase = try AppDatabase(dbQueue)
|
||||
/// ```
|
||||
struct AppDatabase {
|
||||
/// Creates an `AppDatabase`, and makes sure the database schema
|
||||
/// is ready.
|
||||
///
|
||||
/// - important: Create the `DatabaseWriter` with a configuration
|
||||
/// returned by ``makeConfiguration(_:)``.
|
||||
init(_ dbWriter: any DatabaseWriter) throws {
|
||||
self.dbWriter = dbWriter
|
||||
try migrator.migrate(dbWriter)
|
||||
}
|
||||
|
||||
/// Provides access to the database.
|
||||
///
|
||||
/// Application can use a `DatabasePool`, while SwiftUI previews and tests
|
||||
/// can use a fast in-memory `DatabaseQueue`.
|
||||
///
|
||||
/// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections>
|
||||
private let dbWriter: any DatabaseWriter
|
||||
}
|
||||
|
||||
// MARK: - Database Configuration
|
||||
|
||||
extension AppDatabase {
|
||||
private static let sqlLogger = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "SQL")
|
||||
|
||||
/// Returns a database configuration suited for `PlayerRepository`.
|
||||
///
|
||||
/// SQL statements are logged if the `SQL_TRACE` environment variable
|
||||
/// is set.
|
||||
///
|
||||
/// - parameter base: A base configuration.
|
||||
public static func makeConfiguration(_ base: Configuration = Configuration()) -> Configuration {
|
||||
var config = base
|
||||
|
||||
// An opportunity to add required custom SQL functions or
|
||||
// collations, if needed:
|
||||
// config.prepareDatabase { db in
|
||||
// db.add(function: ...)
|
||||
// }
|
||||
|
||||
// Log SQL statements if the `SQL_TRACE` environment variable is set.
|
||||
// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/database/trace(options:_:)>
|
||||
if ProcessInfo.processInfo.environment["SQL_TRACE"] != nil {
|
||||
config.prepareDatabase { db in
|
||||
db.trace {
|
||||
// It's ok to log statements publicly. Sensitive
|
||||
// information (statement arguments) are not logged
|
||||
// unless config.publicStatementArguments is set
|
||||
// (see below).
|
||||
os_log("%{public}@", log: sqlLogger, type: .debug, String(describing: $0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// Protect sensitive information by enabling verbose debugging in
|
||||
// DEBUG builds only.
|
||||
// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/configuration/publicstatementarguments>
|
||||
config.publicStatementArguments = true
|
||||
#endif
|
||||
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Database Migrations
|
||||
|
||||
extension AppDatabase {
|
||||
/// The DatabaseMigrator that defines the database schema.
|
||||
///
|
||||
/// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/migrations>
|
||||
private var migrator: DatabaseMigrator {
|
||||
var migrator = DatabaseMigrator()
|
||||
|
||||
#if DEBUG
|
||||
// Speed up development by nuking the database when migrations change
|
||||
// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/migrations>
|
||||
migrator.eraseDatabaseOnSchemaChange = true
|
||||
#endif
|
||||
|
||||
migrator.registerMigration("createPlayer") { db in
|
||||
// Create a table
|
||||
// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseschema>
|
||||
try db.create(table: "player") { t in
|
||||
t.autoIncrementedPrimaryKey("id")
|
||||
t.column("name", .text).notNull()
|
||||
t.column("score", .integer).notNull()
|
||||
}
|
||||
}
|
||||
|
||||
// Migrations for future application versions will be inserted here:
|
||||
// migrator.registerMigration(...) { db in
|
||||
// ...
|
||||
// }
|
||||
|
||||
return migrator
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Database Access: Writes
|
||||
// The write methods execute invariant-preserving database transactions.
|
||||
|
||||
extension AppDatabase {
|
||||
/// A validation error that prevents some players from being saved into
|
||||
/// the database.
|
||||
enum ValidationError: LocalizedError {
|
||||
case missingName
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingName:
|
||||
return "Please provide a name"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves (inserts or updates) a player. When the method returns, the
|
||||
/// player is present in the database, and its id is not nil.
|
||||
func savePlayer(_ player: inout Player) async throws {
|
||||
if player.name.isEmpty {
|
||||
throw ValidationError.missingName
|
||||
}
|
||||
player = try await dbWriter.write { [player] db in
|
||||
try player.saved(db)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the specified players
|
||||
func deletePlayers(ids: [Int64]) async throws {
|
||||
try await dbWriter.write { db in
|
||||
_ = try Player.deleteAll(db, ids: ids)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete all players
|
||||
func deleteAllPlayers() async throws {
|
||||
try await dbWriter.write { db in
|
||||
_ = try Player.deleteAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh all players (by performing some random changes, for demo purpose).
|
||||
func refreshPlayers() async throws {
|
||||
try await dbWriter.write { db in
|
||||
if try Player.all().isEmpty(db) {
|
||||
// When database is empty, insert new random players
|
||||
try createRandomPlayers(db)
|
||||
} else {
|
||||
// Insert a player
|
||||
if Bool.random() {
|
||||
_ = try Player.makeRandom().inserted(db) // insert but ignore inserted id
|
||||
}
|
||||
|
||||
// Delete a random player
|
||||
if Bool.random() {
|
||||
try Player.order(sql: "RANDOM()").limit(1).deleteAll(db)
|
||||
}
|
||||
|
||||
// Update some players
|
||||
for var player in try Player.fetchAll(db) where Bool.random() {
|
||||
try player.updateChanges(db) {
|
||||
$0.score = Player.randomScore()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create random players if the database is empty.
|
||||
func createRandomPlayersIfEmpty() throws {
|
||||
try dbWriter.write { db in
|
||||
if try Player.all().isEmpty(db) {
|
||||
try createRandomPlayers(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let uiTestPlayers = [
|
||||
Player(id: nil, name: "Arthur", score: 5),
|
||||
Player(id: nil, name: "Barbara", score: 6),
|
||||
Player(id: nil, name: "Craig", score: 8),
|
||||
Player(id: nil, name: "David", score: 4),
|
||||
Player(id: nil, name: "Elena", score: 1),
|
||||
Player(id: nil, name: "Frederik", score: 2),
|
||||
Player(id: nil, name: "Gilbert", score: 7),
|
||||
Player(id: nil, name: "Henriette", score: 3)]
|
||||
|
||||
func createPlayersForUITests() throws {
|
||||
try dbWriter.write { db in
|
||||
try AppDatabase.uiTestPlayers.forEach { player in
|
||||
_ = try player.inserted(db) // insert but ignore inserted id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Support for `createRandomPlayersIfEmpty()` and `refreshPlayers()`.
|
||||
private func createRandomPlayers(_ db: Database) throws {
|
||||
for _ in 0..<8 {
|
||||
_ = try Player.makeRandom().inserted(db) // insert but ignore inserted id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Database Access: Reads
|
||||
|
||||
// This demo app does not provide any specific reading method, and instead
|
||||
// gives an unrestricted read-only access to the rest of the application.
|
||||
// In your app, you are free to choose another path, and define focused
|
||||
// reading methods.
|
||||
extension AppDatabase {
|
||||
/// Provides a read-only access to the database
|
||||
var reader: DatabaseReader {
|
||||
dbWriter
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import GRDBQuery
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct GRDBAsyncDemoApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
AppView().appDatabase(.shared)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Give SwiftUI access to the database
|
||||
|
||||
private struct AppDatabaseKey: EnvironmentKey {
|
||||
static var defaultValue: AppDatabase { .empty() }
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var appDatabase: AppDatabase {
|
||||
get { self[AppDatabaseKey.self] }
|
||||
set { self[AppDatabaseKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func appDatabase(_ appDatabase: AppDatabase) -> some View {
|
||||
self
|
||||
.environment(\.appDatabase, appDatabase)
|
||||
.databaseContext(.readOnly { appDatabase.reader })
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,77 +0,0 @@
|
||||
import Foundation
|
||||
import GRDB
|
||||
|
||||
extension AppDatabase {
|
||||
/// The database for the application
|
||||
static let shared = makeShared()
|
||||
|
||||
private static func makeShared() -> AppDatabase {
|
||||
do {
|
||||
// Apply recommendations from
|
||||
// <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections>
|
||||
//
|
||||
// Create the "Application Support/Database" directory if needed
|
||||
let fileManager = FileManager.default
|
||||
let appSupportURL = try fileManager.url(
|
||||
for: .applicationSupportDirectory, in: .userDomainMask,
|
||||
appropriateFor: nil, create: true)
|
||||
let directoryURL = appSupportURL.appendingPathComponent("Database", isDirectory: true)
|
||||
|
||||
// Support for tests: delete the database if requested
|
||||
if CommandLine.arguments.contains("-reset") {
|
||||
try? fileManager.removeItem(at: directoryURL)
|
||||
}
|
||||
|
||||
// Create the database folder if needed
|
||||
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
|
||||
|
||||
// Open or create the database
|
||||
let databaseURL = directoryURL.appendingPathComponent("db.sqlite")
|
||||
NSLog("Database stored at \(databaseURL.path)")
|
||||
let dbPool = try DatabasePool(
|
||||
path: databaseURL.path,
|
||||
// Use default AppDatabase configuration
|
||||
configuration: AppDatabase.makeConfiguration())
|
||||
|
||||
// Create the AppDatabase
|
||||
let appDatabase = try AppDatabase(dbPool)
|
||||
|
||||
// Prepare the database with test fixtures if requested
|
||||
if CommandLine.arguments.contains("-fixedTestData") {
|
||||
try appDatabase.createPlayersForUITests()
|
||||
} else {
|
||||
// Otherwise, populate the database if it is empty, for better
|
||||
// demo purpose.
|
||||
try appDatabase.createRandomPlayersIfEmpty()
|
||||
}
|
||||
|
||||
return appDatabase
|
||||
} catch {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate.
|
||||
//
|
||||
// Typical reasons for an error here include:
|
||||
// * The parent directory cannot be created, or disallows writing.
|
||||
// * The database is not accessible, due to permissions or data protection when the device is locked.
|
||||
// * The device is out of space.
|
||||
// * The database could not be migrated to its latest schema version.
|
||||
// Check the error message to determine what the actual problem was.
|
||||
fatalError("Unresolved error \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an empty database for SwiftUI previews
|
||||
static func empty() -> AppDatabase {
|
||||
// Connect to an in-memory database
|
||||
// See https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections
|
||||
let dbQueue = try! DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
return try! AppDatabase(dbQueue)
|
||||
}
|
||||
|
||||
/// Creates a database full of random players for SwiftUI previews
|
||||
static func random() -> AppDatabase {
|
||||
let appDatabase = empty()
|
||||
try! appDatabase.createRandomPlayersIfEmpty()
|
||||
return appDatabase
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import GRDB
|
||||
|
||||
/// The Player struct.
|
||||
///
|
||||
/// Identifiable conformance supports SwiftUI list animations, and type-safe
|
||||
/// GRDB primary key methods.
|
||||
/// Equatable conformance supports tests.
|
||||
struct Player: Identifiable, Equatable {
|
||||
/// The player id.
|
||||
///
|
||||
/// Int64 is the recommended type for auto-incremented database ids.
|
||||
/// Use nil for players that are not inserted yet in the database.
|
||||
var id: Int64?
|
||||
var name: String
|
||||
var score: Int
|
||||
}
|
||||
|
||||
extension Player {
|
||||
private static let names = [
|
||||
"Arthur", "Anita", "Barbara", "Bernard", "Craig", "Chiara", "David",
|
||||
"Dean", "Éric", "Elena", "Fatima", "Frederik", "Gilbert", "Georgette",
|
||||
"Henriette", "Hassan", "Ignacio", "Irene", "Julie", "Jack", "Karl",
|
||||
"Kristel", "Louis", "Liz", "Masashi", "Mary", "Noam", "Nicole",
|
||||
"Ophelie", "Oleg", "Pascal", "Patricia", "Quentin", "Quinn", "Raoul",
|
||||
"Rachel", "Stephan", "Susie", "Tristan", "Tatiana", "Ursule", "Urbain",
|
||||
"Victor", "Violette", "Wilfried", "Wilhelmina", "Yvon", "Yann",
|
||||
"Zazie", "Zoé"]
|
||||
|
||||
/// Creates a new player with empty name and zero score
|
||||
static func new() -> Player {
|
||||
Player(id: nil, name: "", score: 0)
|
||||
}
|
||||
|
||||
/// Creates a new player with random name and random score
|
||||
static func makeRandom() -> Player {
|
||||
Player(id: nil, name: randomName(), score: randomScore())
|
||||
}
|
||||
|
||||
/// Returns a random name
|
||||
static func randomName() -> String {
|
||||
names.randomElement()!
|
||||
}
|
||||
|
||||
/// Returns a random score
|
||||
static func randomScore() -> Int {
|
||||
10 * Int.random(in: 0...100)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
/// Make Player a Codable Record.
|
||||
///
|
||||
/// See <https://github.com/groue/GRDB.swift/blob/master/README.md#records>
|
||||
extension Player: Codable, FetchableRecord, MutablePersistableRecord {
|
||||
// Define database columns from CodingKeys
|
||||
fileprivate enum Columns {
|
||||
static let name = Column(CodingKeys.name)
|
||||
static let score = Column(CodingKeys.score)
|
||||
}
|
||||
|
||||
/// Updates a player id after it has been inserted in the database.
|
||||
mutating func didInsert(_ inserted: InsertionSuccess) {
|
||||
id = inserted.rowID
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Player Database Requests
|
||||
|
||||
/// Define some player requests used by the application.
|
||||
///
|
||||
/// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/recordrecommendedpractices>
|
||||
extension DerivableRequest<Player> {
|
||||
/// A request of players ordered by name.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// let players: [Player] = try dbWriter.read { db in
|
||||
/// try Player.all().orderedByName().fetchAll(db)
|
||||
/// }
|
||||
func orderedByName() -> Self {
|
||||
// Sort by name in a localized case insensitive fashion
|
||||
// See https://github.com/groue/GRDB.swift/blob/master/README.md#string-comparison
|
||||
order(Player.Columns.name.collating(.localizedCaseInsensitiveCompare))
|
||||
}
|
||||
|
||||
/// A request of players ordered by score.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// let players: [Player] = try dbWriter.read { db in
|
||||
/// try Player.all().orderedByScore().fetchAll(db)
|
||||
/// }
|
||||
/// let bestPlayer: Player? = try dbWriter.read { db in
|
||||
/// try Player.all().orderedByScore().fetchOne(db)
|
||||
/// }
|
||||
func orderedByScore() -> Self {
|
||||
// Sort by descending score, and then by name, in a
|
||||
// localized case insensitive fashion
|
||||
// See https://github.com/groue/GRDB.swift/blob/master/README.md#string-comparison
|
||||
order(
|
||||
Player.Columns.score.desc,
|
||||
Player.Columns.name.collating(.localizedCaseInsensitiveCompare))
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import GRDB
|
||||
import GRDBQuery
|
||||
|
||||
/// A player request can be used with the `@Query` property wrapper in order to
|
||||
/// feed a view with a list of players.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// struct MyView: View {
|
||||
/// @Query(PlayerRequest(ordering: .byName)) private var players: [Player]
|
||||
///
|
||||
/// var body: some View {
|
||||
/// List(players) { player in ... )
|
||||
/// }
|
||||
/// }
|
||||
struct PlayerRequest: ValueObservationQueryable {
|
||||
enum Ordering {
|
||||
case byScore
|
||||
case byName
|
||||
}
|
||||
|
||||
static var defaultValue: [Player] { [] }
|
||||
|
||||
/// The ordering used by the player request.
|
||||
var ordering: Ordering
|
||||
|
||||
func fetch(_ db: Database) throws -> [Player] {
|
||||
switch ordering {
|
||||
case .byScore:
|
||||
return try Player.all().orderedByScore().fetchAll(db)
|
||||
case .byName:
|
||||
return try Player.all().orderedByName().fetchAll(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_20pt@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_20pt@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_29pt@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_29pt@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_40pt@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_40pt@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_60pt@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_60pt@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_20pt.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_20pt@2x-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_29pt.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_29pt@2x-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_40pt.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_40pt@2x-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_76pt.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_76pt@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |