mirror of
https://github.com/XcodesOrg/XcodesApp.git
synced 2026-03-25 08:55:46 +00:00
Compare commits
622 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a0d3353b9 | ||
|
|
da8d22c6d3 | ||
|
|
4f9b7f8f6a | ||
|
|
f34e291433 | ||
|
|
789eedcdb7 | ||
|
|
f9262d9a05 | ||
|
|
4776af75ee | ||
|
|
cab5533b49 | ||
|
|
fc50db1cee | ||
|
|
576585b7de | ||
|
|
6cf9647840 | ||
|
|
90dccd8180 | ||
|
|
372d5b1f4e | ||
|
|
4063796833 | ||
|
|
be2eea0532 | ||
|
|
a6dec75603 | ||
|
|
9527fb2961 | ||
|
|
084bd8f4a1 | ||
|
|
f93da1c502 | ||
|
|
79ef494504 | ||
|
|
cdf3675a5e | ||
|
|
5974a85800 | ||
|
|
4ccb6e7f06 | ||
|
|
22e4991f9a | ||
|
|
059c51b5dd | ||
|
|
00dcf3816e | ||
|
|
08738d6912 | ||
|
|
472e36ed0f | ||
|
|
bfb8c2cbb8 | ||
|
|
a7b3f78813 | ||
|
|
2e2b16e759 | ||
|
|
0bc8e42a9b | ||
|
|
ceae881d9a | ||
|
|
debc41f688 | ||
|
|
4b9d86b22e | ||
|
|
bbec397613 | ||
|
|
a434d26921 | ||
|
|
78615418b5 | ||
|
|
54cca28dbe | ||
|
|
10ce2b8511 | ||
|
|
14b2b3da10 | ||
|
|
f3f3bf0e4f | ||
|
|
3621601658 | ||
|
|
9630a68224 | ||
|
|
227f42af37 | ||
|
|
8fbf9eef8a | ||
|
|
b302365454 | ||
|
|
a61338804c | ||
|
|
259ad0789a | ||
|
|
cc03660576 | ||
|
|
a43bf63aab | ||
|
|
cfef2879b5 | ||
|
|
36424a78e0 | ||
|
|
3d9cf73fc1 | ||
|
|
2dc1bcdcbb | ||
|
|
afa6dff0b9 | ||
|
|
17f3d365b8 | ||
|
|
7b154501b6 | ||
|
|
8654756d67 | ||
|
|
85c9bdba55 | ||
|
|
0a02b29a4b | ||
|
|
3a8722c07d | ||
|
|
29bf77007a | ||
|
|
a75c54f2f6 | ||
|
|
42c2c6bfc0 | ||
|
|
69e667cf87 | ||
|
|
dc22b913f3 | ||
|
|
9b107ec98c | ||
|
|
2ed84ef792 | ||
|
|
e04ed029de | ||
|
|
e3f996da6e | ||
|
|
aca4e0ac89 | ||
|
|
dd9a348298 | ||
|
|
d6efd8fa74 | ||
|
|
b81eab0cc4 | ||
|
|
dc31fe884c | ||
|
|
283c1a4739 | ||
|
|
cac0ebfab7 | ||
|
|
59331bcb38 | ||
|
|
3d6908f526 | ||
|
|
b91a7e53ad | ||
|
|
f8970f44d5 | ||
|
|
6050c78613 | ||
|
|
cf85e2fc5a | ||
|
|
15630109d5 | ||
|
|
7171b90c88 | ||
|
|
3d5fdd528b | ||
|
|
06558610e9 | ||
|
|
5e5fdf0d3a | ||
|
|
8277554048 | ||
|
|
ba11d4129e | ||
|
|
389d8d2a63 | ||
|
|
f4567bdf1e | ||
|
|
27bbf6f2a3 | ||
|
|
c1670b250a | ||
|
|
8e78c1c8c5 | ||
|
|
c31a1ef6fa | ||
|
|
37a86643b9 | ||
|
|
c245a1e69f | ||
|
|
0f5e42b88e | ||
|
|
dd46112769 | ||
|
|
c3e568e234 | ||
|
|
f269dcbc98 | ||
|
|
604358bf6c | ||
|
|
1a3ca60f46 | ||
|
|
27d5b03321 | ||
|
|
20b9833b67 | ||
|
|
e855a1fb62 | ||
|
|
908e7ba3ea | ||
|
|
7daa3e600e | ||
|
|
a936bf84b2 | ||
|
|
897f449b7c | ||
|
|
5ebe24060f | ||
|
|
13496d8a7d | ||
|
|
4b6616fdea | ||
|
|
74237f8fc1 | ||
|
|
c94c2c1979 | ||
|
|
b36165100b | ||
|
|
8a6f5a7deb | ||
|
|
600d4cbafa | ||
|
|
8811a6d0d8 | ||
|
|
f973e7d744 | ||
|
|
6e64db26fb | ||
|
|
97bf958301 | ||
|
|
dc5f637886 | ||
|
|
a587d53798 | ||
|
|
a433bc4f72 | ||
|
|
66deeb0d57 | ||
|
|
4a4b469e3f | ||
|
|
7628f85ba8 | ||
|
|
f64e97f7a2 | ||
|
|
1152bbfb28 | ||
|
|
33dc6347f2 | ||
|
|
7599178042 | ||
|
|
6fda52ed37 | ||
|
|
8f1f6e9363 | ||
|
|
848ce1d362 | ||
|
|
38ae157be5 | ||
|
|
7f3e8125c0 | ||
|
|
7f14a4b8b5 | ||
|
|
cd80053be6 | ||
|
|
021d0aa745 | ||
|
|
180dd52eee | ||
|
|
348c89871e | ||
|
|
21c26c9793 | ||
|
|
4e177b5fbf | ||
|
|
411f76788c | ||
|
|
88b15b989a | ||
|
|
45626e2789 | ||
|
|
1411bd30b3 | ||
|
|
f3472d8cc6 | ||
|
|
b9d37054ea | ||
|
|
48df320aa8 | ||
|
|
feac373350 | ||
|
|
9e1eff06a3 | ||
|
|
35314ce36f | ||
|
|
9d73cd2091 | ||
|
|
91b24fc162 | ||
|
|
b3fa08b43e | ||
|
|
c417978976 | ||
|
|
aa34f3b10d | ||
|
|
c595eba5df | ||
|
|
61bee6c84f | ||
|
|
0aefecf9bf | ||
|
|
acaafaeef5 | ||
|
|
7bdb227f3f | ||
|
|
7cf2c6f244 | ||
|
|
b17fbf56a8 | ||
|
|
dbf30000ca | ||
|
|
7359ae19ae | ||
|
|
c09b56086b | ||
|
|
b5e50c6eed | ||
|
|
f4c9a6d683 | ||
|
|
9a5e127387 | ||
|
|
238f974ce3 | ||
|
|
b89fc21b8b | ||
|
|
bf0dc9d855 | ||
|
|
74d8f395a5 | ||
|
|
4394c48e11 | ||
|
|
f62c5139f0 | ||
|
|
6cd8584058 | ||
|
|
96e4682c56 | ||
|
|
f58fced154 | ||
|
|
fd6baa81c4 | ||
|
|
9a866acf53 | ||
|
|
6f432eef7f | ||
|
|
5632820780 | ||
|
|
5993385a73 | ||
|
|
238ac71517 | ||
|
|
e9a95a3e63 | ||
|
|
21e2a10eec | ||
|
|
bf4650edcb | ||
|
|
14f00a0e09 | ||
|
|
d7dc67c046 | ||
|
|
8534e2850b | ||
|
|
5054ead2ca | ||
|
|
dc691ad38c | ||
|
|
4ba5d31427 | ||
|
|
791ae598a3 | ||
|
|
950045b427 | ||
|
|
34d7f6834e | ||
|
|
cc9d5e9e16 | ||
|
|
b4e598fc49 | ||
|
|
6b6088d58d | ||
|
|
e5c4fd431a | ||
|
|
ae55e0fb15 | ||
|
|
c7f9e54796 | ||
|
|
e520a96021 | ||
|
|
53ec9156ff | ||
|
|
8d7412fd18 | ||
|
|
5aed44f969 | ||
|
|
91f75843da | ||
|
|
6f0cdc72ec | ||
|
|
49eb2f4cf2 | ||
|
|
8a7597e70b | ||
|
|
144c591a41 | ||
|
|
0e76a18168 | ||
|
|
a078b8f7d0 | ||
|
|
a00dae9b0a | ||
|
|
c4bd1d2789 | ||
|
|
ab1ebe016f | ||
|
|
9cb4f243b0 | ||
|
|
c534d2494d | ||
|
|
19808c58e4 | ||
|
|
b1c986af3e | ||
|
|
2a9227a0d5 | ||
|
|
71ed9a3617 | ||
|
|
da6bfc775c | ||
|
|
66a60fcf19 | ||
|
|
0ad800a5db | ||
|
|
9d7f122d11 | ||
|
|
0252bf1fe0 | ||
|
|
36be5fd743 | ||
|
|
2418d22f71 | ||
|
|
fb7e1f65c1 | ||
|
|
8a0595adc9 | ||
|
|
7686620b1e | ||
|
|
f1604538f3 | ||
|
|
e4d21ac18b | ||
|
|
99c4b49f28 | ||
|
|
9ec90a3ca8 | ||
|
|
159f1caa5c | ||
|
|
0dc13d8eee | ||
|
|
dede435725 | ||
|
|
4acd676f69 | ||
|
|
dada7f2716 | ||
|
|
e1ea64c647 | ||
|
|
1cf4381c3a | ||
|
|
111f08fdcf | ||
|
|
4d95a270ae | ||
|
|
2184c872d3 | ||
|
|
7aaf0563e6 | ||
|
|
efcf6508f5 | ||
|
|
39f39212f6 | ||
|
|
7167c40072 | ||
|
|
79c1f67811 | ||
|
|
805f754bc8 | ||
|
|
48e9b1568e | ||
|
|
b968149235 | ||
|
|
3c5f86023e | ||
|
|
c85b3c2b6c | ||
|
|
36a9bd267b | ||
|
|
cd69ddc40e | ||
|
|
ede9bd1c46 | ||
|
|
7070575a32 | ||
|
|
638543070b | ||
|
|
98b986a30d | ||
|
|
f25fc3923e | ||
|
|
487bd2d1dd | ||
|
|
0884f85359 | ||
|
|
78a62c4eba | ||
|
|
c0cff4822c | ||
|
|
9622b563b3 | ||
|
|
2d3a26374b | ||
|
|
eda3e2680b | ||
|
|
0d631e6628 | ||
|
|
1f032a5719 | ||
|
|
c81a638e35 | ||
|
|
b3e91fad7a | ||
|
|
57bf6d8a80 | ||
|
|
7690c19115 | ||
|
|
ad749c1cb0 | ||
|
|
c5ada02a31 | ||
|
|
c1836a78c4 | ||
|
|
4649ba0c8a | ||
|
|
c153a93b1b | ||
|
|
f470986e2b | ||
|
|
b650261ddd | ||
|
|
ec4dc2b3d0 | ||
|
|
a596e5ff6c | ||
|
|
6ffce23616 | ||
|
|
2fdc06031c | ||
|
|
2f7d45c67a | ||
|
|
a2379583ef | ||
|
|
4e55aac679 | ||
|
|
d406972e81 | ||
|
|
9e787f98d8 | ||
|
|
d172df68e3 | ||
|
|
d08d2d38e4 | ||
|
|
112829b553 | ||
|
|
05f5c2cebf | ||
|
|
cecceea62f | ||
|
|
77ca8a2571 | ||
|
|
f1fea6365c | ||
|
|
03eb166c3d | ||
|
|
e56d83dbe3 | ||
|
|
7079e062d2 | ||
|
|
487cbb0045 | ||
|
|
5b18a90f81 | ||
|
|
b9dc7bf757 | ||
|
|
1bd5017350 | ||
|
|
ab4a3444ed | ||
|
|
8ff65381e6 | ||
|
|
ca363e1398 | ||
|
|
d9917ce2c4 | ||
|
|
44a85d63db | ||
|
|
19f1fd876d | ||
|
|
75ae0ab456 | ||
|
|
2f749deb8d | ||
|
|
6fcb4aa857 | ||
|
|
5edeb60835 | ||
|
|
47710b6bed | ||
|
|
330f590d8e | ||
|
|
b8c6a7cda8 | ||
|
|
cf57fde101 | ||
|
|
74ef10b697 | ||
|
|
5b95ef2af6 | ||
|
|
965b87002f | ||
|
|
5b1cc4a93d | ||
|
|
13715930af | ||
|
|
35fb72db68 | ||
|
|
dc5a8b03b6 | ||
|
|
b6e654c6f2 | ||
|
|
622fac605c | ||
|
|
de35bed9fa | ||
|
|
9dd91a2ce8 | ||
|
|
5977210252 | ||
|
|
8654a2075a | ||
|
|
7325502853 | ||
|
|
fc35950885 | ||
|
|
415d86718c | ||
|
|
581af76948 | ||
|
|
fb42bfc013 | ||
|
|
2de476b101 | ||
|
|
a556d95ee9 | ||
|
|
4f25905f4c | ||
|
|
8169ee0a22 | ||
|
|
927f842c61 | ||
|
|
9cf10125ca | ||
|
|
3353eb1112 | ||
|
|
9dd569c605 | ||
|
|
507fa37bb5 | ||
|
|
907e503f71 | ||
|
|
4ecc245db6 | ||
|
|
84702ad63b | ||
|
|
0607f57077 | ||
|
|
9b6a479798 | ||
|
|
e7f0adf69f | ||
|
|
d8e1069a92 | ||
|
|
60582094af | ||
|
|
cc4165252e | ||
|
|
3529b19295 | ||
|
|
771cb3242c | ||
|
|
7245d54a7c | ||
|
|
2d22210d5b | ||
|
|
3b1254a48e | ||
|
|
95c9776788 | ||
|
|
93f34dd31b | ||
|
|
ce906c5ce9 | ||
|
|
d3e07242b1 | ||
|
|
5423f20c26 | ||
|
|
74a870d4ff | ||
|
|
48b5611e0b | ||
|
|
371c94e3fa | ||
|
|
9d681927d2 | ||
|
|
ba031403d6 | ||
|
|
846fbc0635 | ||
|
|
0bc8edf75e | ||
|
|
b439eb555b | ||
|
|
d955ec8bd2 | ||
|
|
c2c67f1269 | ||
|
|
76bb3fbae0 | ||
|
|
29503ad9cf | ||
|
|
a0efd467c6 | ||
|
|
2f37ae0e41 | ||
|
|
9452db4749 | ||
|
|
b5550d0557 | ||
|
|
7e459f8933 | ||
|
|
7263ff3f67 | ||
|
|
16bd85c918 | ||
|
|
87cdbd7ec0 | ||
|
|
4a48d55ac7 | ||
|
|
f20ba75384 | ||
|
|
c496238fb9 | ||
|
|
7eb5a8f073 | ||
|
|
834dd9807a | ||
|
|
e0be252899 | ||
|
|
5b43677f30 | ||
|
|
816c00460d | ||
|
|
550d917b33 | ||
|
|
45ea417a98 | ||
|
|
b56a73c8fe | ||
|
|
495dfedf46 | ||
|
|
0b6ae5d7ec | ||
|
|
b4a4f8e329 | ||
|
|
f8ec0a37fe | ||
|
|
34817a6916 | ||
|
|
9a61a94239 | ||
|
|
ce19069353 | ||
|
|
c373068f15 | ||
|
|
a0a74ed5bf | ||
|
|
ebafb0fb3d | ||
|
|
2d748a1a2a | ||
|
|
4049e43ce5 | ||
|
|
35ae6b7672 | ||
|
|
0e45884608 | ||
|
|
b9ba988c30 | ||
|
|
d31065e55e | ||
|
|
e533012ea0 | ||
|
|
5be971cc8e | ||
|
|
513d39d45e | ||
|
|
7150bced63 | ||
|
|
25460b9f80 | ||
|
|
7fda5e7a9a | ||
|
|
ae5b606e52 | ||
|
|
cf03241320 | ||
|
|
27abbb18fd | ||
|
|
093e44c21f | ||
|
|
502559e7a4 | ||
|
|
6e8dbbc6de | ||
|
|
a02cc71ac6 | ||
|
|
651fe480a0 | ||
|
|
90dbf4f010 | ||
|
|
64d6b5d5dc | ||
|
|
5e0eb827d3 | ||
|
|
ed2e5bfcaa | ||
|
|
2bad04fec7 | ||
|
|
ea3264a2ce | ||
|
|
0e7deed1c6 | ||
|
|
7ae956e44d | ||
|
|
78c9207877 | ||
|
|
bb8676b620 | ||
|
|
cb1120624b | ||
|
|
da93339a3d | ||
|
|
13e829ca88 | ||
|
|
beca9b4187 | ||
|
|
2030ef2d5a | ||
|
|
998a8982f8 | ||
|
|
dc6124f25c | ||
|
|
bb66552812 | ||
|
|
c27b28868b | ||
|
|
e987104502 | ||
|
|
8b43903b42 | ||
|
|
3574a3e781 | ||
|
|
0f3d92b70f | ||
|
|
e2f49bee23 | ||
|
|
c045592aea | ||
|
|
197197982f | ||
|
|
3e970f5b56 | ||
|
|
33b0356622 | ||
|
|
bb0e4d489f | ||
|
|
410e1e5ae3 | ||
|
|
c85f48ae3c | ||
|
|
28456c4e8e | ||
|
|
17d94cac67 | ||
|
|
b74c567a5c | ||
|
|
ac3bb5a330 | ||
|
|
653f1ae59e | ||
|
|
61464cd812 | ||
|
|
739193c3cd | ||
|
|
8fb1564fdd | ||
|
|
dcb9c0e860 | ||
|
|
b2c949d68a | ||
|
|
0296f14632 | ||
|
|
48b1b19185 | ||
|
|
a5486aa6ac | ||
|
|
fb6a858491 | ||
|
|
a0b45f45be | ||
|
|
38b7fe77dd | ||
|
|
ee9ee59fc4 | ||
|
|
b64af2fc44 | ||
|
|
e34f1a207a | ||
|
|
491e810a3c | ||
|
|
2ecd1398fb | ||
|
|
155b57f202 | ||
|
|
672efcbca4 | ||
|
|
dc1d89dfc9 | ||
|
|
286e69bbaf | ||
|
|
bc0f44bc9a | ||
|
|
60bbe49ea7 | ||
|
|
45f034565b | ||
|
|
43d604aa6b | ||
|
|
b93fa01d07 | ||
|
|
5f0b722799 | ||
|
|
8f3f992460 | ||
|
|
7945f94bf8 | ||
|
|
a70069ce87 | ||
|
|
575a7269df | ||
|
|
1baf1f8515 | ||
|
|
bbbd0322eb | ||
|
|
8831d5f992 | ||
|
|
ffec3d5157 | ||
|
|
564ca96149 | ||
|
|
c9f7c3b7d1 | ||
|
|
d351068684 | ||
|
|
b2143744b3 | ||
|
|
c7049c908c | ||
|
|
9483a0c785 | ||
|
|
b3e1b58a0c | ||
|
|
e8e9c8bbd9 | ||
|
|
b240d0f77b | ||
|
|
57521e5e65 | ||
|
|
9a926af7ee | ||
|
|
961e4e9d5b | ||
|
|
f2a7c155eb | ||
|
|
d461721221 | ||
|
|
71760ca6e3 | ||
|
|
a1923ea29a | ||
|
|
65f79d9313 | ||
|
|
8185b1054f | ||
|
|
3213b3ef33 | ||
|
|
25bbe1cb54 | ||
|
|
75a9047492 | ||
|
|
6d6b9db70e | ||
|
|
a9c7e524ad | ||
|
|
2f40821244 | ||
|
|
bce40c95ae | ||
|
|
5fa3152918 | ||
|
|
a106835e15 | ||
|
|
f3e97f54f5 | ||
|
|
9f013ccdf2 | ||
|
|
b9140036e2 | ||
|
|
f82136b8a0 | ||
|
|
4a2dc8b603 | ||
|
|
311014323a | ||
|
|
5106ccead4 | ||
|
|
ae35153fdf | ||
|
|
f699ab6c2f | ||
|
|
3db3fdbb54 | ||
|
|
e59bf08e54 | ||
|
|
cea8548872 | ||
|
|
7ae8f4211d | ||
|
|
37c8d17716 | ||
|
|
37e4a185e5 | ||
|
|
3a0e06ded2 | ||
|
|
2abae033ea | ||
|
|
4204b42044 | ||
|
|
a4349ee540 | ||
|
|
8b9a21ec74 | ||
|
|
7440f5715d | ||
|
|
5386b630e2 | ||
|
|
0b03ee98ef | ||
|
|
050cf98660 | ||
|
|
becc545efd | ||
|
|
dd99b9c611 | ||
|
|
755c2656fd | ||
|
|
8ed69a97a1 | ||
|
|
37c67b7c16 | ||
|
|
048aac2a8b | ||
|
|
e3555f9868 | ||
|
|
c64ff9f6ed | ||
|
|
3116a6857e | ||
|
|
23521d2180 | ||
|
|
dbdc7cf76d | ||
|
|
11f7cae14f | ||
|
|
8786b403cb | ||
|
|
857c232fb0 | ||
|
|
74f54c8128 | ||
|
|
5d680633e0 | ||
|
|
4af1fca7c7 | ||
|
|
fad96f497d | ||
|
|
d1bee98abb | ||
|
|
0821834fed | ||
|
|
795de95d53 | ||
|
|
d01013d8b8 | ||
|
|
dc85ab87f7 | ||
|
|
0ab62517dd | ||
|
|
7a82620a8d | ||
|
|
21c6ef2d47 | ||
|
|
764a242096 | ||
|
|
77ab5b9f59 | ||
|
|
aad0f54ed3 | ||
|
|
cd8104ca69 | ||
|
|
c8f4de47ea | ||
|
|
1d22be649a | ||
|
|
abf8dbf7b2 | ||
|
|
03a136385f | ||
|
|
44bef1a5f4 | ||
|
|
f7b4c5e2bb | ||
|
|
b36ef479a7 | ||
|
|
e2f0c9dbc5 | ||
|
|
20e7b57ad4 | ||
|
|
4cee0f2f15 | ||
|
|
e628d04beb | ||
|
|
c281abe7d2 | ||
|
|
555843327b | ||
|
|
c2259cf078 | ||
|
|
9a62be6376 | ||
|
|
34dabd73dc | ||
|
|
e92434e9ba | ||
|
|
70d4372f51 | ||
|
|
f665054c7f | ||
|
|
6f71cce405 | ||
|
|
227768f6ae | ||
|
|
1bd83870a2 | ||
|
|
8e7063e538 | ||
|
|
9fc2c7aaf6 | ||
|
|
f75bf2d5bb | ||
|
|
e2ac15eed4 | ||
|
|
ce001c8e68 | ||
|
|
c0974edc98 | ||
|
|
49746e4b81 | ||
|
|
a394f1df20 | ||
|
|
9de2684f2f | ||
|
|
61f43849b5 | ||
|
|
709af9e97a | ||
|
|
239aeabf08 | ||
|
|
4db8346c2f | ||
|
|
f769f69f87 | ||
|
|
a3c541cb39 | ||
|
|
04d81ed9e4 | ||
|
|
e8fd83032b |
170 changed files with 32119 additions and 1218 deletions
3
.github/release-drafter.yml
vendored
3
.github/release-drafter.yml
vendored
|
|
@ -5,6 +5,9 @@ categories:
|
|||
- title: '🐛 Bug Fixes'
|
||||
labels:
|
||||
- 'bugfix'
|
||||
- title: '🌎 Localization'
|
||||
labels:
|
||||
- 'localization'
|
||||
- title: '🧰 Maintenance'
|
||||
label:
|
||||
- 'chore'
|
||||
|
|
|
|||
32
.github/workflows/appcast.yml
vendored
32
.github/workflows/appcast.yml
vendored
|
|
@ -9,23 +9,23 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout 🛎
|
||||
uses: actions/checkout@v2.3.5
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# If you're using actions/checkout@v2.3.5 you must set persist-credentials to false in most cases for the deployment to work correctly.
|
||||
# If you're using actions/checkout@v4 you must set persist-credentials to false in most cases for the deployment to work correctly.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Cache 📦
|
||||
uses: actions/cache@v2.1.6
|
||||
with:
|
||||
path: AppCast/vendor/bundle
|
||||
key: ${{ runner.os }}-gems-v1.0-${{ hashFiles('AppCast/Gemfile') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gems-
|
||||
# - name: Cache 📦
|
||||
# uses: actions/cache@v4.1.1
|
||||
# with:
|
||||
# path: AppCast/vendor/bundle
|
||||
# key: ${{ runner.os }}-gems-v1.0-${{ hashFiles('AppCast/Gemfile') }}
|
||||
# restore-keys: |
|
||||
# ${{ runner.os }}-gems-
|
||||
|
||||
- name: Ruby ♦️
|
||||
uses: actions/setup-ruby@v1.1.3
|
||||
- name: Setup Ruby, JRuby and TruffleRuby
|
||||
uses: ruby/setup-ruby@v1.197.0
|
||||
with:
|
||||
ruby-version: '2.7'
|
||||
ruby-version: '3.0'
|
||||
|
||||
- name: Bundler 💎
|
||||
working-directory: AppCast
|
||||
|
|
@ -43,8 +43,8 @@ jobs:
|
|||
run: bundle exec jekyll build
|
||||
|
||||
- name: Publish 🚀
|
||||
uses: JamesIves/github-pages-deploy-action@releases/v3
|
||||
uses: JamesIves/github-pages-deploy-action@releases/v4
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BRANCH: gh-pages
|
||||
FOLDER: AppCast/_site
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: gh-pages
|
||||
folder: AppCast/_site
|
||||
|
|
|
|||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -8,10 +8,10 @@ on:
|
|||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: macOS-11.0
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v2.3.5
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run tests
|
||||
env:
|
||||
DEVELOPER_DIR: /Applications/Xcode_12.5.1.app
|
||||
DEVELOPER_DIR: /Applications/Xcode_16.4.app
|
||||
run: xcodebuild test -scheme Xcodes
|
||||
|
|
|
|||
2
.github/workflows/release-drafter.yml
vendored
2
.github/workflows/release-drafter.yml
vendored
|
|
@ -11,6 +11,6 @@ jobs:
|
|||
update_release_draft:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@v5
|
||||
- uses: release-drafter/release-drafter@v6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
|||
22
.github/workflows/xcstrings.yml
vendored
Normal file
22
.github/workflows/xcstrings.yml
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
name: XCStrings Validation
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: macos-13
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Clone SwiftPolyglot
|
||||
run: git clone --branch 0.3.1 -- https://github.com/appdecostudio/SwiftPolyglot.git ../SwiftPolyglot
|
||||
|
||||
- name: validate translations
|
||||
run: |
|
||||
swift build --package-path ../SwiftPolyglot --configuration release
|
||||
swift run --package-path ../SwiftPolyglot swiftpolyglot "ca,de,el,es,fi,fr,hi,it,ja,ko,nl,pl,pt-BR,ru,tr,uk,zh-Hans,zh-Hant" --errorOnMissing
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -36,6 +36,9 @@ Product/*
|
|||
timeline.xctimeline
|
||||
playground.xcworkspace
|
||||
|
||||
# Jetbrains IDEA
|
||||
.idea
|
||||
|
||||
# Swift Package Manager
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ source "https://rubygems.org"
|
|||
#
|
||||
# This will help ensure the proper Jekyll version is running.
|
||||
# Happy Jekylling!
|
||||
gem "jekyll", "~> 3.9.0"
|
||||
gem "jekyll", "~> 4.4.1"
|
||||
|
||||
gem "jekyll-github-metadata", group: :jekyll_plugins
|
||||
|
||||
|
|
|
|||
|
|
@ -1,88 +1,110 @@
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
addressable (2.8.0)
|
||||
public_suffix (>= 2.0.2, < 5.0)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
base64 (0.3.0)
|
||||
bigdecimal (3.2.2)
|
||||
colorator (1.1.0)
|
||||
concurrent-ruby (1.1.7)
|
||||
em-websocket (0.5.2)
|
||||
concurrent-ruby (1.3.5)
|
||||
csv (3.3.5)
|
||||
em-websocket (0.5.3)
|
||||
eventmachine (>= 0.12.9)
|
||||
http_parser.rb (~> 0.6.0)
|
||||
http_parser.rb (~> 0)
|
||||
eventmachine (1.2.7)
|
||||
faraday (1.3.0)
|
||||
faraday-net_http (~> 1.0)
|
||||
multipart-post (>= 1.2, < 3)
|
||||
ruby2_keywords
|
||||
faraday-net_http (1.0.1)
|
||||
ffi (1.14.2)
|
||||
ffi (1.17.2)
|
||||
ffi (1.17.2-x86_64-darwin)
|
||||
forwardable-extended (2.6.0)
|
||||
http_parser.rb (0.6.0)
|
||||
i18n (0.9.5)
|
||||
google-protobuf (4.31.1)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.31.1-x86_64-darwin)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
http_parser.rb (0.8.0)
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
jekyll (3.9.0)
|
||||
jekyll (4.4.1)
|
||||
addressable (~> 2.4)
|
||||
base64 (~> 0.2)
|
||||
colorator (~> 1.0)
|
||||
csv (~> 3.0)
|
||||
em-websocket (~> 0.5)
|
||||
i18n (~> 0.7)
|
||||
jekyll-sass-converter (~> 1.0)
|
||||
i18n (~> 1.0)
|
||||
jekyll-sass-converter (>= 2.0, < 4.0)
|
||||
jekyll-watch (~> 2.0)
|
||||
kramdown (>= 1.17, < 3)
|
||||
json (~> 2.6)
|
||||
kramdown (~> 2.3, >= 2.3.1)
|
||||
kramdown-parser-gfm (~> 1.0)
|
||||
liquid (~> 4.0)
|
||||
mercenary (~> 0.3.3)
|
||||
mercenary (~> 0.3, >= 0.3.6)
|
||||
pathutil (~> 0.9)
|
||||
rouge (>= 1.7, < 4)
|
||||
rouge (>= 3.0, < 5.0)
|
||||
safe_yaml (~> 1.0)
|
||||
terminal-table (>= 1.8, < 4.0)
|
||||
webrick (~> 1.7)
|
||||
jekyll-github-metadata (2.13.0)
|
||||
jekyll (>= 3.4, < 5.0)
|
||||
octokit (~> 4.0, != 4.4.0)
|
||||
jekyll-sass-converter (1.5.2)
|
||||
sass (~> 3.4)
|
||||
jekyll-sass-converter (3.1.0)
|
||||
sass-embedded (~> 1.75)
|
||||
jekyll-watch (2.2.1)
|
||||
listen (~> 3.0)
|
||||
kramdown (2.3.1)
|
||||
rexml
|
||||
json (2.12.2)
|
||||
kramdown (2.5.1)
|
||||
rexml (>= 3.3.9)
|
||||
kramdown-parser-gfm (1.1.0)
|
||||
kramdown (~> 2.0)
|
||||
liquid (4.0.3)
|
||||
listen (3.4.1)
|
||||
liquid (4.0.4)
|
||||
listen (3.9.0)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
mercenary (0.3.6)
|
||||
mercenary (0.4.0)
|
||||
multipart-post (2.1.1)
|
||||
octokit (4.20.0)
|
||||
faraday (>= 0.9)
|
||||
sawyer (~> 0.8.0, >= 0.5.3)
|
||||
pathutil (0.16.2)
|
||||
forwardable-extended (~> 2.6)
|
||||
public_suffix (4.0.6)
|
||||
rb-fsevent (0.10.4)
|
||||
rb-inotify (0.10.1)
|
||||
public_suffix (6.0.2)
|
||||
rake (13.3.0)
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.11.1)
|
||||
ffi (~> 1.0)
|
||||
rexml (3.2.5)
|
||||
rouge (3.26.0)
|
||||
rexml (3.4.1)
|
||||
rouge (4.5.2)
|
||||
ruby2_keywords (0.0.2)
|
||||
safe_yaml (1.0.5)
|
||||
sass (3.7.4)
|
||||
sass-listen (~> 4.0.0)
|
||||
sass-listen (4.0.0)
|
||||
rb-fsevent (~> 0.9, >= 0.9.4)
|
||||
rb-inotify (~> 0.9, >= 0.9.7)
|
||||
sass-embedded (1.89.2)
|
||||
google-protobuf (~> 4.31)
|
||||
rake (>= 13)
|
||||
sass-embedded (1.89.2-x86_64-darwin)
|
||||
google-protobuf (~> 4.31)
|
||||
sawyer (0.8.2)
|
||||
addressable (>= 2.3.5)
|
||||
faraday (> 0.8, < 2.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
thread_safe (0.3.6)
|
||||
tzinfo (1.2.9)
|
||||
tzinfo (1.2.10)
|
||||
thread_safe (~> 0.1)
|
||||
tzinfo-data (1.2020.6)
|
||||
tzinfo (>= 1.0.0)
|
||||
unicode-display_width (2.6.0)
|
||||
wdm (0.1.1)
|
||||
webrick (1.9.1)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
x86_64-darwin-20
|
||||
|
||||
DEPENDENCIES
|
||||
jekyll (~> 3.9.0)
|
||||
jekyll (~> 4.4.1)
|
||||
jekyll-github-metadata
|
||||
kramdown-parser-gfm
|
||||
tzinfo (~> 1.2)
|
||||
|
|
@ -90,4 +112,4 @@ DEPENDENCIES
|
|||
wdm (~> 0.1.0)
|
||||
|
||||
BUNDLED WITH
|
||||
2.2.5
|
||||
2.6.9
|
||||
|
|
|
|||
43
CONTRIBUTING.md
Normal file
43
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Contributing to Xcodes
|
||||
We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's:
|
||||
|
||||
- Reporting a bug
|
||||
- Discussing the current state of the code
|
||||
- Submitting a fix
|
||||
- Proposing new features
|
||||
- Becoming a maintainer
|
||||
|
||||
## We Develop with GitHub
|
||||
We use GitHub to host code, to track issues and feature requests, as well as accept pull requests.
|
||||
|
||||
## All Code Changes Happen Through Pull Requests
|
||||
Pull requests are the best way to propose changes to the codebase We actively welcome your pull requests:
|
||||
|
||||
1. Fork the repo and create your branch from `main`.
|
||||
2. If you've added code that should be tested, add tests.
|
||||
3. If you've added new functionality, add documentation
|
||||
4. Ensure the test suite passes.
|
||||
5. Make sure your code lints.
|
||||
6. Issue that pull request!
|
||||
|
||||
## Any contributions you make will be under the MIT Software License
|
||||
In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern.
|
||||
|
||||
## Report bugs using GitHub [issues](https://github.com/robotsandpencils/xcodesapp/issues)
|
||||
We use GitHub issues to track public bugs. Report a bug by [opening a new issue](); it's that easy!
|
||||
|
||||
## Write bug reports with detail, background, and sample code
|
||||
|
||||
**Great Bug Reports** tend to have:
|
||||
|
||||
- A quick summary and/or background
|
||||
- Steps to reproduce
|
||||
- Be specific!
|
||||
- What you expected would happen
|
||||
- What actually happens
|
||||
- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work)
|
||||
|
||||
People *love* thorough bug reports.
|
||||
|
||||
## License
|
||||
By contributing, you agree that your contributions will be licensed under its MIT License.
|
||||
|
|
@ -73,4 +73,4 @@ Based on [this blog post](https://yiqiu.me/2015/11/19/sparkle-update-on-github/)
|
|||
|
||||
We're deliberately not capturing system profile data with Sparkle right now, because we don't want it and because it would require additional infrastructure.
|
||||
|
||||
We also considered https://github.com/mxcl/AppUpdater, but decided aganist it because it seemed less battle-tested than Sparkle and currently lacks an open source license.
|
||||
We also considered https://github.com/mxcl/AppUpdater, but decided against it because it seemed less battle-tested than Sparkle and currently lacks an open source license.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import Foundation
|
||||
|
||||
let machServiceName = "com.robotsandpencils.XcodesApp.Helper"
|
||||
let clientBundleID = "com.robotsandpencils.XcodesApp"
|
||||
let machServiceName = "com.xcodesorg.xcodesapp.Helper"
|
||||
let clientBundleID = "com.xcodesorg.xcodesapp"
|
||||
let subjectOrganizationalUnit = Bundle.main.infoDictionary!["CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT"] as! String
|
||||
|
||||
@objc(HelperXPCProtocol)
|
||||
|
|
|
|||
BIN
IconDark.png
Normal file
BIN
IconDark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
IconMono.png
Normal file
BIN
IconMono.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
123
README.md
123
README.md
|
|
@ -1,47 +1,116 @@
|
|||
<h1><img src="icon.png" align="center" width=50 height=50 /> Xcodes.app</h1>
|
||||
<h1><img src="icon.png" align="center" width=50 height=50 /> <img src="IconDark.png" align="center" width=50 height=50 /> <img src="IconMono.png" align="center" width=50 height=50 /> Xcodes.app</h1>
|
||||
|
||||
The easiest way to install and switch between multiple versions of Xcode.
|
||||
|
||||
_If you're looking for a command-line version of Xcodes.app, try [`xcodes`](https://github.com/RobotsAndPencils/xcodes)._
|
||||
_If you're looking for a command-line version of Xcodes.app, try [`xcodes`](https://github.com/XcodesOrg/xcodes)._
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
### :tada: Announcement
|
||||
|
||||
XcodesApp is now part of the `XcodesOrg` - [read more here](nextstep.md)
|
||||
|
||||
## Features
|
||||
|
||||
- List all available Xcode versions from [Xcode Releases'](https://xcodereleases.com) data or the Apple Developer website.
|
||||
- Install any Xcode version, fully automated from start to finish. Xcodes uses [`aria2`](https://aria2.github.io), which uses up to 16 connections to download 3-5x faster than URLSession.
|
||||
- Install any Xcode version, **fully automated** from start to finish. Xcodes uses [`aria2`](https://aria2.github.io), which uses up to 16 connections to download 3-5x faster than URLSession.
|
||||
- Automatically resumes installs if network errors.
|
||||
- Apple ID required to download Xcode versions.
|
||||
- Just click a button to make a version active with `xcode-select`.
|
||||
- View release notes, OS compatibility, included SDKs and compilers from [Xcode Releases](https://xcodereleases.com).
|
||||
- Dark/Light Mode supported
|
||||
- Security Key Authentication supported
|
||||
- Support installing Platforms/Runtimes
|
||||
- Support installing Apple Silicon variants
|
||||
|
||||
## Platforms/Runtimes
|
||||
|
||||
- Xcodes supports downloading the Apple runtimes via the app. Simply click on the Platform, and Xcodes will install automatically for you.
|
||||
|
||||
**Note: iOS 18+, tvOS 18+, watchOS 11+, visionOS 2+ requires that Xcode 16.1 Beta 3+ be installed and active.**
|
||||
|
||||
## Apple Silicon Variants
|
||||
|
||||
As of Xcode 26, Apple provides Apple Silicon as well as Universal variants for Xcode versions as well as each runtime. Simply tap on which variant you want installed. To install the Apple Silicon runtime variant Xcode 26 is required to be active.
|
||||
|
||||
## Experiments
|
||||
|
||||
- Thanks to the wonderful work of [https://github.com/saagarjha/unxip](https://github.com/saagarjha/unxip), turn on the experiment to increase your unxipping time by up to 70%! More can be found on his repo, but bugs, high memory may occur if used.
|
||||
|
||||

|
||||

|
||||
|
||||
## Localization
|
||||
|
||||
Xcodes supports localization in several languages.
|
||||
|
||||
The following languages are supported because of the following community users!
|
||||
|
||||
|||||
|
||||
|-|-|-|-|
|
||||
|French 🇫🇷 |[@dompepin](https://github.com/dompepin)|Italian 🇮🇹 |[gualtierofrigerio](https://github.com/gualtierofrigerio)|
|
||||
|Spanish 🇪🇸🇲 |[@cesartru88](https://github.com/cesartru88)|Korean 🇰🇷 |[@ryan-son](https://github.com/ryan-son)|
|
||||
|Russian 🇷🇺 |[@alexmazlov](https://github.com/alexmazlov)|Turkish 🇹🇷 |[@egesucu](https://github.com/egesucu)|
|
||||
|Hindi 🇮🇳 |[@KGurpreet](https://github.com/KGurpreet)|Chinese-Simplified 🇨🇳|[@megabitsenmzq](https://github.com/megabitsenmzq)|
|
||||
|Finnish 🇫🇮 |[@marcusziade](https://github.com/marcusziade)|Chinese-Traditional 🇹🇼|[@itszero](https://github.com/itszero)|
|
||||
|Ukranian 🇺🇦 |[@gelosi](https://github.com/gelosi)|Japanese 🇯🇵|[@tatsuz0u](https://github.com/tatsuz0u)|
|
||||
|German 🇩🇪|[@drct](https://github.com/drct)|Dutch 🇳🇱|[@jfversluis](https://github/com/jfversluis)|
|
||||
|Brazilian Portuguese 🇧🇷|[@brunomunizaf](https://github.com/brunomunizaf)|Polish 🇵🇱|[@jakex7](https://github.com/jakex7)|
|
||||
|Catalan|[@ferranabello](https://github.com/ferranabello)|Greek 🇬🇷|[@alladinian](https://github.com/alladinian)
|
||||
|Thai 🇹🇭|[@neetrath](https://github.com/neetrath)|
|
||||
|
||||
Want to add more languages? Simply create a PR with the updated strings file.
|
||||
|
||||
## Installation
|
||||
v1.X - requires macOS 11 or newer
|
||||
v2.X - requires macOS 13
|
||||
v3.X - requires macOS 13 - architecture variants and updated icon.
|
||||
|
||||
Xcodes.app runs on macOS Big Sur 11.0 or later.
|
||||
### Install with Homebrew
|
||||
|
||||
### Homebrew Cask
|
||||
Developer ID-signed and notarized release builds are available on Homebrew. These don't require Xcode to already be installed in order to use.
|
||||
|
||||
```sh
|
||||
brew install --cask xcodes
|
||||
|
||||
# These are Developer ID-signed and notarized release builds and don't require Xcode to already be installed in order to use.
|
||||
```
|
||||
|
||||
### Download a release
|
||||
### Manually install
|
||||
|
||||
1. Download the latest version [here](https://github.com/RobotsAndPencils/XcodesApp/releases/latest) using the **Xcodes.zip** asset. These are Developer ID-signed and notarized release builds and don't require Xcode to already be installed in order to use.
|
||||
1. Download the latest version [here](https://github.com/XcodesOrg/XcodesApp/releases/latest) using the **Xcodes.zip** asset. These are Developer ID-signed and notarized release builds and don't require Xcode to already be installed in order to use.
|
||||
2. Move the unzipped `Xcodes.app` to your `/Applications` directory
|
||||
|
||||
## Support
|
||||
|
||||
Xcodes.app and CLI is updated, maintained with contributors like yourself. Even open source libraries and tools come with expenses. If you would like to support Xcodes or donate to the development and maintenance of the tool, it would be greatly appreciated. There is absolutely no obligation!
|
||||
|
||||
<a href="https://opencollective.com/xcodesapp" target="_blank">
|
||||
<img src="https://opencollective.com/xcodesapp/donate/button@2x.png?color=blue" class="buymeacoffee" width=200 />
|
||||
</a>
|
||||
|
||||
## Development
|
||||
|
||||
You'll need macOS 11 Big Sur and Xcode 12 in order to build and run Xcodes.app.
|
||||
You'll need macOS 15.6 Ventura and Xcode 26 in order to build and run Xcodes.app.
|
||||
|
||||
If you aren't a Robots and Pencils employee you'll need to change the CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT build setting to your Apple Developer team ID in order for code signing validation to succeed between the main app and the privileged helper.
|
||||
`Unxip` and `aria2` must be compiled as a universal binary
|
||||
```
|
||||
# compile for Intel
|
||||
swiftc -parse-as-library -O -target x86_64-apple-macos11 unxip.swift
|
||||
# compile for M1
|
||||
swiftc -parse-as-library -O -target arm64-apple-macos11 unxip.swift
|
||||
|
||||
# combine for universal binary
|
||||
lipo -create -output unxip unxip_intel unxip_m1
|
||||
# check it
|
||||
lipo -archs unxip
|
||||
```
|
||||
|
||||
Notable design decisions are recorded in [DECISIONS.md](./DECISIONS.md). The Apple authentication flow is described in [Apple.paw](./Apple.paw), which will allow you to play with the API endpoints that are involved using the [Paw](https://paw.cloud) app.
|
||||
|
||||
[`xcode-install`](https://github.com/xcpretty/xcode-install) and [fastlane/spaceship](https://github.com/fastlane/fastlane/tree/master/spaceship) both deserve credit for figuring out the hard parts of what makes this possible.
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Releasing a new version</summary>
|
||||
|
||||
|
|
@ -50,9 +119,9 @@ Follow the steps below to build and release a new version of Xcodes.app. For any
|
|||
```sh
|
||||
# Update the version number in Xcode and commit the change, if necessary
|
||||
|
||||
# Question: Did anything in XPCHelper change?
|
||||
# - com.robotsandpencils.XcodesApp.Helper folder and HelperXPCShared
|
||||
# - if so, bump the version number in com.robotsandpencils.XcodesApp.Helper target.
|
||||
# Question: Did anything in XPCHelper change?
|
||||
# - com.xcodesorg.xcodesapp.Helper folder and HelperXPCShared
|
||||
# - if so, bump the version number in com.xcodesorg.xcodesapp.Helper target.
|
||||
# Note: you do not have to bump the version number if nothing has changed.
|
||||
# Note2: If you do bump the version, the end user, must re-install the XPCHelper and give permission again.
|
||||
|
||||
|
|
@ -77,17 +146,20 @@ scripts/package_release.sh
|
|||
# Notarize the app
|
||||
# Do this from the Product directory so the app is zipped without being nested inside Product
|
||||
# Create a app specific password on appleid.apple.com if you haven't already
|
||||
# % xcrun altool --store-password-in-keychain-item "AC_PASSWORD" -u "<appleiduseremail>" -p <app_specific_secret>
|
||||
# xcrun notarytool store-credentials "AC_PASSWORD" \
|
||||
# --apple-id "test@example.com" \
|
||||
# --team-id "teamid" \
|
||||
# --password "app specific password"
|
||||
|
||||
pushd Product
|
||||
../scripts/notarize.sh "test@example.com" "@keychain:AC_PASSWORD" <MyOrg> Xcodes.zip
|
||||
../scripts/notarize.sh Xcodes.zip <MYORG>
|
||||
|
||||
# Sign the .zip for Sparkle, note the signature in the output for later
|
||||
# If you're warned about the signing key not being found, see the Xcodes 1Password vault for the key and installation instructions.
|
||||
../scripts/sign_update Xcodes.zip
|
||||
popd
|
||||
|
||||
# Go to https://github.com/RobotsAndPencils/XcodesApp/releases
|
||||
# Go to https://github.com/XcodesOrg/XcodesApp/releases
|
||||
# If there are uncategorized PRs, add the appropriate label and run the Release Drafter action manually
|
||||
# Edit the latest draft release
|
||||
# Set its tag to the tag you just pushed
|
||||
|
|
@ -98,14 +170,13 @@ popd
|
|||
# Attach the zip that was created in the Product directory to the release
|
||||
# Publish the release
|
||||
|
||||
# Update the [Homebrew Cask](https://github.com/RobotsAndPencils/homebrew-cask/blob/master/Casks/xcodes.rb).
|
||||
shasum -a 256 xcodes.zip
|
||||
# Update the [Homebrew Cask](https://github.com/XcodesOrg/homebrew-cask/blob/master/Casks/x/xcodes.rb).
|
||||
```
|
||||
</details>
|
||||
|
||||
## Contact
|
||||
## Maintainers
|
||||
|
||||
<a href="http://www.robotsandpencils.com"><img src="R&PLogo.png" width="153" height="74" /></a>
|
||||
[Matt Kiazyk](https://github.com/mattkiazyk) - [Twitter](https://www.twitter.com/mattkiazyk)
|
||||
|
||||
Made with ❤️ by [Robots & Pencils](http://www.robotsandpencils.com)
|
||||
|
||||
[Twitter](https://twitter.com/robotsNpencils) | [GitHub](https://github.com/robotsandpencils)
|
||||
[Twitter](https://twitter.com/xcodesApp) | [GitHub](https://github.com/xcodesOrg) | [Mastadon](https://iosdev.space/@XcodesApp) |
|
||||
|
|
|
|||
26
Scripts/fix_libfido2_framework.sh
Executable file
26
Scripts/fix_libfido2_framework.sh
Executable file
|
|
@ -0,0 +1,26 @@
|
|||
#!/bin/sh
|
||||
|
||||
# Fix libfido2.framework structure for macOS validation
|
||||
# If this script is not run, the build will fail because xcodebuild is expecting the library in a specific structure
|
||||
FRAMEWORK_PATH="${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Frameworks/libfido2.framework"
|
||||
|
||||
if [ -d "$FRAMEWORK_PATH" ] && [ -f "$FRAMEWORK_PATH/Info.plist" ] && [ ! -d "$FRAMEWORK_PATH/Versions" ]; then
|
||||
echo "Fixing libfido2.framework bundle structure..."
|
||||
|
||||
# Create proper bundle structure
|
||||
mkdir -p "$FRAMEWORK_PATH/Versions/A/Resources"
|
||||
|
||||
# Move files to proper locations
|
||||
mv "$FRAMEWORK_PATH/Info.plist" "$FRAMEWORK_PATH/Versions/A/Resources/"
|
||||
mv "$FRAMEWORK_PATH/libfido2" "$FRAMEWORK_PATH/Versions/A/"
|
||||
if [ -f "$FRAMEWORK_PATH/LICENSE" ]; then
|
||||
mv "$FRAMEWORK_PATH/LICENSE" "$FRAMEWORK_PATH/Versions/A/"
|
||||
fi
|
||||
|
||||
# Create symbolic links
|
||||
ln -sf A "$FRAMEWORK_PATH/Versions/Current"
|
||||
ln -sf Versions/Current/libfido2 "$FRAMEWORK_PATH/libfido2"
|
||||
ln -sf Versions/Current/Resources "$FRAMEWORK_PATH/Resources"
|
||||
|
||||
echo "libfido2.framework structure fixed"
|
||||
fi
|
||||
|
|
@ -17,46 +17,39 @@
|
|||
# https://developer.apple.com/documentation/xcode/notarizing_macos_software_before_distribution/customizing_the_notarization_workflow
|
||||
# Adapted from https://github.com/keybase/client/blob/46f5df0aa64ff19198ba7b044bbb7cd907c0be9f/packaging/desktop/package_darwin.sh
|
||||
|
||||
username="$1"
|
||||
password="$2"
|
||||
asc_provider="$3"
|
||||
file="$4"
|
||||
file="$1"
|
||||
team_id="$2"
|
||||
|
||||
echo "Uploading to notarization service"
|
||||
|
||||
uuid=$(xcrun altool \
|
||||
--notarize-app \
|
||||
--primary-bundle-id "com.robotsandpencils.XcodesApp.zip" \
|
||||
--username "$username" \
|
||||
--password "$password" \
|
||||
--asc-provider "$asc_provider" \
|
||||
--file "$file" 2>&1 | \
|
||||
grep 'RequestUUID' | \
|
||||
awk '{ print $3 }')
|
||||
result=$(xcrun notarytool submit "$file" \
|
||||
--keychain-profile "AC_PASSWORD" \
|
||||
--team-id "$team_id" \
|
||||
--wait)
|
||||
# echo "done1"
|
||||
echo $result
|
||||
|
||||
# My grep/awk is bad and I can't figure out how to get the UUID out properly
|
||||
# uuid=$("$result" | \
|
||||
# grep 'id:' | tail -n1 | \
|
||||
# cut -d":" -f2-)
|
||||
|
||||
echo "Successfully uploaded to notarization service, polling for result: $uuid"
|
||||
|
||||
sleep 15
|
||||
while :
|
||||
do
|
||||
fullstatus=$(xcrun altool \
|
||||
--notarization-info "$uuid" \
|
||||
--username "$username" \
|
||||
--password "$password" \
|
||||
--asc-provider "$asc_provider" 2>&1)
|
||||
status=$(echo "$fullstatus" | grep 'Status\:' | awk '{ print $2 }')
|
||||
if [ "$status" = "success" ]; then
|
||||
echo "Notarization success"
|
||||
exit 0
|
||||
elif [ "$status" = "in" ]; then
|
||||
echo "Notarization still in progress, sleeping for 15 seconds and trying again"
|
||||
sleep 15
|
||||
else
|
||||
echo "Notarization failed, full status below"
|
||||
echo "$fullstatus"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
# we should check here using the info (or notarytool log) to check the results and log
|
||||
#
|
||||
|
||||
# fullstatus=$(xcrun notarytool info "$uuid" \
|
||||
# --keychain-profile "AC_PASSWORD" 2>&1)
|
||||
# status=$(echo "$fullstatus" | grep 'status\:' | awk '{ print $2 }')
|
||||
# if [ "$status" = "Accepted" ]; then
|
||||
# echo "Notarization success"
|
||||
# exit 0
|
||||
# else
|
||||
# echo "Notarization failed, full status below"
|
||||
# echo "$fullstatus"
|
||||
# exit 1
|
||||
# fi
|
||||
|
||||
# Remove .zip
|
||||
rm $file
|
||||
|
|
@ -66,4 +59,4 @@ app_path="$(basename -s ".zip" "$file").app"
|
|||
xcrun stapler staple "$app_path"
|
||||
|
||||
# Zip the stapled app for distribution
|
||||
ditto -c -k --sequesterRsrc --keepParent "$file" "$app_path"
|
||||
ditto -c -k --sequesterRsrc --keepParent "$app_path" "$file"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ PROJECT_DIR=$(pwd)/$PROJECT_NAME/Resources
|
|||
SCRIPTS_DIR=$(pwd)/Scripts
|
||||
INFOPLIST_FILE="Info.plist"
|
||||
|
||||
# If needed ensure that the unxip binary is signed with a hardened runtime so we can notarize
|
||||
# codesign --force --options runtime --sign "Developer ID Application: Robots and Pencils Inc." $PROJECT_DIR/unxip
|
||||
|
||||
# Ensure a clean build
|
||||
rm -rf Archive/*
|
||||
rm -rf Product/*
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/bin/bash
|
||||
|
||||
PRIVILEGED_HELPER_LABEL=com.robotsandpencils.XcodesApp.Helper
|
||||
PRIVILEGED_HELPER_LABEL=com.xcodesorg.xcodesapp.Helper
|
||||
|
||||
sudo rm /Library/PrivilegedHelperTools/$PRIVILEGED_HELPER_LABEL
|
||||
sudo rm /Library/LaunchDaemons/$PRIVILEGED_HELPER_LABEL.plist
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,24 @@
|
|||
{
|
||||
"object": {
|
||||
"pins": [
|
||||
{
|
||||
"package": "AsyncNetworkService",
|
||||
"repositoryURL": "https://github.com/RobotsAndPencils/AsyncHTTPNetworkService",
|
||||
"state": {
|
||||
"branch": "main",
|
||||
"revision": "97770856c4e429f880d4b4dd68cfaf286dc00c30",
|
||||
"version": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "big-num",
|
||||
"repositoryURL": "https://github.com/adam-fowler/big-num",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "5c5511ad06aeb2b97d0868f7394e14a624bfb1c7",
|
||||
"version": "2.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "CombineExpectations",
|
||||
"repositoryURL": "https://github.com/groue/CombineExpectations",
|
||||
|
|
@ -11,12 +29,12 @@
|
|||
}
|
||||
},
|
||||
{
|
||||
"package": "XcodeReleases",
|
||||
"repositoryURL": "https://github.com/xcodereleases/data",
|
||||
"package": "DockProgress",
|
||||
"repositoryURL": "https://github.com/sindresorhus/DockProgress",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "b47228c688b608e34b3b84079ab6052a24c7a981",
|
||||
"version": null
|
||||
"revision": "d4f23b5a8f5ca0fac393eb7ba78c2fe3e32e52da",
|
||||
"version": "4.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -46,13 +64,22 @@
|
|||
"version": "1.0.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "LibFido2Swift",
|
||||
"repositoryURL": "https://github.com/kinoroy/LibFido2Swift",
|
||||
"state": {
|
||||
"branch": "main",
|
||||
"revision": "b87a93300c5b35307c9f26ae490963196bd927f1",
|
||||
"version": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Path.swift",
|
||||
"repositoryURL": "https://github.com/mxcl/Path.swift",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "dac007e907a4f4c565cfdc55a9ce148a761a11d5",
|
||||
"version": "0.16.3"
|
||||
"revision": "8e355c28e9393c42e58b18c54cace2c42c98a616",
|
||||
"version": "1.4.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -60,8 +87,35 @@
|
|||
"repositoryURL": "https://github.com/sparkle-project/Sparkle/",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "891afd44c7075e699924ed9b81d8dc94a5111dfd",
|
||||
"version": "1.24.0-spm"
|
||||
"revision": "0ef1ee0220239b3776f433314515fd849025673f",
|
||||
"version": "2.6.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "swift-collections",
|
||||
"repositoryURL": "https://github.com/apple/swift-collections.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "a902f1823a7ff3c9ab2fba0f992396b948eda307",
|
||||
"version": "1.0.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "swift-crypto",
|
||||
"repositoryURL": "https://github.com/apple/swift-crypto",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "ddb07e896a2a8af79512543b1c7eb9797f8898a5",
|
||||
"version": "1.1.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "swift-srp",
|
||||
"repositoryURL": "https://github.com/xcodesOrg/swift-srp",
|
||||
"state": {
|
||||
"branch": "main",
|
||||
"revision": "543aa0122a0257b992f6c7d62d18a26e3dffb8fe",
|
||||
"version": null
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
language = "en"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1500"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CA9FF8AD2595967A00E47BAF"
|
||||
BuildableName = "com.xcodesorg.xcodesapp.Helper"
|
||||
BlueprintName = "com.xcodesorg.xcodesapp.Helper"
|
||||
ReferencedContainer = "container:Xcodes.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</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"
|
||||
viewDebuggingEnabled = "No">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CA9FF8AD2595967A00E47BAF"
|
||||
BuildableName = "com.xcodesorg.xcodesapp.Helper"
|
||||
BlueprintName = "com.xcodesorg.xcodesapp.Helper"
|
||||
ReferencedContainer = "container:Xcodes.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CA9FF8AD2595967A00E47BAF"
|
||||
BuildableName = "com.xcodesorg.xcodesapp.Helper"
|
||||
BlueprintName = "com.xcodesorg.xcodesapp.Helper"
|
||||
ReferencedContainer = "container:Xcodes.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
7
Xcodes/AcknowledgementsGenerator/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
Xcodes/AcknowledgementsGenerator/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
// swift-tools-version:5.3
|
||||
// swift-tools-version:5.4
|
||||
|
||||
import PackageDescription
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ let package = Package(
|
|||
dependencies: [
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
.executableTarget(
|
||||
name: "AcknowledgementsGenerator",
|
||||
dependencies: []
|
||||
),
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ private extension Xcode {
|
|||
try? task.run()
|
||||
let handle = pipe.fileHandleForReading
|
||||
let data = handle.readDataToEndOfFile()
|
||||
let path = String(data: data, encoding: String.Encoding.utf8)
|
||||
let path = String(data: data, encoding: String.Encoding.utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return (path?.isEmpty ?? true) ? nil : path
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,26 @@
|
|||
// swift-tools-version:5.3
|
||||
// swift-tools-version:5.7
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "AppleAPI",
|
||||
platforms: [.macOS(.v10_15)],
|
||||
platforms: [.macOS(.v11)],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
.library(
|
||||
name: "AppleAPI",
|
||||
targets: ["AppleAPI"]),
|
||||
],
|
||||
dependencies: [],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/xcodesOrg/swift-srp", branch: "main")
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||
.target(
|
||||
name: "AppleAPI",
|
||||
dependencies: []),
|
||||
dependencies: [.product(name: "SRP", package: "swift-srp")]),
|
||||
.testTarget(
|
||||
name: "AppleAPITests",
|
||||
dependencies: ["AppleAPI"]),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import Foundation
|
||||
import Combine
|
||||
import SRP
|
||||
import Crypto
|
||||
import CommonCrypto
|
||||
|
||||
|
||||
public class Client {
|
||||
private static let authTypes = ["sa", "hsa", "non-sa", "hsa2"]
|
||||
|
|
@ -8,18 +12,66 @@ public class Client {
|
|||
|
||||
// MARK: - Login
|
||||
|
||||
public func login(accountName: String, password: String) -> AnyPublisher<AuthenticationState, Swift.Error> {
|
||||
public func srpLogin(accountName: String, password: String) -> AnyPublisher<AuthenticationState, Swift.Error> {
|
||||
var serviceKey: String!
|
||||
|
||||
let client = SRPClient(configuration: SRPConfiguration<SHA256>(.N2048))
|
||||
let clientKeys = client.generateKeys()
|
||||
let a = clientKeys.public
|
||||
|
||||
return Current.network.dataTask(with: URLRequest.itcServiceKey)
|
||||
.map(\.data)
|
||||
.decode(type: ServiceKeyResponse.self, decoder: JSONDecoder())
|
||||
.flatMap { serviceKeyResponse -> AnyPublisher<URLSession.DataTaskPublisher.Output, Swift.Error> in
|
||||
.flatMap { serviceKeyResponse -> AnyPublisher<(String, String), Swift.Error> in
|
||||
serviceKey = serviceKeyResponse.authServiceKey
|
||||
return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password))
|
||||
.mapError { $0 as Swift.Error }
|
||||
|
||||
// Fixes issue https://github.com/RobotsAndPencils/XcodesApp/issues/360
|
||||
// On 2023-02-23, Apple added a custom implementation of hashcash to their auth flow
|
||||
// Without this addition, Apple ID's would get set to locked
|
||||
return self.loadHashcash(accountName: accountName, serviceKey: serviceKey)
|
||||
.map { return (serviceKey, $0)}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.flatMap { (serviceKey, hashcash) -> AnyPublisher<(String, String, ServerSRPInitResponse), Swift.Error> in
|
||||
|
||||
return Current.network.dataTask(with: URLRequest.SRPInit(serviceKey: serviceKey, a: Data(a.bytes).base64EncodedString(), accountName: accountName))
|
||||
.map(\.data)
|
||||
.decode(type: ServerSRPInitResponse.self, decoder: JSONDecoder())
|
||||
.map { return (serviceKey, hashcash, $0) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.flatMap { (serviceKey, hashcash, srpInit) -> AnyPublisher<URLSession.DataTaskPublisher.Output, Swift.Error> in
|
||||
guard let decodedB = Data(base64Encoded: srpInit.b) else {
|
||||
return Fail(error: AuthenticationError.srpInvalidPublicKey)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
guard let decodedSalt = Data(base64Encoded: srpInit.salt) else {
|
||||
return Fail(error: AuthenticationError.srpInvalidPublicKey)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let iterations = srpInit.iteration
|
||||
|
||||
do {
|
||||
guard let encryptedPassword = self.pbkdf2(password: password, saltData: decodedSalt, keyByteCount: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), rounds: iterations, protocol: srpInit.protocol) else {
|
||||
return Fail(error: AuthenticationError.srpInvalidPublicKey)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let sharedSecret = try client.calculateSharedSecret(password: encryptedPassword, salt: [UInt8](decodedSalt), clientKeys: clientKeys, serverPublicKey: .init([UInt8](decodedB)))
|
||||
|
||||
let m1 = client.calculateClientProof(username: accountName, salt: [UInt8](decodedSalt), clientPublicKey: a, serverPublicKey: .init([UInt8](decodedB)), sharedSecret: .init(sharedSecret.bytes))
|
||||
let m2 = client.calculateServerProof(clientPublicKey: a, clientProof: m1, sharedSecret: .init([UInt8](sharedSecret.bytes)))
|
||||
|
||||
return Current.network.dataTask(with: URLRequest.SRPComplete(serviceKey: serviceKey, hashcash: hashcash, accountName: accountName, c: srpInit.c, m1: Data(m1).base64EncodedString(), m2: Data(m2).base64EncodedString()))
|
||||
.mapError { $0 as Swift.Error }
|
||||
.eraseToAnyPublisher()
|
||||
} catch {
|
||||
return Fail(error: AuthenticationError.srpInvalidPublicKey)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
.flatMap { result -> AnyPublisher<AuthenticationState, Swift.Error> in
|
||||
let (data, response) = result
|
||||
return Just(data)
|
||||
|
|
@ -56,6 +108,44 @@ public class Client {
|
|||
.mapError { $0 as Swift.Error }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func loadHashcash(accountName: String, serviceKey: String) -> AnyPublisher<String, Swift.Error> {
|
||||
|
||||
Result {
|
||||
try URLRequest.federate(account: accountName, serviceKey: serviceKey)
|
||||
}
|
||||
.publisher
|
||||
.flatMap { request in
|
||||
Current.network.dataTask(with: request)
|
||||
.mapError { $0 as Error }
|
||||
.tryMap { (data, response) throws -> (String) in
|
||||
guard let urlResponse = response as? HTTPURLResponse else {
|
||||
throw AuthenticationError.invalidSession
|
||||
}
|
||||
switch urlResponse.statusCode {
|
||||
case 200..<300:
|
||||
|
||||
let httpResponse = response as! HTTPURLResponse
|
||||
guard let bitsString = httpResponse.allHeaderFields["X-Apple-HC-Bits"] as? String, let bits = UInt(bitsString) else {
|
||||
throw AuthenticationError.invalidHashcash
|
||||
}
|
||||
guard let challenge = httpResponse.allHeaderFields["X-Apple-HC-Challenge"] as? String else {
|
||||
throw AuthenticationError.invalidHashcash
|
||||
}
|
||||
guard let hashcash = Hashcash().mint(resource: challenge, bits: bits) else {
|
||||
throw AuthenticationError.invalidHashcash
|
||||
}
|
||||
return (hashcash)
|
||||
case 400, 401:
|
||||
throw AuthenticationError.invalidHashcash
|
||||
case let code:
|
||||
throw AuthenticationError.badStatusCode(statusCode: code, data: data, response: urlResponse)
|
||||
}
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
}
|
||||
|
||||
func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> AnyPublisher<AuthenticationState, Swift.Error> {
|
||||
let httpResponse = response as! HTTPURLResponse
|
||||
|
|
@ -70,7 +160,7 @@ public class Client {
|
|||
case .twoStep:
|
||||
return Fail(error: AuthenticationError.accountUsesTwoStepAuthentication)
|
||||
.eraseToAnyPublisher()
|
||||
case .twoFactor:
|
||||
case .twoFactor, .securityKey:
|
||||
return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions)
|
||||
.eraseToAnyPublisher()
|
||||
case .unknown:
|
||||
|
|
@ -91,7 +181,10 @@ public class Client {
|
|||
// SMS wasn't sent automatically because user needs to choose a phone to send to
|
||||
} else if authOptions.canFallBackToSMS {
|
||||
option = .smsPendingChoice
|
||||
// Code is shown on trusted devices
|
||||
// Code is shown on trusted devices
|
||||
} else if authOptions.fsaChallenge != nil {
|
||||
option = .securityKey
|
||||
// User needs to use a physical security key to respond to the challenge
|
||||
} else {
|
||||
option = .codeSent
|
||||
}
|
||||
|
|
@ -132,6 +225,8 @@ public class Client {
|
|||
return (data, urlResponse)
|
||||
case 400, 401:
|
||||
throw AuthenticationError.incorrectSecurityCode
|
||||
case 412:
|
||||
throw AuthenticationError.appleIDAndPrivacyAcknowledgementRequired
|
||||
case let code:
|
||||
throw AuthenticationError.badStatusCode(statusCode: code, data: data, response: urlResponse)
|
||||
}
|
||||
|
|
@ -143,17 +238,54 @@ public class Client {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public func submitChallenge(response: Data, sessionData: AppleSessionData) -> AnyPublisher<AuthenticationState, Error> {
|
||||
Result {
|
||||
URLRequest.respondToChallenge(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt, response: response)
|
||||
}
|
||||
.publisher
|
||||
.flatMap { request in
|
||||
Current.network.dataTask(with: request)
|
||||
.mapError { $0 as Error }
|
||||
.tryMap { (data, response) throws -> (Data, URLResponse) in
|
||||
guard let urlResponse = response as? HTTPURLResponse else { return (data, response) }
|
||||
switch urlResponse.statusCode {
|
||||
case 200..<300:
|
||||
return (data, urlResponse)
|
||||
case 400, 401:
|
||||
throw AuthenticationError.incorrectSecurityCode
|
||||
case 412:
|
||||
throw AuthenticationError.appleIDAndPrivacyAcknowledgementRequired
|
||||
case let code:
|
||||
throw AuthenticationError.badStatusCode(statusCode: code, data: data, response: urlResponse)
|
||||
}
|
||||
}
|
||||
.flatMap { (data, response) -> AnyPublisher<AuthenticationState, Error> in
|
||||
self.updateSession(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt)
|
||||
}
|
||||
}.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// MARK: - Session
|
||||
|
||||
/// Use the olympus session endpoint to see if the existing session is still valid
|
||||
public func validateSession() -> AnyPublisher<Void, Error> {
|
||||
return Current.network.dataTask(with: URLRequest.olympusSession)
|
||||
.map(\.data)
|
||||
.tryMap { result -> Data in
|
||||
let httpResponse = result.response as! HTTPURLResponse
|
||||
if httpResponse.statusCode == 401 {
|
||||
throw AuthenticationError.notAuthorized
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
.decode(type: AppleSession.self, decoder: JSONDecoder())
|
||||
.tryMap { session in
|
||||
if session.provider == nil {
|
||||
throw AuthenticationError.notDeveloperAppleId
|
||||
}
|
||||
// A user that is a non-paid Apple Developer will have a provider == nil
|
||||
// Those users can still download Xcode.
|
||||
// Non Apple Developers will get caught in the download as invalid
|
||||
// if session.provider == nil {
|
||||
// throw AuthenticationError.notDeveloperAppleId
|
||||
// }
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
|
@ -167,6 +299,49 @@ public class Client {
|
|||
.mapError { $0 as Error }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func sha256(data : Data) -> Data {
|
||||
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
||||
data.withUnsafeBytes {
|
||||
_ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash)
|
||||
}
|
||||
return Data(hash)
|
||||
}
|
||||
|
||||
private func pbkdf2(password: String, saltData: Data, keyByteCount: Int, prf: CCPseudoRandomAlgorithm, rounds: Int, protocol srpProtocol: SRPProtocol) -> Data? {
|
||||
guard let passwordData = password.data(using: .utf8) else { return nil }
|
||||
let hashedPasswordDataRaw = sha256(data: passwordData)
|
||||
let hashedPasswordData = switch srpProtocol {
|
||||
case .s2k: hashedPasswordDataRaw
|
||||
// the legacy s2k_fo protocol requires hex-encoding the digest before performing PBKDF2.
|
||||
case .s2k_fo: Data(hashedPasswordDataRaw.hexEncodedString().lowercased().utf8)
|
||||
}
|
||||
|
||||
var derivedKeyData = Data(repeating: 0, count: keyByteCount)
|
||||
let derivedCount = derivedKeyData.count
|
||||
let derivationStatus: Int32 = derivedKeyData.withUnsafeMutableBytes { derivedKeyBytes in
|
||||
let keyBuffer: UnsafeMutablePointer<UInt8> =
|
||||
derivedKeyBytes.baseAddress!.assumingMemoryBound(to: UInt8.self)
|
||||
return saltData.withUnsafeBytes { saltBytes -> Int32 in
|
||||
let saltBuffer: UnsafePointer<UInt8> = saltBytes.baseAddress!.assumingMemoryBound(to: UInt8.self)
|
||||
return hashedPasswordData.withUnsafeBytes { hashedPasswordBytes -> Int32 in
|
||||
let passwordBuffer: UnsafePointer<UInt8> = hashedPasswordBytes.baseAddress!.assumingMemoryBound(to: UInt8.self)
|
||||
return CCKeyDerivationPBKDF(
|
||||
CCPBKDFAlgorithm(kCCPBKDF2),
|
||||
passwordBuffer,
|
||||
hashedPasswordData.count,
|
||||
saltBuffer,
|
||||
saltData.count,
|
||||
prf,
|
||||
UInt32(rounds),
|
||||
keyBuffer,
|
||||
derivedCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
return derivationStatus == kCCSuccess ? derivedKeyData : nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
|
@ -180,6 +355,7 @@ public enum AuthenticationState: Equatable {
|
|||
|
||||
public enum AuthenticationError: Swift.Error, LocalizedError, Equatable {
|
||||
case invalidSession
|
||||
case invalidHashcash
|
||||
case invalidUsernameOrPassword(username: String)
|
||||
case incorrectSecurityCode
|
||||
case unexpectedSignInResponse(statusCode: Int, message: String?)
|
||||
|
|
@ -189,11 +365,16 @@ public enum AuthenticationError: Swift.Error, LocalizedError, Equatable {
|
|||
case accountLocked(String)
|
||||
case badStatusCode(statusCode: Int, data: Data, response: HTTPURLResponse)
|
||||
case notDeveloperAppleId
|
||||
case notAuthorized
|
||||
case invalidResult(resultString: String?)
|
||||
case srpInvalidPublicKey
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidSession:
|
||||
return "Your authentication session is invalid. Try signing in again."
|
||||
case .invalidHashcash:
|
||||
return "Could not create a hashcash for the session."
|
||||
case .invalidUsernameOrPassword:
|
||||
return "Invalid username and password combination."
|
||||
case .incorrectSecurityCode:
|
||||
|
|
@ -217,6 +398,12 @@ public enum AuthenticationError: Swift.Error, LocalizedError, Equatable {
|
|||
return "Received an unexpected status code: \(statusCode). If you continue to have problems, please submit a bug report in the Help menu."
|
||||
case .notDeveloperAppleId:
|
||||
return "You are not registered as an Apple Developer. Please visit Apple Developer Registration. https://developer.apple.com/register/"
|
||||
case .notAuthorized:
|
||||
return "You are not authorized. Please Sign in with your Apple ID first."
|
||||
case let .invalidResult(resultString):
|
||||
return resultString ?? "If you continue to have problems, please submit a bug report in the Help menu."
|
||||
case .srpInvalidPublicKey:
|
||||
return "Invalid Key"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -257,27 +444,37 @@ public enum TwoFactorOption: Equatable {
|
|||
case smsSent(AuthOptionsResponse.TrustedPhoneNumber)
|
||||
case codeSent
|
||||
case smsPendingChoice
|
||||
case securityKey
|
||||
}
|
||||
|
||||
public struct FSAChallenge: Equatable, Decodable {
|
||||
public let challenge: String
|
||||
public let keyHandles: [String]
|
||||
public let allowedCredentials: String
|
||||
}
|
||||
|
||||
public struct AuthOptionsResponse: Equatable, Decodable {
|
||||
public let trustedPhoneNumbers: [TrustedPhoneNumber]?
|
||||
public let trustedDevices: [TrustedDevice]?
|
||||
public let securityCode: SecurityCodeInfo
|
||||
public let securityCode: SecurityCodeInfo?
|
||||
public let noTrustedDevices: Bool?
|
||||
public let serviceErrors: [ServiceError]?
|
||||
public let fsaChallenge: FSAChallenge?
|
||||
|
||||
public init(
|
||||
trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]?,
|
||||
trustedDevices: [AuthOptionsResponse.TrustedDevice]?,
|
||||
securityCode: AuthOptionsResponse.SecurityCodeInfo,
|
||||
noTrustedDevices: Bool? = nil,
|
||||
serviceErrors: [ServiceError]? = nil
|
||||
serviceErrors: [ServiceError]? = nil,
|
||||
fsaChallenge: FSAChallenge? = nil
|
||||
) {
|
||||
self.trustedPhoneNumbers = trustedPhoneNumbers
|
||||
self.trustedDevices = trustedDevices
|
||||
self.securityCode = securityCode
|
||||
self.noTrustedDevices = noTrustedDevices
|
||||
self.serviceErrors = serviceErrors
|
||||
self.fsaChallenge = fsaChallenge
|
||||
}
|
||||
|
||||
public var kind: Kind {
|
||||
|
|
@ -285,6 +482,8 @@ public struct AuthOptionsResponse: Equatable, Decodable {
|
|||
return .twoStep
|
||||
} else if trustedPhoneNumbers != nil {
|
||||
return .twoFactor
|
||||
} else if fsaChallenge != nil {
|
||||
return .securityKey
|
||||
} else {
|
||||
return .unknown
|
||||
}
|
||||
|
|
@ -347,7 +546,7 @@ public struct AuthOptionsResponse: Equatable, Decodable {
|
|||
}
|
||||
|
||||
public enum Kind: Equatable {
|
||||
case twoStep, twoFactor, unknown
|
||||
case twoStep, twoFactor, securityKey, unknown
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -384,3 +583,24 @@ public struct AppleProvider: Decodable, Equatable {
|
|||
public struct AppleUser: Decodable, Equatable {
|
||||
public let fullName: String
|
||||
}
|
||||
|
||||
public struct ServerSRPInitResponse: Decodable {
|
||||
let iteration: Int
|
||||
let salt: String
|
||||
let b: String
|
||||
let c: String
|
||||
let `protocol`: SRPProtocol
|
||||
}
|
||||
|
||||
|
||||
|
||||
extension String {
|
||||
func base64ToU8Array() -> Data {
|
||||
return Data(base64Encoded: self) ?? Data()
|
||||
}
|
||||
}
|
||||
extension Data {
|
||||
func hexEncodedString() -> String {
|
||||
return map { String(format: "%02hhx", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
96
Xcodes/AppleAPI/Sources/AppleAPI/Hashcash.swift
Normal file
96
Xcodes/AppleAPI/Sources/AppleAPI/Hashcash.swift
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
//
|
||||
// Hashcash.swift
|
||||
//
|
||||
//
|
||||
// Created by Matt Kiazyk on 2023-02-23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import CommonCrypto
|
||||
|
||||
/*
|
||||
# This App Store Connect hashcash spec was generously donated by...
|
||||
#
|
||||
# __ _
|
||||
# __ _ _ __ _ __ / _|(_) __ _ _ _ _ __ ___ ___
|
||||
# / _` || '_ \ | '_ \ | |_ | | / _` || | | || '__|/ _ \/ __|
|
||||
# | (_| || |_) || |_) || _|| || (_| || |_| || | | __/\__ \
|
||||
# \__,_|| .__/ | .__/ |_| |_| \__, | \__,_||_| \___||___/
|
||||
# |_| |_| |___/
|
||||
#
|
||||
#
|
||||
*/
|
||||
public struct Hashcash {
|
||||
/// A function to returned a minted hash, using a bit and resource string
|
||||
///
|
||||
/**
|
||||
X-APPLE-HC: 1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373
|
||||
^ ^ ^ ^ ^
|
||||
| | | | +-- Counter
|
||||
| | | +-- Resource
|
||||
| | +-- Date YYMMDD[hhmm[ss]]
|
||||
| +-- Bits (number of leading zeros)
|
||||
+-- Version
|
||||
|
||||
We can't use an off-the-shelf Hashcash because Apple's implementation is not quite the same as the spec/convention.
|
||||
1. The spec calls for a nonce called "Rand" to be inserted between the Ext and Counter. They don't do that at all.
|
||||
2. The Counter conventionally encoded as base-64 but Apple just uses the decimal number's string representation.
|
||||
|
||||
Iterate from Counter=0 to Counter=N finding an N that makes the SHA1(X-APPLE-HC) lead with Bits leading zero bits
|
||||
We get the "Resource" from the X-Apple-HC-Challenge header and Bits from X-Apple-HC-Bits
|
||||
*/
|
||||
/// - Parameters:
|
||||
/// - resource: a string to be used for minting
|
||||
/// - bits: grabbed from `X-Apple-HC-Bits` header
|
||||
/// - date: Default uses Date() otherwise used for testing to check.
|
||||
/// - Returns: A String hash to use in `X-Apple-HC` header on /signin
|
||||
public func mint(resource: String,
|
||||
bits: UInt = 10,
|
||||
date: String? = nil) -> String? {
|
||||
|
||||
let ver = "1"
|
||||
|
||||
var ts: String
|
||||
if let date = date {
|
||||
ts = date
|
||||
} else {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyMMddHHmmss"
|
||||
ts = formatter.string(from: Date())
|
||||
}
|
||||
|
||||
let challenge = "\(ver):\(bits):\(ts):\(resource):"
|
||||
|
||||
var counter = 0
|
||||
|
||||
while true {
|
||||
guard let digest = ("\(challenge):\(counter)").sha1 else {
|
||||
print("ERROR: Can't generate SHA1 digest")
|
||||
return nil
|
||||
}
|
||||
|
||||
if digest == bits {
|
||||
return "\(challenge):\(counter)"
|
||||
}
|
||||
counter += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
var sha1: Int? {
|
||||
|
||||
let data = Data(self.utf8)
|
||||
var digest = [UInt8](repeating: 0, count:Int(CC_SHA1_DIGEST_LENGTH))
|
||||
data.withUnsafeBytes {
|
||||
_ = CC_SHA1($0.baseAddress, CC_LONG(data.count), &digest)
|
||||
}
|
||||
let bigEndianValue = digest.withUnsafeBufferPointer {
|
||||
($0.baseAddress!.withMemoryRebound(to: UInt32.self, capacity: 1) { $0 })
|
||||
}.pointee
|
||||
let value = UInt32(bigEndian: bigEndianValue)
|
||||
return value.leadingZeroBitCount
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -7,7 +7,13 @@ public extension URL {
|
|||
static let requestSecurityCode = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/phone")!
|
||||
static func submitSecurityCode(_ code: SecurityCode) -> URL { URL(string: "https://idmsa.apple.com/appleauth/auth/verify/\(code.urlPathComponent)/securitycode")! }
|
||||
static let trust = URL(string: "https://idmsa.apple.com/appleauth/auth/2sv/trust")!
|
||||
static let federate = URL(string: "https://idmsa.apple.com/appleauth/auth/federate")!
|
||||
static let olympusSession = URL(string: "https://appstoreconnect.apple.com/olympus/v1/session")!
|
||||
static let keyAuth = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/security/key")!
|
||||
|
||||
static let srpInit = URL(string: "https://idmsa.apple.com/appleauth/auth/signin/init")!
|
||||
static let srpComplete = URL(string: "https://idmsa.apple.com/appleauth/auth/signin/complete?isRememberMeEnabled=false")!
|
||||
|
||||
}
|
||||
|
||||
public extension URLRequest {
|
||||
|
|
@ -15,7 +21,7 @@ public extension URLRequest {
|
|||
return URLRequest(url: .itcServiceKey)
|
||||
}
|
||||
|
||||
static func signIn(serviceKey: String, accountName: String, password: String) -> URLRequest {
|
||||
static func signIn(serviceKey: String, accountName: String, password: String, hashcash: String) -> URLRequest {
|
||||
struct Body: Encodable {
|
||||
let accountName: String
|
||||
let password: String
|
||||
|
|
@ -27,6 +33,7 @@ public extension URLRequest {
|
|||
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
|
||||
request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest"
|
||||
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
|
||||
request.allHTTPHeaderFields?["X-Apple-HC"] = hashcash
|
||||
request.allHTTPHeaderFields?["Accept"] = "application/json, text/javascript"
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = try! JSONEncoder().encode(Body(accountName: accountName, password: password))
|
||||
|
|
@ -103,6 +110,19 @@ public extension URLRequest {
|
|||
}
|
||||
return request
|
||||
}
|
||||
|
||||
static func respondToChallenge(serviceKey: String, sessionID: String, scnt: String, response: Data) -> URLRequest {
|
||||
var request = URLRequest(url: .keyAuth)
|
||||
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
|
||||
request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID
|
||||
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
|
||||
request.allHTTPHeaderFields?["scnt"] = scnt
|
||||
request.allHTTPHeaderFields?["Accept"] = "application/json"
|
||||
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = response
|
||||
return request
|
||||
}
|
||||
|
||||
static func trust(serviceKey: String, sessionID: String, scnt: String) -> URLRequest {
|
||||
var request = URLRequest(url: .trust)
|
||||
|
|
@ -117,4 +137,68 @@ public extension URLRequest {
|
|||
static var olympusSession: URLRequest {
|
||||
return URLRequest(url: .olympusSession)
|
||||
}
|
||||
|
||||
static func federate(account: String, serviceKey: String) throws -> URLRequest {
|
||||
struct FederateRequest: Encodable {
|
||||
let accountName: String
|
||||
let rememberMe: Bool
|
||||
}
|
||||
var request = URLRequest(url: .signIn)
|
||||
request.allHTTPHeaderFields?["Accept"] = "application/json"
|
||||
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
|
||||
request.httpMethod = "GET"
|
||||
|
||||
// let encoder = JSONEncoder()
|
||||
// encoder.outputFormatting = .withoutEscapingSlashes
|
||||
// request.httpBody = try encoder.encode(FederateRequest(accountName: account, rememberMe: true))
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
static func SRPInit(serviceKey: String, a: String, accountName: String) -> URLRequest {
|
||||
struct ServerSRPInitRequest: Encodable {
|
||||
public let a: String
|
||||
public let accountName: String
|
||||
public let protocols: [SRPProtocol]
|
||||
}
|
||||
|
||||
var request = URLRequest(url: .srpInit)
|
||||
request.httpMethod = "POST"
|
||||
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
|
||||
request.allHTTPHeaderFields?["Accept"] = "application/json"
|
||||
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
|
||||
request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest"
|
||||
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
|
||||
|
||||
request.httpBody = try? JSONEncoder().encode(ServerSRPInitRequest(a: a, accountName: accountName, protocols: [.s2k, .s2k_fo]))
|
||||
return request
|
||||
}
|
||||
|
||||
static func SRPComplete(serviceKey: String, hashcash: String, accountName: String, c: String, m1: String, m2: String) -> URLRequest {
|
||||
struct ServerSRPCompleteRequest: Encodable {
|
||||
let accountName: String
|
||||
let c: String
|
||||
let m1: String
|
||||
let m2: String
|
||||
let rememberMe: Bool
|
||||
}
|
||||
|
||||
var request = URLRequest(url: .srpComplete)
|
||||
request.httpMethod = "POST"
|
||||
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
|
||||
request.allHTTPHeaderFields?["Accept"] = "application/json"
|
||||
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
|
||||
request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest"
|
||||
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
|
||||
request.allHTTPHeaderFields?["X-Apple-HC"] = hashcash
|
||||
|
||||
request.httpBody = try? JSONEncoder().encode(ServerSRPCompleteRequest(accountName: accountName, c: c, m1: m1, m2: m2, rememberMe: false))
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
public enum SRPProtocol: String, Codable {
|
||||
case s2k, s2k_fo
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,14 +2,26 @@ import XCTest
|
|||
@testable import AppleAPI
|
||||
|
||||
final class AppleAPITests: XCTestCase {
|
||||
func testExample() {
|
||||
// This is an example of a functional test case.
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct
|
||||
// results.
|
||||
XCTAssertEqual(AppleAPI().text, "Hello, World!")
|
||||
|
||||
func testValidHashCashMint() {
|
||||
let bits: UInt = 11
|
||||
let resource = "4d74fb15eb23f465f1f6fcbf534e5877"
|
||||
let testDate = "20230223170600"
|
||||
|
||||
let stamp = Hashcash().mint(resource: resource, bits: bits, date: testDate)
|
||||
XCTAssertEqual(stamp, "1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373")
|
||||
}
|
||||
func testValidHashCashMint2() {
|
||||
let bits: UInt = 10
|
||||
let resource = "bb63edf88d2f9c39f23eb4d6f0281158"
|
||||
let testDate = "20230224001754"
|
||||
|
||||
let stamp = Hashcash().mint(resource: resource, bits: bits, date: testDate)
|
||||
XCTAssertEqual(stamp, "1:10:20230224001754:bb63edf88d2f9c39f23eb4d6f0281158::866")
|
||||
}
|
||||
|
||||
static var allTests = [
|
||||
("testExample", testExample),
|
||||
("testValidHashCashMint", testValidHashCashMint),
|
||||
("testValidHashCashMint2", testValidHashCashMint2),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,14 +5,16 @@ import AppleAPI
|
|||
import Version
|
||||
import LegibleError
|
||||
import os.log
|
||||
import DockProgress
|
||||
import XcodesKit
|
||||
|
||||
/// Downloads and installs Xcodes
|
||||
extension AppState {
|
||||
|
||||
// check to see if we should auto install for the user
|
||||
public func autoInstallIfNeeded() {
|
||||
guard let storageValue = UserDefaults.standard.object(forKey: "autoInstallation") as? Int, let autoInstallType = AutoInstallationType(rawValue: storageValue) else { return }
|
||||
|
||||
guard let storageValue = Current.defaults.get(forKey: "autoInstallation") as? Int, let autoInstallType = AutoInstallationType(rawValue: storageValue) else { return }
|
||||
|
||||
if autoInstallType == .none { return }
|
||||
|
||||
// get newest xcode version
|
||||
|
|
@ -24,10 +26,10 @@ extension AppState {
|
|||
if autoInstallType == .newestBeta {
|
||||
Logger.appState.info("Auto installing newest Xcode Beta")
|
||||
// install it, as user doesn't have it installed and it's either latest beta or latest release
|
||||
install(id: newestXcode.id)
|
||||
checkMinVersionAndInstall(id: newestXcode.id)
|
||||
} else if autoInstallType == .newestVersion && newestXcode.version.isNotPrerelease {
|
||||
Logger.appState.info("Auto installing newest Xcode")
|
||||
install(id: newestXcode.id)
|
||||
checkMinVersionAndInstall(id: newestXcode.id)
|
||||
} else {
|
||||
Logger.appState.info("No new Xcodes version found to auto install")
|
||||
}
|
||||
|
|
@ -40,11 +42,11 @@ extension AppState {
|
|||
}
|
||||
|
||||
private func install(_ installationType: InstallationType, downloader: Downloader, attemptNumber: Int) -> AnyPublisher<InstalledXcode, Error> {
|
||||
// We need to check if the Apple ID that is logged in is an Apple Developer
|
||||
// Since users can use xcodereleases, we don't check for Apple ID on a xcode list refresh
|
||||
// If the Apple Id is not a developer, the download action will try and download a xip that is invalid, causing a `xcode13.0.xip is damaged and can't be expanded.`
|
||||
|
||||
Logger.appState.info("Using \(downloader) downloader")
|
||||
|
||||
setupDockProgress()
|
||||
|
||||
return validateSession()
|
||||
.flatMap { _ in
|
||||
self.getXcodeArchive(installationType, downloader: downloader)
|
||||
|
|
@ -53,6 +55,8 @@ extension AppState {
|
|||
self.installArchivedXcode(xcode, at: url)
|
||||
}
|
||||
.catch { error -> AnyPublisher<InstalledXcode, Swift.Error> in
|
||||
self.resetDockProgressTracking()
|
||||
|
||||
switch error {
|
||||
case InstallationError.damagedXIP(let damagedXIPURL):
|
||||
guard attemptNumber < 1 else { return Fail(error: error).eraseToAnyPublisher() }
|
||||
|
|
@ -88,22 +92,24 @@ extension AppState {
|
|||
private func getXcodeArchive(_ installationType: InstallationType, downloader: Downloader) -> AnyPublisher<(AvailableXcode, URL), Error> {
|
||||
switch installationType {
|
||||
case .version(let availableXcode):
|
||||
if let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version.isEquivalent(to: availableXcode.version) }) {
|
||||
if let installedXcode = Current.files.installedXcodes(Path.installDirectory).first(where: { $0.version.isEquivalent(to: availableXcode.version) }) {
|
||||
return Fail(error: InstallationError.versionAlreadyInstalled(installedXcode))
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return downloadXcode(availableXcode: availableXcode, downloader: downloader)
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadXcode(availableXcode: AvailableXcode, downloader: Downloader) -> AnyPublisher<(AvailableXcode, URL), Error> {
|
||||
downloadOrUseExistingArchive(for: availableXcode, downloader: downloader, progressChanged: { [unowned self] progress in
|
||||
DispatchQueue.main.async {
|
||||
self.setInstallationStep(of: availableXcode.version, to: .downloading(progress: progress))
|
||||
}
|
||||
})
|
||||
.map { return (availableXcode, $0) }
|
||||
.eraseToAnyPublisher()
|
||||
self.downloadOrUseExistingArchive(for: availableXcode, downloader: downloader, progressChanged: { [unowned self] progress in
|
||||
DispatchQueue.main.async {
|
||||
self.setInstallationStep(of: availableXcode.version, to: .downloading(progress: progress))
|
||||
self.overallProgress.addChild(progress, withPendingUnitCount: AppState.totalProgressUnits - AppState.unxipProgressWeight)
|
||||
}
|
||||
})
|
||||
.map { return (availableXcode, $0) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public func downloadOrUseExistingArchive(for availableXcode: AvailableXcode, downloader: Downloader, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher<URL, Error> {
|
||||
|
|
@ -152,6 +158,7 @@ extension AppState {
|
|||
cookies
|
||||
)
|
||||
progressChanged(progress)
|
||||
|
||||
return publisher
|
||||
.map { _ in destination.url }
|
||||
.eraseToAnyPublisher()
|
||||
|
|
@ -166,6 +173,7 @@ extension AppState {
|
|||
to: destination.url,
|
||||
resumingWith: resumeData ?? persistedResumeData)
|
||||
progressChanged(progress)
|
||||
|
||||
return publisher
|
||||
.map { $0.saveLocation }
|
||||
.eraseToAnyPublisher()
|
||||
|
|
@ -177,8 +185,11 @@ extension AppState {
|
|||
}
|
||||
|
||||
public func installArchivedXcode(_ availableXcode: AvailableXcode, at archiveURL: URL) -> AnyPublisher<InstalledXcode, Error> {
|
||||
unxipProgress.completedUnitCount = 0
|
||||
overallProgress.addChild(unxipProgress, withPendingUnitCount: AppState.unxipProgressWeight)
|
||||
|
||||
do {
|
||||
let destinationURL = Path.root.join("Applications").join("Xcode-\(availableXcode.version.descriptionWithoutBuildMetadata).app").url
|
||||
let destinationURL = Path.installDirectory.join("Xcode-\(availableXcode.version.descriptionWithoutBuildMetadata).app").url
|
||||
switch archiveURL.pathExtension {
|
||||
case "xip":
|
||||
return unarchiveAndMoveXIP(availableXcode: availableXcode, at: archiveURL, to: destinationURL)
|
||||
|
|
@ -214,8 +225,9 @@ extension AppState {
|
|||
.handleEvents(receiveCompletion: { [unowned self] completion in
|
||||
if case let .failure(error) = completion {
|
||||
self.error = error
|
||||
self.presentedAlert = .generic(title: "Unable to install archived Xcode", message: error.legibleLocalizedDescription)
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.InstallArchive.Error.Title"), message: error.legibleLocalizedDescription)
|
||||
}
|
||||
resetDockProgressTracking()
|
||||
})
|
||||
.catch { _ in
|
||||
Just(installedXcode)
|
||||
|
|
@ -238,8 +250,8 @@ extension AppState {
|
|||
|
||||
func unarchiveAndMoveXIP(availableXcode: AvailableXcode, at source: URL, to destination: URL) -> AnyPublisher<URL, Swift.Error> {
|
||||
self.setInstallationStep(of: availableXcode.version, to: .unarchiving)
|
||||
|
||||
return Current.shell.unxip(source)
|
||||
|
||||
return unxipOrUnxipExperiment(source)
|
||||
.catch { error -> AnyPublisher<ProcessOutput, Swift.Error> in
|
||||
if let executionError = error as? ProcessExecutionError {
|
||||
if executionError.standardError.contains("damaged and can’t be expanded") {
|
||||
|
|
@ -278,6 +290,16 @@ extension AppState {
|
|||
})
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func unxipOrUnxipExperiment(_ source: URL) -> AnyPublisher<ProcessOutput, Error> {
|
||||
if unxipExperiment {
|
||||
// All hard work done by https://github.com/saagarjha/unxip
|
||||
// Compiled to binary with `swiftc -parse-as-library -O unxip.swift`
|
||||
return Current.shell.unxipExperiment(source)
|
||||
} else {
|
||||
return Current.shell.unxip(source)
|
||||
}
|
||||
}
|
||||
|
||||
public func verifySecurityAssessment(of xcode: InstalledXcode) -> AnyPublisher<Void, Error> {
|
||||
return Current.shell.spctlAssess(xcode.path.url)
|
||||
|
|
@ -351,7 +373,7 @@ extension AppState {
|
|||
receiveCompletion: { completion in
|
||||
if case let .failure(error) = completion {
|
||||
self.error = error
|
||||
self.presentedAlert = .generic(title: "Unable to perform post install steps", message: error.legibleLocalizedDescription)
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.PostInstall.Title"), message: error.legibleLocalizedDescription)
|
||||
}
|
||||
},
|
||||
receiveValue: {}
|
||||
|
|
@ -406,6 +428,9 @@ extension AppState {
|
|||
}
|
||||
self.presentedAlert = .privilegedHelper
|
||||
}
|
||||
|
||||
unxipProgress.completedUnitCount = AppState.totalProgressUnits
|
||||
resetDockProgressTracking()
|
||||
|
||||
return helperInstallConsentSubject
|
||||
.flatMap {
|
||||
|
|
@ -446,15 +471,48 @@ extension AppState {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// MARK: - Dock Progress Tracking
|
||||
|
||||
private func setupDockProgress() {
|
||||
Task { @MainActor in
|
||||
DockProgress.progressInstance = nil
|
||||
DockProgress.style = .bar
|
||||
|
||||
let progress = Progress(totalUnitCount: AppState.totalProgressUnits)
|
||||
progress.kind = .file
|
||||
progress.fileOperationKind = .downloading
|
||||
overallProgress = progress
|
||||
|
||||
DockProgress.progressInstance = overallProgress
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func resetDockProgressTracking() {
|
||||
Task { @MainActor in
|
||||
DockProgress.progress = 1 // Only way to completely remove overlay with DockProgress is setting progress to complete
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
func setInstallationStep(of version: Version, to step: InstallationStep) {
|
||||
func setInstallationStep(of version: Version, to step: XcodeInstallationStep) {
|
||||
DispatchQueue.main.async {
|
||||
guard let index = self.allXcodes.firstIndex(where: { $0.version.isEquivalent(to: version) }) else { return }
|
||||
self.allXcodes[index].installState = .installing(step)
|
||||
|
||||
let xcode = self.allXcodes[index]
|
||||
Current.notificationManager.scheduleNotification(title: xcode.id.appleDescription, body: step.description, category: .normal)
|
||||
Current.notificationManager.scheduleNotification(title: xcode.version.major.description + "." + xcode.version.appleDescription, body: step.description, category: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
func setInstallationStep(of runtime: DownloadableRuntime, to step: RuntimeInstallationStep, postNotification: Bool = true) {
|
||||
DispatchQueue.main.async {
|
||||
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
|
||||
self.downloadableRuntimes[index].installState = .installing(step)
|
||||
if postNotification {
|
||||
Current.notificationManager.scheduleNotification(title: runtime.name, body: step.description, category: .normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -492,60 +550,41 @@ public enum InstallationError: LocalizedError, Equatable {
|
|||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .damagedXIP(let url):
|
||||
return "The archive \"\(url.lastPathComponent)\" is damaged and can't be expanded."
|
||||
return String(format: localizeString("InstallationError.DamagedXIP"), url.lastPathComponent)
|
||||
case let .notEnoughFreeSpaceToExpandArchive(archivePath, version):
|
||||
return """
|
||||
The archive “\(archivePath.basename())” can’t be expanded because the current volume doesn’t have enough free space.
|
||||
|
||||
Make more space available to expand the archive and then install Xcode \(version.appleDescription) again to start installation from where it left off.
|
||||
"""
|
||||
return String(format: localizeString("InstallationError.NotEnoughFreeSpaceToExpandArchive"), archivePath.basename(), version.appleDescription)
|
||||
case .failedToMoveXcodeToApplications:
|
||||
return "Failed to move Xcode to the /Applications directory."
|
||||
return String(format: localizeString("InstallationError.FailedToMoveXcodeToApplications"), Path.installDirectory.string)
|
||||
case .failedSecurityAssessment(let xcode, let output):
|
||||
return """
|
||||
Xcode \(xcode.version) failed its security assessment with the following output:
|
||||
\(output)
|
||||
It remains installed at \(xcode.path) if you wish to use it anyways.
|
||||
"""
|
||||
return String(format: localizeString("InstallationError.FailedSecurityAssessment"), String(xcode.version), output, xcode.path.string)
|
||||
case .codesignVerifyFailed(let output):
|
||||
return """
|
||||
The downloaded Xcode failed code signing verification with the following output:
|
||||
\(output)
|
||||
"""
|
||||
return String(format: localizeString("InstallationError.CodesignVerifyFailed"), output)
|
||||
case .unexpectedCodeSigningIdentity(let identity, let certificateAuthority):
|
||||
return """
|
||||
The downloaded Xcode doesn't have the expected code signing identity.
|
||||
Got:
|
||||
\(identity)
|
||||
\(certificateAuthority)
|
||||
Expected:
|
||||
\(XcodeTeamIdentifier)
|
||||
\(XcodeCertificateAuthority)
|
||||
"""
|
||||
return String(format: localizeString("InstallationError.UnexpectedCodeSigningIdentity"), identity, certificateAuthority, XcodeTeamIdentifier, XcodeCertificateAuthority)
|
||||
case .unsupportedFileFormat(let fileExtension):
|
||||
return "xcodes doesn't (yet) support installing Xcode from the \(fileExtension) file format."
|
||||
return String(format: localizeString("InstallationError.UnsupportedFileFormat"), fileExtension)
|
||||
case .missingSudoerPassword:
|
||||
return "Missing password. Please try again."
|
||||
return localizeString("InstallationError.MissingSudoerPassword")
|
||||
case let .unavailableVersion(version):
|
||||
return "Could not find version \(version.appleDescription)."
|
||||
return String(format: localizeString("InstallationError.UnavailableVersion"), version.appleDescription)
|
||||
case .noNonPrereleaseVersionAvailable:
|
||||
return "No non-prerelease versions available."
|
||||
return localizeString("InstallationError.NoNonPrereleaseVersionAvailable")
|
||||
case .noPrereleaseVersionAvailable:
|
||||
return "No prerelease versions available."
|
||||
return localizeString("InstallationError.NoPrereleaseVersionAvailable")
|
||||
case .missingUsernameOrPassword:
|
||||
return "Missing username or a password. Please try again."
|
||||
return localizeString("InstallationError.MissingUsernameOrPassword")
|
||||
case let .versionAlreadyInstalled(installedXcode):
|
||||
return "\(installedXcode.version.appleDescription) is already installed at \(installedXcode.path)"
|
||||
return String(format: localizeString("InstallationError.VersionAlreadyInstalled"), installedXcode.version.appleDescription, installedXcode.path.string)
|
||||
case let .invalidVersion(version):
|
||||
return "\(version) is not a valid version number."
|
||||
return String(format: localizeString("InstallationError.InvalidVersion"), version)
|
||||
case let .versionNotInstalled(version):
|
||||
return "\(version.appleDescription) is not installed."
|
||||
return String(format: localizeString("InstallationError.VersionNotInstalled"), version.appleDescription)
|
||||
case let .postInstallStepsNotPerformed(version, helperInstallState):
|
||||
switch helperInstallState {
|
||||
case .installed:
|
||||
return "Installation was completed, but some post-install steps weren't performed automatically. These will be performed when you first launch Xcode \(version.appleDescription)."
|
||||
return String(format: localizeString("InstallationError.PostInstallStepsNotPerformed.Installed"), version.appleDescription)
|
||||
case .notInstalled, .unknown:
|
||||
return "Installation was completed, but some post-install steps weren't performed automatically. Xcodes performs these steps with a privileged helper, which appears to not be installed. You can install it from Preferences > Advanced.\n\nThese steps will be performed when you first launch Xcode \(version.appleDescription)."
|
||||
return String(format: localizeString("InstallationError.PostInstallStepsNotPerformed.NotInstalled"), version.appleDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
356
Xcodes/Backend/AppState+Runtimes.swift
Normal file
356
Xcodes/Backend/AppState+Runtimes.swift
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
import Foundation
|
||||
import XcodesKit
|
||||
import OSLog
|
||||
import Combine
|
||||
import Path
|
||||
import AppleAPI
|
||||
import Version
|
||||
|
||||
extension AppState {
|
||||
func updateDownloadableRuntimes() {
|
||||
Task {
|
||||
do {
|
||||
|
||||
let downloadableRuntimes = try await self.runtimeService.downloadableRuntimes()
|
||||
let runtimes = downloadableRuntimes.downloadables.map { runtime in
|
||||
var updatedRuntime = runtime
|
||||
|
||||
// This loops through and matches up the simulatorVersion to the mappings
|
||||
let simulatorBuildUpdate = downloadableRuntimes.sdkToSimulatorMappings.filter { SDKToSimulatorMapping in
|
||||
SDKToSimulatorMapping.simulatorBuildUpdate == runtime.simulatorVersion.buildUpdate
|
||||
}
|
||||
updatedRuntime.sdkBuildUpdate = simulatorBuildUpdate.map { $0.sdkBuildUpdate }
|
||||
return updatedRuntime
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.downloadableRuntimes = runtimes
|
||||
}
|
||||
try? cacheDownloadableRuntimes(runtimes)
|
||||
} catch {
|
||||
Logger.appState.error("Error downloading runtimes: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateInstalledRuntimes() {
|
||||
Task {
|
||||
do {
|
||||
Logger.appState.info("Loading Installed runtimes")
|
||||
let runtimes = try await self.runtimeService.localInstalledRuntimes()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.installedRuntimes = runtimes
|
||||
}
|
||||
} catch {
|
||||
Logger.appState.error("Error loading installed runtimes: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadRuntime(runtime: DownloadableRuntime) {
|
||||
guard let selectedXcode = self.allXcodes.first(where: { $0.selected }) else {
|
||||
Logger.appState.error("No selected Xcode")
|
||||
DispatchQueue.main.async {
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: "No selected Xcode. Please make an Xcode active")
|
||||
}
|
||||
return
|
||||
}
|
||||
// new runtimes
|
||||
if runtime.contentType == .cryptexDiskImage {
|
||||
// only selected xcodes > 16.1 beta 3 can download runtimes via a xcodebuild -downloadPlatform version
|
||||
// only Runtimes coming from cryptexDiskImage can be downloaded via xcodebuild
|
||||
if selectedXcode.version > Version(major: 16, minor: 0, patch: 0) {
|
||||
|
||||
if runtime.architectures?.isAppleSilicon ?? false {
|
||||
// Need Xcode 26 but with some RC/Beta's its simpler to just to greater > 25
|
||||
if selectedXcode.version > Version(major: 25, minor: 0, patch: 0) {
|
||||
downloadRuntimeViaXcodeBuild(runtime: runtime)
|
||||
} else {
|
||||
// not supported
|
||||
Logger.appState.error("Trying to download a runtime we can't download")
|
||||
DispatchQueue.main.async {
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: localizeString("Alert.Install.Error.Need.Xcode26"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
} else {
|
||||
downloadRuntimeViaXcodeBuild(runtime: runtime)
|
||||
}
|
||||
} else {
|
||||
// not supported
|
||||
Logger.appState.error("Trying to download a runtime we can't download")
|
||||
DispatchQueue.main.async {
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: localizeString("Alert.Install.Error.Need.Xcode16.1"))
|
||||
}
|
||||
return
|
||||
}
|
||||
} else {
|
||||
downloadRuntimeObseleteWay(runtime: runtime)
|
||||
}
|
||||
}
|
||||
|
||||
func downloadRuntimeViaXcodeBuild(runtime: DownloadableRuntime) {
|
||||
|
||||
let downloadRuntimeTask = Current.shell.downloadRuntime(runtime.platform.shortName, runtime.simulatorVersion.buildUpdate, runtime.architectures?.isAppleSilicon ?? false ? Architecture.arm64.rawValue : nil)
|
||||
|
||||
runtimePublishers[runtime.identifier] = Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
for try await progress in downloadRuntimeTask {
|
||||
if progress.isIndeterminate {
|
||||
DispatchQueue.main.async {
|
||||
self.setInstallationStep(of: runtime, to: .installing, postNotification: false)
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
self.setInstallationStep(of: runtime, to: .downloading(progress: progress), postNotification: false)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Logger.appState.debug("Done downloading runtime - \(runtime.name)")
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
|
||||
self.downloadableRuntimes[index].installState = .installed
|
||||
self.update()
|
||||
}
|
||||
|
||||
} catch {
|
||||
Logger.appState.error("Error downloading runtime: \(error.localizedDescription)")
|
||||
DispatchQueue.main.async {
|
||||
self.error = error
|
||||
if let error = error as? String {
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error)
|
||||
} else {
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadRuntimeObseleteWay(runtime: DownloadableRuntime) {
|
||||
runtimePublishers[runtime.identifier] = Task {
|
||||
do {
|
||||
let downloadedURL = try await downloadRunTimeFull(runtime: runtime)
|
||||
if !Task.isCancelled {
|
||||
Logger.appState.debug("Installing runtime: \(runtime.name)")
|
||||
DispatchQueue.main.async {
|
||||
self.setInstallationStep(of: runtime, to: .installing)
|
||||
}
|
||||
switch runtime.contentType {
|
||||
case .cryptexDiskImage:
|
||||
// not supported yet (do we need to for old packages?)
|
||||
throw "Installing via cryptexDiskImage not support - please install manually from \(downloadedURL.description)"
|
||||
case .package:
|
||||
// not supported yet (do we need to for old packages?)
|
||||
throw "Installing via package not support - please install manually from \(downloadedURL.description)"
|
||||
case .diskImage:
|
||||
try await self.installFromImage(dmgURL: downloadedURL)
|
||||
DispatchQueue.main.async {
|
||||
self.setInstallationStep(of: runtime, to: .trashingArchive)
|
||||
}
|
||||
try Current.files.removeItem(at: downloadedURL)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
|
||||
self.downloadableRuntimes[index].installState = .installed
|
||||
}
|
||||
updateInstalledRuntimes()
|
||||
}
|
||||
|
||||
}
|
||||
catch {
|
||||
Logger.appState.error("Error downloading runtime: \(error.localizedDescription)")
|
||||
DispatchQueue.main.async {
|
||||
self.error = error
|
||||
if let error = error as? String {
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error)
|
||||
} else {
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadRunTimeFull(runtime: DownloadableRuntime) async throws -> URL {
|
||||
guard let source = runtime.source else {
|
||||
throw "Invalid runtime source"
|
||||
}
|
||||
|
||||
guard let downloadPath = runtime.downloadPath else {
|
||||
throw "Invalid runtime downloadPath"
|
||||
}
|
||||
|
||||
// sets a proper cookie for runtimes
|
||||
try await validateADCSession(path: downloadPath)
|
||||
|
||||
let downloader = Downloader(rawValue: Current.defaults.string(forKey: "downloader") ?? "aria2") ?? .aria2
|
||||
|
||||
let url = URL(string: source)!
|
||||
let expectedRuntimePath = Path.xcodesApplicationSupport/"\(url.lastPathComponent)"
|
||||
// aria2 downloads directly to the destination (instead of into /tmp first) so we need to make sure that the download isn't incomplete
|
||||
let aria2DownloadMetadataPath = expectedRuntimePath.parent/(expectedRuntimePath.basename() + ".aria2")
|
||||
var aria2DownloadIsIncomplete = false
|
||||
if case .aria2 = downloader, aria2DownloadMetadataPath.exists {
|
||||
aria2DownloadIsIncomplete = true
|
||||
}
|
||||
if Current.files.fileExistsAtPath(expectedRuntimePath.string), aria2DownloadIsIncomplete == false {
|
||||
Logger.appState.info("Found existing runtime that will be used for installation at \(expectedRuntimePath).")
|
||||
return expectedRuntimePath.url
|
||||
}
|
||||
|
||||
Logger.appState.info("Downloading \(runtime.visibleIdentifier) with \(downloader)")
|
||||
switch downloader {
|
||||
case .aria2:
|
||||
let aria2Path = Path(url: Bundle.main.url(forAuxiliaryExecutable: "aria2c")!)!
|
||||
for try await progress in downloadRuntimeWithAria2(runtime, to: expectedRuntimePath, aria2Path: aria2Path) {
|
||||
DispatchQueue.main.async {
|
||||
self.setInstallationStep(of: runtime, to: .downloading(progress: progress), postNotification: false)
|
||||
}
|
||||
}
|
||||
Logger.appState.debug("Done downloading runtime")
|
||||
|
||||
case .urlSession:
|
||||
throw "Downloading runtimes with URLSession is not supported. Please use aria2"
|
||||
}
|
||||
return expectedRuntimePath.url
|
||||
}
|
||||
|
||||
public func downloadRuntimeWithAria2(_ runtime: DownloadableRuntime, to destination: Path, aria2Path: Path) -> AsyncThrowingStream<Progress, Error> {
|
||||
guard let url = runtime.url else {
|
||||
return AsyncThrowingStream<Progress, Error> { continuation in
|
||||
continuation.finish(throwing: "Invalid or non existant runtime url")
|
||||
}
|
||||
}
|
||||
|
||||
let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies(for: url) ?? []
|
||||
|
||||
return Current.shell.downloadWithAria2Async(aria2Path, url, destination, cookies)
|
||||
}
|
||||
|
||||
|
||||
public func installFromImage(dmgURL: URL) async throws {
|
||||
try await self.runtimeService.installRuntimeImage(dmgURL: dmgURL)
|
||||
}
|
||||
|
||||
func cancelRuntimeInstall(runtime: DownloadableRuntime) {
|
||||
// Cancel the publisher
|
||||
|
||||
runtimePublishers[runtime.identifier]?.cancel()
|
||||
runtimePublishers[runtime.identifier] = nil
|
||||
|
||||
// If the download is cancelled by the user, clean up the download files that aria2 creates.
|
||||
guard let source = runtime.source else {
|
||||
return
|
||||
}
|
||||
let url = URL(string: source)!
|
||||
let expectedRuntimePath = Path.xcodesApplicationSupport/"\(url.lastPathComponent)"
|
||||
let aria2DownloadMetadataPath = expectedRuntimePath.parent/(expectedRuntimePath.basename() + ".aria2")
|
||||
|
||||
try? Current.files.removeItem(at: expectedRuntimePath.url)
|
||||
try? Current.files.removeItem(at: aria2DownloadMetadataPath.url)
|
||||
|
||||
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
|
||||
self.downloadableRuntimes[index].installState = .notInstalled
|
||||
|
||||
updateInstalledRuntimes()
|
||||
}
|
||||
|
||||
func runtimeInstallPath(xcode: Xcode, runtime: DownloadableRuntime) -> Path? {
|
||||
if let coreSimulatorInfo = coreSimulatorInfo(runtime: runtime) {
|
||||
let urlString = coreSimulatorInfo.path["relative"]!
|
||||
// app was not allowed to open up file:// url's so remove
|
||||
let fileRemovedString = urlString.replacingOccurrences(of: "file://", with: "")
|
||||
let url = URL(fileURLWithPath: fileRemovedString)
|
||||
|
||||
return Path(url: url)!
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func coreSimulatorInfo(runtime: DownloadableRuntime) -> CoreSimulatorImage? {
|
||||
return installedRuntimes.filter({
|
||||
$0.runtimeInfo.build == runtime.simulatorVersion.buildUpdate &&
|
||||
((runtime.architectures ?? []).isEmpty ? true :
|
||||
$0.runtimeInfo.supportedArchitectures == runtime.architectures )}).first
|
||||
}
|
||||
|
||||
func deleteRuntime(runtime: DownloadableRuntime) async throws {
|
||||
if let info = coreSimulatorInfo(runtime: runtime) {
|
||||
try await runtimeService.deleteRuntime(identifier: info.uuid)
|
||||
|
||||
// give it some time to actually finish deleting before updating
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.updateInstalledRuntimes()
|
||||
}
|
||||
} else {
|
||||
throw "No simulator found with \(runtime.identifier)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyPublisher {
|
||||
func async() async throws -> Output {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
var cancellable: AnyCancellable?
|
||||
|
||||
cancellable = first()
|
||||
.sink { result in
|
||||
switch result {
|
||||
case .finished:
|
||||
break
|
||||
case let .failure(error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
cancellable?.cancel()
|
||||
} receiveValue: { value in
|
||||
continuation.resume(with: .success(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
extension AnyPublisher where Failure: Error {
|
||||
struct Subscriber {
|
||||
fileprivate let send: (Output) -> Void
|
||||
fileprivate let complete: (Subscribers.Completion<Failure>) -> Void
|
||||
|
||||
func send(_ value: Output) { self.send(value) }
|
||||
func send(completion: Subscribers.Completion<Failure>) { self.complete(completion) }
|
||||
}
|
||||
|
||||
init(_ closure: (Subscriber) -> AnyCancellable) {
|
||||
let subject = PassthroughSubject<Output, Failure>()
|
||||
|
||||
let subscriber = Subscriber(
|
||||
send: subject.send,
|
||||
complete: subject.send(completion:)
|
||||
)
|
||||
let cancel = closure(subscriber)
|
||||
|
||||
self = subject
|
||||
.handleEvents(receiveCancel: cancel.cancel)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyPublisher where Failure == Error {
|
||||
init(taskPriority: TaskPriority? = nil, asyncFunc: @escaping () async throws -> Output) {
|
||||
self.init { subscriber in
|
||||
let task = Task(priority: taskPriority) {
|
||||
do {
|
||||
subscriber.send(try await asyncFunc())
|
||||
subscriber.send(completion: .finished)
|
||||
} catch {
|
||||
subscriber.send(completion: .failure(error))
|
||||
}
|
||||
}
|
||||
return AnyCancellable { task.cancel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,8 @@ import Foundation
|
|||
import Path
|
||||
import Version
|
||||
import SwiftSoup
|
||||
import struct XCModel.Xcode
|
||||
import AppleAPI
|
||||
import XcodesKit
|
||||
|
||||
extension AppState {
|
||||
|
||||
|
|
@ -36,6 +36,8 @@ extension AppState {
|
|||
|
||||
func update() {
|
||||
guard !isUpdating else { return }
|
||||
updateDownloadableRuntimes()
|
||||
updateInstalledRuntimes()
|
||||
updatePublisher = updateSelectedXcodePath()
|
||||
.flatMap { _ in
|
||||
self.updateAvailableXcodes(from: self.dataSource)
|
||||
|
|
@ -47,7 +49,7 @@ extension AppState {
|
|||
// Prevent setting the app state error if it is an invalid session, we will present the sign in view instead
|
||||
if error as? AuthenticationError != .invalidSession {
|
||||
self.error = error
|
||||
self.presentedAlert = .generic(title: "Unable to update selected Xcode", message: error.legibleLocalizedDescription)
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.Update.Error.Title"), message: error.legibleLocalizedDescription)
|
||||
}
|
||||
case .finished:
|
||||
Current.defaults.setDate(Current.date(), forKey: "lastUpdated")
|
||||
|
|
@ -125,20 +127,40 @@ extension AppState {
|
|||
withIntermediateDirectories: true)
|
||||
try data.write(to: Path.cacheFile.url)
|
||||
}
|
||||
|
||||
// MARK: Runtime Cache
|
||||
|
||||
func loadCacheDownloadableRuntimes() throws {
|
||||
guard let data = Current.files.contents(atPath: Path.runtimeCacheFile.string) else { return }
|
||||
let runtimes = try JSONDecoder().decode([DownloadableRuntime].self, from: data)
|
||||
self.downloadableRuntimes = runtimes
|
||||
}
|
||||
|
||||
func cacheDownloadableRuntimes(_ runtimes: [DownloadableRuntime]) throws {
|
||||
let data = try JSONEncoder().encode(runtimes)
|
||||
try FileManager.default.createDirectory(at: Path.runtimeCacheFile.url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: Path.runtimeCacheFile.url)
|
||||
}
|
||||
}
|
||||
|
||||
extension AppState {
|
||||
// MARK: - Apple
|
||||
|
||||
private func releasedXcodes() -> AnyPublisher<[AvailableXcode], Error> {
|
||||
private func releasedXcodes() -> AnyPublisher<[AvailableXcode], Swift.Error> {
|
||||
Current.network.dataTask(with: URLRequest.downloads)
|
||||
.map(\.data)
|
||||
.decode(type: Downloads.self, decoder: configure(JSONDecoder()) {
|
||||
$0.dateDecodingStrategy = .formatted(.downloadsDateModified)
|
||||
})
|
||||
.map { downloads -> [AvailableXcode] in
|
||||
let xcodes = downloads
|
||||
.downloads
|
||||
.tryMap { downloads -> [AvailableXcode] in
|
||||
if downloads.hasError {
|
||||
throw AuthenticationError.invalidResult(resultString: downloads.resultsString)
|
||||
}
|
||||
guard let downloadList = downloads.downloads else {
|
||||
throw AuthenticationError.invalidResult(resultString: localizeString("DownloadingError"))
|
||||
}
|
||||
let xcodes = downloadList
|
||||
.filter { $0.name.range(of: "^Xcode [0-9]", options: .regularExpression) != nil }
|
||||
.compactMap { download -> AvailableXcode? in
|
||||
let urlPrefix = URL(string: "https://download.developer.apple.com/")!
|
||||
|
|
@ -188,8 +210,8 @@ extension AppState {
|
|||
private func xcodeReleases() -> AnyPublisher<[AvailableXcode], Error> {
|
||||
Current.network.dataTask(with: URLRequest(url: URL(string: "https://xcodereleases.com/data.json")!))
|
||||
.map(\.data)
|
||||
.decode(type: [XCModel.Xcode].self, decoder: JSONDecoder())
|
||||
.map { xcReleasesXcodes in
|
||||
.decode(type: [XcodeRelease].self, decoder: JSONDecoder())
|
||||
.map { xcReleasesXcodes in
|
||||
let xcodes = xcReleasesXcodes.compactMap { xcReleasesXcode -> AvailableXcode? in
|
||||
guard
|
||||
let downloadURL = xcReleasesXcode.links?.download?.url,
|
||||
|
|
@ -210,7 +232,8 @@ extension AppState {
|
|||
requiredMacOSVersion: xcReleasesXcode.requires,
|
||||
releaseNotesURL: xcReleasesXcode.links?.notes?.url,
|
||||
sdks: xcReleasesXcode.sdks,
|
||||
compilers: xcReleasesXcode.compilers
|
||||
compilers: xcReleasesXcode.compilers,
|
||||
architectures: xcReleasesXcode.architectures
|
||||
)
|
||||
}
|
||||
return xcodes
|
||||
|
|
|
|||
|
|
@ -7,9 +7,33 @@ import KeychainAccess
|
|||
import Path
|
||||
import Version
|
||||
import os.log
|
||||
import DockProgress
|
||||
import XcodesKit
|
||||
import LibFido2Swift
|
||||
|
||||
enum PreferenceKey: String {
|
||||
case installPath
|
||||
case localPath
|
||||
case unxipExperiment
|
||||
case createSymLinkOnSelect
|
||||
case onSelectActionType
|
||||
case showOpenInRosettaOption
|
||||
case autoInstallation
|
||||
case SUEnableAutomaticChecks
|
||||
case includePrereleaseVersions
|
||||
case downloader
|
||||
case dataSource
|
||||
case xcodeListCategory
|
||||
case allowedMajorVersions
|
||||
case hideSupportXcodes
|
||||
case xcodeListArchitectures
|
||||
|
||||
func isManaged() -> Bool { UserDefaults.standard.objectIsForced(forKey: self.rawValue) }
|
||||
}
|
||||
|
||||
class AppState: ObservableObject {
|
||||
private let client = AppleAPI.Client()
|
||||
internal let runtimeService = RuntimeService()
|
||||
|
||||
// MARK: - Published Properties
|
||||
|
||||
|
|
@ -17,11 +41,11 @@ class AppState: ObservableObject {
|
|||
@Published var availableXcodes: [AvailableXcode] = [] {
|
||||
willSet {
|
||||
if newValue.count > availableXcodes.count && availableXcodes.count != 0 {
|
||||
Current.notificationManager.scheduleNotification(title: "New Xcode versions", body: "New Xcode versions are available to download.", category: .normal)
|
||||
Current.notificationManager.scheduleNotification(title: localizeString("Notification.NewXcodeVersion.Title"), body: localizeString("Notification.NewXcodeVersion.Body"), category: .normal)
|
||||
}
|
||||
updateAllXcodes(
|
||||
availableXcodes: newValue,
|
||||
installedXcodes: Current.files.installedXcodes(Path.root/"Applications"),
|
||||
installedXcodes: Current.files.installedXcodes(Path.installDirectory),
|
||||
selectedXcodePath: selectedXcodePath
|
||||
)
|
||||
}
|
||||
|
|
@ -34,7 +58,7 @@ class AppState: ObservableObject {
|
|||
willSet {
|
||||
updateAllXcodes(
|
||||
availableXcodes: availableXcodes,
|
||||
installedXcodes: Current.files.installedXcodes(Path.root/"Applications"),
|
||||
installedXcodes: Current.files.installedXcodes(Path.installDirectory),
|
||||
selectedXcodePath: newValue
|
||||
)
|
||||
}
|
||||
|
|
@ -43,9 +67,9 @@ class AppState: ObservableObject {
|
|||
var isUpdating: Bool { updatePublisher != nil }
|
||||
@Published var presentedSheet: XcodesSheet? = nil
|
||||
@Published var isProcessingAuthRequest = false
|
||||
@Published var secondFactorData: SecondFactorData?
|
||||
@Published var xcodeBeingConfirmedForUninstallation: Xcode?
|
||||
@Published var presentedAlert: XcodesAlert?
|
||||
@Published var presentedPreferenceAlert: XcodesPreferencesAlert?
|
||||
@Published var helperInstallState: HelperInstallState = .notInstalled
|
||||
/// Whether the user is being prepared for the helper installation alert with an explanation.
|
||||
/// This closure will be performed after the user chooses whether or not to proceed.
|
||||
|
|
@ -62,14 +86,85 @@ class AppState: ObservableObject {
|
|||
Current.defaults.set(localPath, forKey: "localPath")
|
||||
}
|
||||
}
|
||||
|
||||
var disableLocalPathChange: Bool { PreferenceKey.localPath.isManaged() }
|
||||
|
||||
@Published var installPath = "" {
|
||||
didSet {
|
||||
Current.defaults.set(installPath, forKey: "installPath")
|
||||
}
|
||||
}
|
||||
|
||||
var disableInstallPathChange: Bool { PreferenceKey.installPath.isManaged() }
|
||||
|
||||
@Published var unxipExperiment = false {
|
||||
didSet {
|
||||
Current.defaults.set(unxipExperiment, forKey: "unxipExperiment")
|
||||
}
|
||||
}
|
||||
|
||||
var disableUnxipExperiment: Bool { PreferenceKey.unxipExperiment.isManaged() }
|
||||
|
||||
@Published var createSymLinkOnSelect = false {
|
||||
didSet {
|
||||
Current.defaults.set(createSymLinkOnSelect, forKey: "createSymLinkOnSelect")
|
||||
}
|
||||
}
|
||||
|
||||
var createSymLinkOnSelectDisabled: Bool {
|
||||
return onSelectActionType == .rename || PreferenceKey.createSymLinkOnSelect.isManaged()
|
||||
}
|
||||
|
||||
@Published var onSelectActionType = SelectedActionType.none {
|
||||
didSet {
|
||||
Current.defaults.set(onSelectActionType.rawValue, forKey: "onSelectActionType")
|
||||
|
||||
if onSelectActionType == .rename {
|
||||
createSymLinkOnSelect = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var onSelectActionTypeDisabled: Bool { PreferenceKey.onSelectActionType.isManaged() }
|
||||
|
||||
@Published var showOpenInRosettaOption = false {
|
||||
didSet {
|
||||
Current.defaults.set(showOpenInRosettaOption, forKey: "showOpenInRosettaOption")
|
||||
}
|
||||
}
|
||||
|
||||
@Published var terminateAfterLastWindowClosed = false {
|
||||
didSet {
|
||||
Current.defaults.set(terminateAfterLastWindowClosed, forKey: "terminateAfterLastWindowClosed")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Runtimes
|
||||
|
||||
@Published var downloadableRuntimes: [DownloadableRuntime] = []
|
||||
@Published var installedRuntimes: [CoreSimulatorImage] = []
|
||||
|
||||
// MARK: - Publisher Cancellables
|
||||
|
||||
var cancellables = Set<AnyCancellable>()
|
||||
private var installationPublishers: [Version: AnyCancellable] = [:]
|
||||
private var installationPublishers: [XcodeID: AnyCancellable] = [:]
|
||||
internal var runtimePublishers: [String: Task<(), any Error>] = [:]
|
||||
private var selectPublisher: AnyCancellable?
|
||||
private var uninstallPublisher: AnyCancellable?
|
||||
private var autoInstallTimer: Timer?
|
||||
|
||||
// MARK: - Dock Progress Tracking
|
||||
|
||||
public static let totalProgressUnits = Int64(10)
|
||||
public static let unxipProgressWeight = Int64(1)
|
||||
var overallProgress = Progress()
|
||||
var unxipProgress = {
|
||||
let progress = Progress(totalUnitCount: totalProgressUnits)
|
||||
progress.kind = .file
|
||||
progress.fileOperationKind = .copying
|
||||
return progress
|
||||
}()
|
||||
|
||||
// MARK: -
|
||||
|
||||
var dataSource: DataSource {
|
||||
|
|
@ -84,11 +179,23 @@ class AppState: ObservableObject {
|
|||
savedUsername != nil
|
||||
}
|
||||
|
||||
var bottomStatusBarMessage: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "dd/MM/yyyy"
|
||||
let finishDate = formatter.date(from: "11/06/2022")
|
||||
|
||||
if Date().compare(finishDate!) == .orderedAscending {
|
||||
return String(format: localizeString("WWDC.Message"), "2022")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init() {
|
||||
guard !isTesting else { return }
|
||||
try? loadCachedAvailableXcodes()
|
||||
try? loadCacheDownloadableRuntimes()
|
||||
checkIfHelperIsInstalled()
|
||||
setupAutoInstallTimer()
|
||||
setupDefaults()
|
||||
|
|
@ -96,13 +203,19 @@ class AppState: ObservableObject {
|
|||
|
||||
func setupDefaults() {
|
||||
localPath = Current.defaults.string(forKey: "localPath") ?? Path.defaultXcodesApplicationSupport.string
|
||||
unxipExperiment = Current.defaults.bool(forKey: "unxipExperiment") ?? false
|
||||
createSymLinkOnSelect = Current.defaults.bool(forKey: "createSymLinkOnSelect") ?? false
|
||||
onSelectActionType = SelectedActionType(rawValue: Current.defaults.string(forKey: "onSelectActionType") ?? "none") ?? .none
|
||||
installPath = Current.defaults.string(forKey: "installPath") ?? Path.defaultInstallDirectory.string
|
||||
showOpenInRosettaOption = Current.defaults.bool(forKey: "showOpenInRosettaOption") ?? false
|
||||
terminateAfterLastWindowClosed = Current.defaults.bool(forKey: "terminateAfterLastWindowClosed") ?? false
|
||||
}
|
||||
|
||||
// MARK: Timer
|
||||
/// Runs a timer every 6 hours when app is open to check if it needs to auto install any xcodes
|
||||
func setupAutoInstallTimer() {
|
||||
guard let storageValue = UserDefaults.standard.object(forKey: "autoInstallation") as? Int, let autoInstallType = AutoInstallationType(rawValue: storageValue) else { return }
|
||||
|
||||
guard let storageValue = Current.defaults.get(forKey: "autoInstallation") as? Int, let autoInstallType = AutoInstallationType(rawValue: storageValue) else { return }
|
||||
|
||||
if autoInstallType == .none { return }
|
||||
|
||||
autoInstallTimer = Timer.scheduledTimer(withTimeInterval: 60*60*6, repeats: true) { [weak self] _ in
|
||||
|
|
@ -111,7 +224,28 @@ class AppState: ObservableObject {
|
|||
}
|
||||
// MARK: - Authentication
|
||||
|
||||
func validateADCSession(path: String) -> AnyPublisher<Void, Error> {
|
||||
return Current.network.dataTask(with: URLRequest.downloadADCAuth(path: path))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.tryMap { result -> Void in
|
||||
let httpResponse = result.response as! HTTPURLResponse
|
||||
if httpResponse.statusCode == 401 {
|
||||
throw AuthenticationError.notAuthorized
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func validateADCSession(path: String) async throws {
|
||||
let result = try await Current.network.dataTaskAsync(with: URLRequest.downloadADCAuth(path: path))
|
||||
let httpResponse = result.1 as! HTTPURLResponse
|
||||
if httpResponse.statusCode == 401 {
|
||||
throw AuthenticationError.notAuthorized
|
||||
}
|
||||
}
|
||||
|
||||
func validateSession() -> AnyPublisher<Void, Error> {
|
||||
|
||||
return Current.network.validateSession()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.handleEvents(receiveCompletion: { completion in
|
||||
|
|
@ -144,7 +278,7 @@ class AppState: ObservableObject {
|
|||
|
||||
func signIn(username: String, password: String) {
|
||||
authError = nil
|
||||
signIn(username: username, password: password)
|
||||
signIn(username: username.lowercased(), password: password)
|
||||
.sink(
|
||||
receiveCompletion: { _ in },
|
||||
receiveValue: { _ in }
|
||||
|
|
@ -157,7 +291,7 @@ class AppState: ObservableObject {
|
|||
Current.defaults.set(username, forKey: "username")
|
||||
|
||||
isProcessingAuthRequest = true
|
||||
return client.login(accountName: username, password: password)
|
||||
return client.srpLogin(accountName: username, password: password)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.handleEvents(
|
||||
receiveOutput: { authenticationState in
|
||||
|
|
@ -172,12 +306,17 @@ class AppState: ObservableObject {
|
|||
}
|
||||
|
||||
func handleTwoFactorOption(_ option: TwoFactorOption, authOptions: AuthOptionsResponse, serviceKey: String, sessionID: String, scnt: String) {
|
||||
self.secondFactorData = SecondFactorData(
|
||||
option: option,
|
||||
authOptions: authOptions,
|
||||
sessionData: AppleSessionData(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
|
||||
)
|
||||
self.presentedSheet = .twoFactor
|
||||
let sessionData = AppleSessionData(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
|
||||
|
||||
if option == .securityKey, fido2DeviceIsPresent() && !fido2DeviceNeedsPin() {
|
||||
createAndSubmitSecurityKeyAssertationWithPinCode(nil, sessionData: sessionData, authOptions: authOptions)
|
||||
} else {
|
||||
self.presentedSheet = .twoFactor(.init(
|
||||
option: option,
|
||||
authOptions: authOptions,
|
||||
sessionData: sessionData
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func requestSMS(to trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber, authOptions: AuthOptionsResponse, sessionData: AppleSessionData) {
|
||||
|
|
@ -200,7 +339,11 @@ class AppState: ObservableObject {
|
|||
}
|
||||
|
||||
func choosePhoneNumberForSMS(authOptions: AuthOptionsResponse, sessionData: AppleSessionData) {
|
||||
secondFactorData = SecondFactorData(option: .smsPendingChoice, authOptions: authOptions, sessionData: sessionData)
|
||||
self.presentedSheet = .twoFactor(.init(
|
||||
option: .smsPendingChoice,
|
||||
authOptions: authOptions,
|
||||
sessionData: sessionData
|
||||
))
|
||||
}
|
||||
|
||||
func submitSecurityCode(_ code: SecurityCode, sessionData: AppleSessionData) {
|
||||
|
|
@ -219,6 +362,83 @@ class AppState: ObservableObject {
|
|||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private lazy var fido2 = FIDO2()
|
||||
|
||||
func createAndSubmitSecurityKeyAssertationWithPinCode(_ pinCode: String?, sessionData: AppleSessionData, authOptions: AuthOptionsResponse) {
|
||||
self.presentedSheet = .securityKeyTouchToConfirm
|
||||
|
||||
guard let fsaChallenge = authOptions.fsaChallenge else {
|
||||
// This shouldn't happen
|
||||
// we shouldn't have called this method without setting the fsaChallenge
|
||||
// so this is an assertionFailure
|
||||
assertionFailure()
|
||||
self.authError = "Something went wrong. Please file a bug report"
|
||||
return
|
||||
}
|
||||
|
||||
// The challenge is encoded in Base64URL encoding
|
||||
let challengeUrl = fsaChallenge.challenge
|
||||
let challenge = FIDO2.base64urlToBase64(base64url: challengeUrl)
|
||||
let origin = "https://idmsa.apple.com"
|
||||
let rpId = "apple.com"
|
||||
// Allowed creds is sent as a comma separated string
|
||||
let validCreds = fsaChallenge.allowedCredentials.split(separator: ",").map(String.init)
|
||||
|
||||
Task {
|
||||
do {
|
||||
let response = try fido2.respondToChallenge(args: ChallengeArgs(rpId: rpId, validCredentials: validCreds, devPin: pinCode, challenge: challenge, origin: origin))
|
||||
|
||||
Task { @MainActor in
|
||||
self.isProcessingAuthRequest = true
|
||||
}
|
||||
|
||||
let respData = try JSONEncoder().encode(response)
|
||||
client.submitChallenge(response: respData, sessionData: AppleSessionData(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.handleEvents(
|
||||
receiveOutput: { authenticationState in
|
||||
self.authenticationState = authenticationState
|
||||
},
|
||||
receiveCompletion: { completion in
|
||||
self.handleAuthenticationFlowCompletion(completion)
|
||||
self.isProcessingAuthRequest = false
|
||||
}
|
||||
).sink(
|
||||
receiveCompletion: { _ in },
|
||||
receiveValue: { _ in }
|
||||
).store(in: &cancellables)
|
||||
} catch FIDO2Error.canceledByUser {
|
||||
// User cancelled the auth flow
|
||||
// we don't have to show an error
|
||||
// because the sheet will already be dismissed
|
||||
} catch {
|
||||
Task { @MainActor in
|
||||
authError = error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fido2DeviceIsPresent() -> Bool {
|
||||
fido2.hasDeviceAttached()
|
||||
}
|
||||
|
||||
func fido2DeviceNeedsPin() -> Bool {
|
||||
do {
|
||||
return try fido2.deviceHasPin()
|
||||
} catch {
|
||||
Task { @MainActor in
|
||||
authError = error
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func cancelSecurityKeyAssertationRequest() {
|
||||
self.fido2.cancel()
|
||||
}
|
||||
|
||||
private func handleAuthenticationFlowCompletion(_ completion: Subscribers.Completion<Error>) {
|
||||
switch completion {
|
||||
case let .failure(error):
|
||||
|
|
@ -230,7 +450,6 @@ class AppState: ObservableObject {
|
|||
switch self.authenticationState {
|
||||
case .authenticated, .unauthenticated, .notAppleDeveloper:
|
||||
self.presentedSheet = nil
|
||||
self.secondFactorData = nil
|
||||
case let .waitingForSecondFactor(option, authOptions, sessionData):
|
||||
self.handleTwoFactorOption(option, authOptions: authOptions, serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt)
|
||||
}
|
||||
|
|
@ -270,7 +489,7 @@ class AppState: ObservableObject {
|
|||
receiveCompletion: { [unowned self] completion in
|
||||
if case let .failure(error) = completion {
|
||||
self.error = error
|
||||
self.presentedAlert = .generic(title: "Unable to install helper", message: error.legibleLocalizedDescription)
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.PrivilegedHelper.Error.Title"), message: error.legibleLocalizedDescription)
|
||||
}
|
||||
},
|
||||
receiveValue: {}
|
||||
|
|
@ -305,10 +524,42 @@ class AppState: ObservableObject {
|
|||
|
||||
// MARK: - Install
|
||||
|
||||
func install(id: Xcode.ID) {
|
||||
guard let availableXcode = availableXcodes.first(where: { $0.version == id }) else { return }
|
||||
func checkMinVersionAndInstall(id: XcodeID) {
|
||||
guard let availableXcode = availableXcodes.first(where: { $0.xcodeID == id }) else { return }
|
||||
|
||||
// Check to see if users macOS is supported
|
||||
if let requiredMacOSVersion = availableXcode.requiredMacOSVersion {
|
||||
if hasMinSupportedOS(requiredMacOSVersion: requiredMacOSVersion) {
|
||||
// prompt
|
||||
self.presentedAlert = .checkMinSupportedVersion(xcode: availableXcode, macOS: ProcessInfo.processInfo.operatingSystemVersion.versionString())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch self.dataSource {
|
||||
case .apple:
|
||||
install(id: id)
|
||||
case .xcodeReleases:
|
||||
install(id: id)
|
||||
}
|
||||
}
|
||||
|
||||
func hasMinSupportedOS(requiredMacOSVersion: String) -> Bool {
|
||||
let split = requiredMacOSVersion.components(separatedBy: ".").compactMap { Int($0) }
|
||||
let xcodeMinimumMacOSVersion = OperatingSystemVersion(majorVersion: split[safe: 0] ?? 0, minorVersion: split[safe: 1] ?? 0, patchVersion: split[safe: 2] ?? 0)
|
||||
|
||||
return !ProcessInfo.processInfo.isOperatingSystemAtLeast(xcodeMinimumMacOSVersion)
|
||||
}
|
||||
|
||||
func install(id: XcodeID) {
|
||||
guard let availableXcode = availableXcodes.first(where: { $0.xcodeID == id }) else { return }
|
||||
|
||||
installationPublishers[id] = signInIfNeeded()
|
||||
.handleEvents(
|
||||
receiveSubscription: { [unowned self] _ in
|
||||
self.setInstallationStep(of: availableXcode.version, to: .authenticating)
|
||||
}
|
||||
)
|
||||
.flatMap { [unowned self] in
|
||||
// signInIfNeeded might finish before the user actually authenticates if UI is involved.
|
||||
// This publisher will wait for the @Published authentication state to change to authenticated or unauthenticated before finishing,
|
||||
|
|
@ -337,12 +588,22 @@ class AppState: ObservableObject {
|
|||
// We need the cookies from its response in order to download Xcodes though,
|
||||
// so perform it here first just to be sure.
|
||||
Current.network.dataTask(with: URLRequest.downloads)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.map { _ in Void() }
|
||||
.map(\.data)
|
||||
.decode(type: Downloads.self, decoder: configure(JSONDecoder()) {
|
||||
$0.dateDecodingStrategy = .formatted(.downloadsDateModified)
|
||||
})
|
||||
.tryMap { downloads -> Void in
|
||||
if downloads.hasError {
|
||||
throw AuthenticationError.invalidResult(resultString: downloads.resultsString)
|
||||
}
|
||||
if downloads.downloads == nil {
|
||||
throw AuthenticationError.invalidResult(resultString: localizeString("DownloadingError"))
|
||||
}
|
||||
}
|
||||
.mapError { $0 as Error }
|
||||
}
|
||||
.flatMap { [unowned self] in
|
||||
self.install(.version(availableXcode), downloader: Downloader(rawValue: UserDefaults.standard.string(forKey: "downloader") ?? "aria2") ?? .aria2)
|
||||
self.install(.version(availableXcode), downloader: Downloader(rawValue: Current.defaults.string(forKey: "downloader") ?? "aria2") ?? .aria2)
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(
|
||||
|
|
@ -350,9 +611,13 @@ class AppState: ObservableObject {
|
|||
self.installationPublishers[id] = nil
|
||||
if case let .failure(error) = completion {
|
||||
// Prevent setting the app state error if it is an invalid session, we will present the sign in view instead
|
||||
if error as? AuthenticationError != .invalidSession {
|
||||
if let error = error as? AuthenticationError, case .notAuthorized = error {
|
||||
self.error = error
|
||||
self.presentedAlert = .generic(title: "Unable to install Xcode", message: error.legibleLocalizedDescription)
|
||||
self.presentedAlert = .unauthenticated
|
||||
|
||||
} else if error as? AuthenticationError != .invalidSession {
|
||||
self.error = error
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription)
|
||||
}
|
||||
if let index = self.allXcodes.firstIndex(where: { $0.id == id }) {
|
||||
self.allXcodes[index].installState = .notInstalled
|
||||
|
|
@ -363,11 +628,38 @@ class AppState: ObservableObject {
|
|||
)
|
||||
}
|
||||
|
||||
/// Skips using the username/password to log in to Apple, and simply gets a Auth Cookie used in downloading
|
||||
/// As of Nov 2022 this was returning a 403 forbidden
|
||||
func installWithoutLogin(id: Xcode.ID) {
|
||||
guard let availableXcode = availableXcodes.first(where: { $0.xcodeID == id }) else { return }
|
||||
|
||||
installationPublishers[id] = self.install(.version(availableXcode), downloader: Downloader(rawValue: Current.defaults.string(forKey: "downloader") ?? "aria2") ?? .aria2)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(
|
||||
receiveCompletion: { [unowned self] completion in
|
||||
self.installationPublishers[id] = nil
|
||||
if case let .failure(error) = completion {
|
||||
// Prevent setting the app state error if it is an invalid session, we will present the sign in view instead
|
||||
if error as? AuthenticationError != .invalidSession {
|
||||
self.error = error
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription)
|
||||
}
|
||||
if let index = self.allXcodes.firstIndex(where: { $0.id == id }) {
|
||||
self.allXcodes[index].installState = .notInstalled
|
||||
}
|
||||
}
|
||||
},
|
||||
receiveValue: { _ in }
|
||||
)
|
||||
}
|
||||
|
||||
func cancelInstall(id: Xcode.ID) {
|
||||
guard let availableXcode = availableXcodes.first(where: { $0.version == id }) else { return }
|
||||
guard let availableXcode = availableXcodes.first(where: { $0.xcodeID == id }) else { return }
|
||||
|
||||
// Cancel the publisher
|
||||
installationPublishers[id] = nil
|
||||
|
||||
resetDockProgressTracking()
|
||||
|
||||
// If the download is cancelled by the user, clean up the download files that aria2 creates.
|
||||
// This isn't done as part of the publisher with handleEvents(receiveCancel:) because it shouldn't happen when e.g. the app quits.
|
||||
|
|
@ -396,7 +688,7 @@ class AppState: ObservableObject {
|
|||
receiveCompletion: { [unowned self] completion in
|
||||
if case let .failure(error) = completion {
|
||||
self.error = error
|
||||
self.presentedAlert = .generic(title: "Unable to uninstall Xcode", message: error.legibleLocalizedDescription)
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.Uninstall.Error.Title"), message: error.legibleLocalizedDescription)
|
||||
}
|
||||
self.uninstallPublisher = nil
|
||||
},
|
||||
|
|
@ -404,10 +696,10 @@ class AppState: ObservableObject {
|
|||
)
|
||||
}
|
||||
|
||||
func reveal(xcode: Xcode) {
|
||||
func reveal(_ path: Path?) {
|
||||
// TODO: show error if not
|
||||
guard let installedXcodePath = xcode.installedPath else { return }
|
||||
NSWorkspace.shared.activateFileViewerSelecting([installedXcodePath.url])
|
||||
guard let path = path else { return }
|
||||
NSWorkspace.shared.activateFileViewerSelecting([path.url])
|
||||
}
|
||||
|
||||
func reveal(path: String) {
|
||||
|
|
@ -438,10 +730,15 @@ class AppState: ObservableObject {
|
|||
}
|
||||
|
||||
guard
|
||||
let installedXcodePath = xcode.installedPath,
|
||||
var installedXcodePath = xcode.installedPath,
|
||||
selectPublisher == nil
|
||||
else { return }
|
||||
|
||||
if onSelectActionType == .rename {
|
||||
guard let newDestinationXcodePath = renameToXcode(xcode: xcode) else { return }
|
||||
installedXcodePath = newDestinationXcodePath
|
||||
}
|
||||
|
||||
selectPublisher = installHelperIfNecessary()
|
||||
.flatMap {
|
||||
Current.helper.switchXcodePath(installedXcodePath.string)
|
||||
|
|
@ -453,7 +750,11 @@ class AppState: ObservableObject {
|
|||
receiveCompletion: { [unowned self] completion in
|
||||
if case let .failure(error) = completion {
|
||||
self.error = error
|
||||
self.presentedAlert = .generic(title: "Unable to select Xcode", message: error.legibleLocalizedDescription)
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.Select.Error.Title"), message: error.legibleLocalizedDescription)
|
||||
} else {
|
||||
if self.createSymLinkOnSelect {
|
||||
createSymbolicLink(xcode: xcode)
|
||||
}
|
||||
}
|
||||
self.selectPublisher = nil
|
||||
},
|
||||
|
|
@ -461,13 +762,18 @@ class AppState: ObservableObject {
|
|||
)
|
||||
}
|
||||
|
||||
func open(xcode: Xcode) {
|
||||
func open(xcode: Xcode, openInRosetta: Bool? = false) {
|
||||
switch xcode.installState {
|
||||
case let .installed(path):
|
||||
NSWorkspace.shared.openApplication(at: path.url, configuration: .init())
|
||||
default:
|
||||
Logger.appState.error("\(xcode.id) is not installed")
|
||||
return
|
||||
case let .installed(path):
|
||||
let config = NSWorkspace.OpenConfiguration.init()
|
||||
if (openInRosetta ?? false) {
|
||||
config.architecture = CPU_TYPE_X86_64
|
||||
}
|
||||
config.allowsRunningApplicationSubstitution = false
|
||||
NSWorkspace.shared.openApplication(at: path.url, configuration: config)
|
||||
default:
|
||||
Logger.appState.error("\(xcode.id.version) is not installed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -479,6 +785,79 @@ class AppState: ObservableObject {
|
|||
NSPasteboard.general.setString(installedXcodePath.string, forType: .string)
|
||||
}
|
||||
|
||||
func copyReleaseNote(from url: URL?) {
|
||||
guard let url = url else { return }
|
||||
NSPasteboard.general.declareTypes([.URL, .string], owner: nil)
|
||||
NSPasteboard.general.writeObjects([url as NSURL])
|
||||
NSPasteboard.general.setString(url.absoluteString, forType: .string)
|
||||
}
|
||||
|
||||
func createSymbolicLink(xcode: Xcode, isBeta: Bool = false) {
|
||||
guard let installedXcodePath = xcode.installedPath else { return }
|
||||
|
||||
let destinationPath: Path = Path.installDirectory/"Xcode\(isBeta ? "-Beta" : "").app"
|
||||
|
||||
// does an Xcode.app file exist?
|
||||
if FileManager.default.fileExists(atPath: destinationPath.string) {
|
||||
do {
|
||||
// if it's not a symlink, error because we don't want to delete an actual xcode.app file
|
||||
let attributes: [FileAttributeKey : Any]? = try? FileManager.default.attributesOfItem(atPath: destinationPath.string)
|
||||
|
||||
if attributes?[.type] as? FileAttributeType == FileAttributeType.typeSymbolicLink {
|
||||
try FileManager.default.removeItem(atPath: destinationPath.string)
|
||||
Logger.appState.info("Successfully deleted old symlink")
|
||||
} else {
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: localizeString("Alert.SymLink.Message"))
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.createSymbolicLink(atPath: destinationPath.string, withDestinationPath: installedXcodePath.string)
|
||||
Logger.appState.info("Successfully created symbolic link with Xcode\(isBeta ? "-Beta": "").app")
|
||||
} catch {
|
||||
Logger.appState.error("Unable to create symbolic Link")
|
||||
self.error = error
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.legibleLocalizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func renameToXcode(xcode: Xcode) -> Path? {
|
||||
guard let installedXcodePath = xcode.installedPath else { return nil }
|
||||
|
||||
let destinationPath: Path = Path.installDirectory/"Xcode.app"
|
||||
|
||||
// rename any old named `Xcode.app` to the Xcodes versioned named files
|
||||
if FileManager.default.fileExists(atPath: destinationPath.string) {
|
||||
if let originalXcode = Current.files.installedXcode(destination: destinationPath) {
|
||||
let newName = "Xcode-\(originalXcode.version.descriptionWithoutBuildMetadata).app"
|
||||
Logger.appState.debug("Found Xcode.app - renaming back to \(newName)")
|
||||
do {
|
||||
try destinationPath.rename(to: newName)
|
||||
} catch {
|
||||
Logger.appState.error("Unable to create rename Xcode.app back to original")
|
||||
self.error = error
|
||||
// TODO UPDATE MY ERROR STRING
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.legibleLocalizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
// rename passed in xcode to xcode.app
|
||||
Logger.appState.debug("Found Xcode.app - renaming back to Xcode.app")
|
||||
do {
|
||||
return try installedXcodePath.rename(to: "Xcode.app")
|
||||
} catch {
|
||||
Logger.appState.error("Unable to create rename Xcode.app back to original")
|
||||
self.error = error
|
||||
// TODO UPDATE MY ERROR STRING
|
||||
self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.legibleLocalizedDescription)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateAllXcodes(availableXcodes: [AvailableXcode], installedXcodes: [InstalledXcode], selectedXcodePath: String?) {
|
||||
var adjustedAvailableXcodes = availableXcodes
|
||||
|
||||
|
|
@ -489,7 +868,7 @@ class AppState: ObservableObject {
|
|||
// If build metadata matches exactly, replace the available version with the installed version.
|
||||
// This should handle Apple versions from /downloads/more which don't have build metadata identifiers.
|
||||
if let index = adjustedAvailableXcodes.map(\.version).firstIndex(where: { $0.buildMetadataIdentifiers == installedXcode.version.buildMetadataIdentifiers }) {
|
||||
adjustedAvailableXcodes[index].version = installedXcode.version
|
||||
adjustedAvailableXcodes[index].xcodeID = installedXcode.xcodeID
|
||||
}
|
||||
// If an installed version is the same as one that's listed online which doesn't have build metadata, replace it with the installed version
|
||||
// Not all prerelease Apple versions available online include build metadata
|
||||
|
|
@ -497,7 +876,7 @@ class AppState: ObservableObject {
|
|||
availableXcode.version.isEquivalent(to: installedXcode.version) &&
|
||||
availableXcode.version.buildMetadataIdentifiers.isEmpty
|
||||
}) {
|
||||
adjustedAvailableXcodes[index].version = installedXcode.version
|
||||
adjustedAvailableXcodes[index].xcodeID = installedXcode.xcodeID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -514,14 +893,21 @@ class AppState: ObservableObject {
|
|||
// Include this version if there's only one with this build identifier
|
||||
return availableXcodesWithIdenticalBuildIdentifiers.count == 1 ||
|
||||
// Or if there's more than one with this build identifier and this is the release version
|
||||
availableXcodesWithIdenticalBuildIdentifiers.count > 1 && availableXcode.version.prereleaseIdentifiers.isEmpty
|
||||
}
|
||||
|
||||
availableXcodesWithIdenticalBuildIdentifiers.count > 1 && (availableXcode.version.prereleaseIdentifiers.isEmpty || availableXcode.architectures?.count ?? 0 != 0)
|
||||
}
|
||||
.map { availableXcode -> Xcode in
|
||||
let installedXcode = installedXcodes.first(where: { installedXcode in
|
||||
availableXcode.version.isEquivalent(to: installedXcode.version)
|
||||
// if we want to have only specific Xcodes as selected instead of the Architecture Equivalent.
|
||||
// if availableXcode.architectures == nil {
|
||||
// return availableXcode.version.isEquivalent(to: installedXcode.version)
|
||||
// } else {
|
||||
// return availableXcode.xcodeID == installedXcode.xcodeID
|
||||
// }
|
||||
return availableXcode.version.isEquivalent(to: installedXcode.version)
|
||||
})
|
||||
|
||||
let identicalBuilds: [Version]
|
||||
let identicalBuilds: [XcodeID]
|
||||
let prereleaseAvailableXcodesWithIdenticalBuildIdentifiers = availableXcodes
|
||||
.filter {
|
||||
return $0.version.buildMetadataIdentifiers == availableXcode.version.buildMetadataIdentifiers &&
|
||||
|
|
@ -531,13 +917,13 @@ class AppState: ObservableObject {
|
|||
}
|
||||
// If this is the release version, add the identical builds to it
|
||||
if !prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.isEmpty, availableXcode.version.prereleaseIdentifiers.isEmpty {
|
||||
identicalBuilds = [availableXcode.version] + prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.map(\.version)
|
||||
identicalBuilds = [availableXcode.xcodeID] + prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.map(\.xcodeID)
|
||||
} else {
|
||||
identicalBuilds = []
|
||||
}
|
||||
|
||||
// If the existing install state is "installing", keep it
|
||||
let existingXcodeInstallState = allXcodes.first { $0.version == availableXcode.version && $0.installState.installing }?.installState
|
||||
let existingXcodeInstallState = allXcodes.first { $0.id == availableXcode.xcodeID && $0.installState.installing }?.installState
|
||||
// Otherwise, determine it from whether there's an installed Xcode
|
||||
let defaultXcodeInstallState: XcodeInstallState = installedXcode.map { .installed($0.path) } ?? .notInstalled
|
||||
|
||||
|
|
@ -552,7 +938,8 @@ class AppState: ObservableObject {
|
|||
releaseDate: availableXcode.releaseDate,
|
||||
sdks: availableXcode.sdks,
|
||||
compilers: availableXcode.compilers,
|
||||
downloadFileSize: availableXcode.fileSize
|
||||
downloadFileSize: availableXcode.fileSize,
|
||||
architectures: availableXcode.architectures
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -574,6 +961,7 @@ class AppState: ObservableObject {
|
|||
|
||||
self.allXcodes = newAllXcodes.sorted { $0.version > $1.version }
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
|
|
@ -607,10 +995,10 @@ class AppState: ObservableObject {
|
|||
var message: String
|
||||
var id: String { title + message }
|
||||
}
|
||||
}
|
||||
|
||||
struct SecondFactorData {
|
||||
let option: TwoFactorOption
|
||||
let authOptions: AuthOptionsResponse
|
||||
let sessionData: AppleSessionData
|
||||
extension OperatingSystemVersion {
|
||||
func versionString() -> String {
|
||||
return String(majorVersion) + "." + String(minorVersion) + "." + String(patchVersion)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import Foundation
|
||||
import Version
|
||||
import struct XCModel.SDKs
|
||||
import struct XCModel.Compilers
|
||||
import XcodesKit
|
||||
|
||||
/// A version of Xcode that's available for installation
|
||||
public struct AvailableXcode: Codable {
|
||||
public var version: Version
|
||||
public var version: Version {
|
||||
return xcodeID.version
|
||||
}
|
||||
public let url: URL
|
||||
public let filename: String
|
||||
public let releaseDate: Date?
|
||||
|
|
@ -14,6 +15,11 @@ public struct AvailableXcode: Codable {
|
|||
public let sdks: SDKs?
|
||||
public let compilers: Compilers?
|
||||
public let fileSize: Int64?
|
||||
public let architectures: [Architecture]?
|
||||
public var downloadPath: String {
|
||||
return url.path
|
||||
}
|
||||
public var xcodeID: XcodeID
|
||||
|
||||
public init(
|
||||
version: Version,
|
||||
|
|
@ -24,9 +30,9 @@ public struct AvailableXcode: Codable {
|
|||
releaseNotesURL: URL? = nil,
|
||||
sdks: SDKs? = nil,
|
||||
compilers: Compilers? = nil,
|
||||
fileSize: Int64? = nil
|
||||
fileSize: Int64? = nil,
|
||||
architectures: [Architecture]? = nil
|
||||
) {
|
||||
self.version = version
|
||||
self.url = url
|
||||
self.filename = filename
|
||||
self.releaseDate = releaseDate
|
||||
|
|
@ -35,5 +41,7 @@ public struct AvailableXcode: Codable {
|
|||
self.sdks = sdks
|
||||
self.compilers = compilers
|
||||
self.fileSize = fileSize
|
||||
self.architectures = architectures
|
||||
self.xcodeID = XcodeID(version: version, architectures: architectures)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
Xcodes/Backend/Collection+.swift
Normal file
17
Xcodes/Backend/Collection+.swift
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
//
|
||||
// Collection+.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Matt Kiazyk on 2022-04-11.
|
||||
// Copyright © 2022 Robots and Pencils. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Collection {
|
||||
|
||||
/// Returns the element at the specified index iff it is within bounds, otherwise nil.
|
||||
subscript (safe index: Index) -> Element? {
|
||||
return indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
|
|
@ -14,4 +14,6 @@ public enum DataSource: String, CaseIterable, Identifiable, CustomStringConverti
|
|||
case .xcodeReleases: return "Xcode Releases"
|
||||
}
|
||||
}
|
||||
|
||||
var isManaged: Bool { PreferenceKey.dataSource.isManaged() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ extension DateFormatter {
|
|||
static let downloadsDateModified: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MM/dd/yy HH:mm"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.calendar = .init(identifier: .iso8601)
|
||||
return formatter
|
||||
}()
|
||||
|
||||
|
|
|
|||
|
|
@ -13,4 +13,6 @@ public enum Downloader: String, CaseIterable, Identifiable, CustomStringConverti
|
|||
case .aria2: return "aria2"
|
||||
}
|
||||
}
|
||||
|
||||
var isManaged: Bool { PreferenceKey.downloader.isManaged() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,13 @@ import Path
|
|||
import Version
|
||||
|
||||
struct Downloads: Codable {
|
||||
let downloads: [Download]
|
||||
let resultCode: Int
|
||||
let resultsString: String?
|
||||
let downloads: [Download]?
|
||||
|
||||
var hasError: Bool {
|
||||
return resultCode != 0
|
||||
}
|
||||
}
|
||||
|
||||
// Set to Int64 as ByteCountFormatter uses it.
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
import Foundation
|
||||
import Path
|
||||
|
||||
extension Entry {
|
||||
var isAppBundle: Bool {
|
||||
kind == .directory &&
|
||||
extension Path {
|
||||
static func isAppBundle(path: Path) -> Bool {
|
||||
path.isDirectory &&
|
||||
path.extension == "app" &&
|
||||
!path.isSymlink
|
||||
}
|
||||
|
||||
var infoPlist: InfoPlist? {
|
||||
static func infoPlist(path: Path) -> InfoPlist? {
|
||||
let infoPlistPath = path.join("Contents").join("Info.plist")
|
||||
guard
|
||||
let infoPlistData = try? Data(contentsOf: infoPlistPath.url),
|
||||
|
|
@ -17,4 +16,12 @@ extension Entry {
|
|||
|
||||
return infoPlist
|
||||
}
|
||||
|
||||
var isAppBundle: Bool {
|
||||
Path.isAppBundle(path: self)
|
||||
}
|
||||
|
||||
var infoPlist: InfoPlist? {
|
||||
Path.infoPlist(path: self)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import Foundation
|
|||
import Path
|
||||
import AppleAPI
|
||||
import KeychainAccess
|
||||
|
||||
import XcodesKit
|
||||
/**
|
||||
Lightweight dependency injection using global mutable state :P
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ public struct Shell {
|
|||
"--max-connection-per-server=16",
|
||||
"--split=16",
|
||||
"--summary-interval=1",
|
||||
"--stop-with-process=\(ProcessInfo.processInfo.processIdentifier)",
|
||||
"--stop-with-process=\(ProcessInfo.processInfo.processIdentifier)", // if xcodes quits, stop aria2 process
|
||||
"--dir=\(destination.parent.string)",
|
||||
"--out=\(destination.basename())",
|
||||
"--human-readable=false", // sets the output to use bytes instead of formatting
|
||||
|
|
@ -111,6 +111,169 @@ public struct Shell {
|
|||
|
||||
return (progress, publisher)
|
||||
}
|
||||
|
||||
public var downloadWithAria2Async: (Path, URL, Path, [HTTPCookie]) -> AsyncThrowingStream<Progress, Error> = { aria2Path, url, destination, cookies in
|
||||
return AsyncThrowingStream<Progress, Error> { continuation in
|
||||
|
||||
Task {
|
||||
// Assume progress will not have data races, so we manually opt-out isolation checks.
|
||||
nonisolated(unsafe) var progress = Progress()
|
||||
progress.kind = .file
|
||||
progress.fileOperationKind = .downloading
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = aria2Path.url
|
||||
process.arguments = [
|
||||
"--header=Cookie: \(cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; "))",
|
||||
"--max-connection-per-server=16",
|
||||
"--split=16",
|
||||
"--summary-interval=1",
|
||||
"--stop-with-process=\(ProcessInfo.processInfo.processIdentifier)", // if xcodes quits, stop aria2 process
|
||||
"--dir=\(destination.parent.string)",
|
||||
"--out=\(destination.basename())",
|
||||
"--human-readable=false", // sets the output to use bytes instead of formatting
|
||||
url.absoluteString,
|
||||
]
|
||||
let stdOutPipe = Pipe()
|
||||
process.standardOutput = stdOutPipe
|
||||
let stdErrPipe = Pipe()
|
||||
process.standardError = stdErrPipe
|
||||
|
||||
let observer = NotificationCenter.default.addObserver(
|
||||
forName: .NSFileHandleDataAvailable,
|
||||
object: nil,
|
||||
queue: OperationQueue.main
|
||||
) { note in
|
||||
guard
|
||||
// This should always be the case for Notification.Name.NSFileHandleDataAvailable
|
||||
let handle = note.object as? FileHandle,
|
||||
handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading
|
||||
else { return }
|
||||
|
||||
defer { handle.waitForDataInBackgroundAndNotify() }
|
||||
|
||||
let string = String(decoding: handle.availableData, as: UTF8.self)
|
||||
// TODO: fix warning. ObservingProgressView is currently tied to an updating progress
|
||||
progress.updateFromAria2(string: string)
|
||||
|
||||
continuation.yield(progress)
|
||||
}
|
||||
|
||||
stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
|
||||
stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
|
||||
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
process.terminate()
|
||||
NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
|
||||
process.waitUntilExit()
|
||||
|
||||
NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)
|
||||
|
||||
guard process.terminationReason == .exit, process.terminationStatus == 0 else {
|
||||
if let aria2cError = Aria2CError(exitStatus: process.terminationStatus) {
|
||||
continuation.finish(throwing: aria2cError)
|
||||
} else {
|
||||
continuation.finish(throwing: ProcessExecutionError(process: process, standardOutput: "", standardError: ""))
|
||||
}
|
||||
return
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public var unxipExperiment: (URL) -> AnyPublisher<ProcessOutput, Error> = { url in
|
||||
let unxipPath = Path(url: Bundle.main.url(forAuxiliaryExecutable: "unxip")!)!
|
||||
return Process.run(unxipPath.url, workingDirectory: url.deletingLastPathComponent(), ["\(url.path)"])
|
||||
}
|
||||
|
||||
public var downloadRuntime: (String, String, String?) -> AsyncThrowingStream<Progress, Error> = { platform, version, architecture in
|
||||
return AsyncThrowingStream<Progress, Error> { continuation in
|
||||
Task {
|
||||
// Assume progress will not have data races, so we manually opt-out isolation checks.
|
||||
nonisolated(unsafe) var progress = Progress()
|
||||
progress.kind = .file
|
||||
progress.fileOperationKind = .downloading
|
||||
|
||||
var process = Process()
|
||||
let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild").url
|
||||
|
||||
process.executableURL = xcodeBuildPath
|
||||
process.arguments = [
|
||||
"-downloadPlatform",
|
||||
"\(platform)",
|
||||
"-buildVersion",
|
||||
"\(version)"
|
||||
]
|
||||
|
||||
if let architecture {
|
||||
process.arguments?.append(contentsOf: [
|
||||
"-architectureVariant",
|
||||
"\(architecture)"
|
||||
])
|
||||
}
|
||||
|
||||
let stdOutPipe = Pipe()
|
||||
process.standardOutput = stdOutPipe
|
||||
let stdErrPipe = Pipe()
|
||||
process.standardError = stdErrPipe
|
||||
|
||||
let observer = NotificationCenter.default.addObserver(
|
||||
forName: .NSFileHandleDataAvailable,
|
||||
object: nil,
|
||||
queue: OperationQueue.main
|
||||
) { note in
|
||||
guard
|
||||
// This should always be the case for Notification.Name.NSFileHandleDataAvailable
|
||||
let handle = note.object as? FileHandle,
|
||||
handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading
|
||||
else { return }
|
||||
|
||||
defer { handle.waitForDataInBackgroundAndNotify() }
|
||||
|
||||
let string = String(decoding: handle.availableData, as: UTF8.self)
|
||||
|
||||
// TODO: fix warning. ObservingProgressView is currently tied to an updating progress
|
||||
progress.updateFromXcodebuild(text: string)
|
||||
|
||||
continuation.yield(progress)
|
||||
}
|
||||
|
||||
stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
|
||||
stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
|
||||
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
process.terminate()
|
||||
NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
|
||||
process.waitUntilExit()
|
||||
|
||||
NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)
|
||||
|
||||
guard process.terminationReason == .exit, process.terminationStatus == 0 else {
|
||||
continuation.finish(throwing: ProcessExecutionError(process: process, standardOutput: "", standardError: ""))
|
||||
return
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct Files {
|
||||
|
|
@ -158,11 +321,26 @@ public struct Files {
|
|||
}
|
||||
|
||||
public var installedXcodes = _installedXcodes
|
||||
|
||||
public func installedXcode(destination: Path) -> InstalledXcode? {
|
||||
if Path.isAppBundle(path: destination) && Path.infoPlist(path: destination)?.bundleID == "com.apple.dt.Xcode" {
|
||||
return InstalledXcode.init(path: destination)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public var write: (Data, URL) throws -> Void = { try $0.write(to: $1) }
|
||||
|
||||
public func write(_ data: Data, to url: URL) throws {
|
||||
try write(data, url)
|
||||
}
|
||||
}
|
||||
|
||||
private func _installedXcodes(destination: Path) -> [InstalledXcode] {
|
||||
((try? destination.ls()) ?? [])
|
||||
destination.ls()
|
||||
.filter { $0.isAppBundle && $0.infoPlist?.bundleID == "com.apple.dt.Xcode" }
|
||||
.map { $0.path }
|
||||
.map { $0 }
|
||||
.compactMap(InstalledXcode.init)
|
||||
}
|
||||
|
||||
|
|
@ -174,10 +352,15 @@ public struct Network {
|
|||
.mapError { $0 as Error }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public func dataTask(with request: URLRequest) -> AnyPublisher<URLSession.DataTaskPublisher.Output, Error> {
|
||||
dataTask(request)
|
||||
}
|
||||
|
||||
|
||||
public func dataTaskAsync(with request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
return try await AppleAPI.Current.network.session.data(for: request)
|
||||
}
|
||||
|
||||
public var downloadTask: (URL, URL, Data?) -> (Progress, AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) = { AppleAPI.Current.network.session.downloadTask(with: $0, to: $1, resumingWith: $2) }
|
||||
|
||||
public func downloadTask(with url: URL, to saveLocation: URL, resumingWith resumeData: Data?) -> (progress: Progress, publisher: AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) {
|
||||
|
|
@ -238,6 +421,11 @@ public struct Defaults {
|
|||
public func get(forKey key: String) -> Any? {
|
||||
get(key)
|
||||
}
|
||||
|
||||
public var bool: (String) -> Bool? = { UserDefaults.standard.bool(forKey: $0) }
|
||||
public func bool(forKey key: String) -> Bool? {
|
||||
bool(key)
|
||||
}
|
||||
}
|
||||
|
||||
private let helperClient = HelperClient()
|
||||
|
|
|
|||
23
Xcodes/Backend/FileError.swift
Normal file
23
Xcodes/Backend/FileError.swift
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
//
|
||||
// FileError.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Leon Wolf on 06.10.22.
|
||||
// Copyright © 2022 Robots and Pencils. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import LegibleError
|
||||
|
||||
enum FileError: LocalizedError{
|
||||
case fileNotFound(_ fileName: String)
|
||||
}
|
||||
|
||||
extension FileError {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .fileNotFound(let fileName):
|
||||
return String(format: localizeString("Alert.Uninstall.Error.Message.FileNotFound"), fileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,11 @@ extension FileManager {
|
|||
@discardableResult
|
||||
func trashItem(at url: URL) throws -> URL {
|
||||
var resultingItemURL: NSURL!
|
||||
try trashItem(at: url, resultingItemURL: &resultingItemURL)
|
||||
if fileExists(atPath: url.path) {
|
||||
try trashItem(at: url, resultingItemURL: &resultingItemURL)
|
||||
} else {
|
||||
throw FileError.fileNotFound(url.lastPathComponent)
|
||||
}
|
||||
return resultingItemURL as URL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
Xcodes/Backend/Hardware.swift
Normal file
28
Xcodes/Backend/Hardware.swift
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import Foundation
|
||||
|
||||
|
||||
struct Hardware {
|
||||
|
||||
///
|
||||
/// Determines the architecture of the Mac on which we're running. Returns `arm64` for Apple Silicon
|
||||
/// and `x86_64` for Intel-based Macs or `nil` if the system call fails.
|
||||
static func getMachineHardwareName() -> String?
|
||||
{
|
||||
var sysInfo = utsname()
|
||||
let retVal = uname(&sysInfo)
|
||||
var finalString: String? = nil
|
||||
|
||||
if retVal == EXIT_SUCCESS
|
||||
{
|
||||
let bytes = Data(bytes: &sysInfo.machine, count: Int(_SYS_NAMELEN))
|
||||
finalString = String(data: bytes, encoding: .utf8)
|
||||
}
|
||||
|
||||
// _SYS_NAMELEN will include a billion null-terminators. Clear those out so string comparisons work as you expect.
|
||||
return finalString?.trimmingCharacters(in: CharacterSet(charactersIn: "\0"))
|
||||
}
|
||||
|
||||
static func isAppleSilicon() -> Bool {
|
||||
return Hardware.getMachineHardwareName() == "arm64"
|
||||
}
|
||||
}
|
||||
|
|
@ -361,7 +361,7 @@ enum HelperClientError: LocalizedError {
|
|||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .failedToCreateRemoteObjectProxy:
|
||||
return "Unable to communicate with privileged helper."
|
||||
return localizeString("HelperClient.error")
|
||||
case let .message(message):
|
||||
return message
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
/// A numbered step
|
||||
enum InstallationStep: Equatable, CustomStringConvertible {
|
||||
case downloading(progress: Progress)
|
||||
case unarchiving
|
||||
case moving(destination: String)
|
||||
case trashingArchive
|
||||
case checkingSecurity
|
||||
case finishing
|
||||
|
||||
var description: String {
|
||||
"(\(stepNumber)/\(stepCount)) \(message)"
|
||||
}
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .downloading:
|
||||
return "Downloading"
|
||||
case .unarchiving:
|
||||
return "Unarchiving (This can take a while)"
|
||||
case .moving(let destination):
|
||||
return "Moving to \(destination)"
|
||||
case .trashingArchive:
|
||||
return "Moving archive to the Trash"
|
||||
case .checkingSecurity:
|
||||
return "Security verification"
|
||||
case .finishing:
|
||||
return "Finishing"
|
||||
}
|
||||
}
|
||||
|
||||
var stepNumber: Int {
|
||||
switch self {
|
||||
case .downloading: return 1
|
||||
case .unarchiving: return 2
|
||||
case .moving: return 3
|
||||
case .trashingArchive: return 4
|
||||
case .checkingSecurity: return 5
|
||||
case .finishing: return 6
|
||||
}
|
||||
}
|
||||
|
||||
var stepCount: Int { 6 }
|
||||
}
|
||||
|
|
@ -1,12 +1,17 @@
|
|||
import Foundation
|
||||
import Version
|
||||
import Path
|
||||
import XcodesKit
|
||||
|
||||
/// A version of Xcode that's already installed
|
||||
public struct InstalledXcode: Equatable {
|
||||
public let path: Path
|
||||
public let xcodeID: XcodeID
|
||||
|
||||
/// Composed of the bundle short version from Info.plist and the product build version from version.plist
|
||||
public let version: Version
|
||||
public var version: Version {
|
||||
return xcodeID.version
|
||||
}
|
||||
|
||||
public init?(path: Path) {
|
||||
self.path = path
|
||||
|
|
@ -31,12 +36,21 @@ public struct InstalledXcode: Equatable {
|
|||
else if infoPlist.bundleIconName == "XcodeBeta", !prereleaseIdentifiers.contains("beta") {
|
||||
prereleaseIdentifiers = ["beta"]
|
||||
}
|
||||
|
||||
self.version = Version(major: bundleVersion.major,
|
||||
|
||||
let archsString = try? XcodesKit.Current.shell.archs(path.url.appending(path: "Contents/MacOS/Xcode")).out
|
||||
|
||||
let architectures = archsString?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.split(separator: " ")
|
||||
.compactMap { Architecture(rawValue: String($0)) }
|
||||
|
||||
let version = Version(major: bundleVersion.major,
|
||||
minor: bundleVersion.minor,
|
||||
patch: bundleVersion.patch,
|
||||
prereleaseIdentifiers: prereleaseIdentifiers,
|
||||
buildMetadataIdentifiers: [versionPlist.productBuildVersion].compactMap { $0 })
|
||||
|
||||
self.xcodeID = XcodeID(version: version, architectures: architectures)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
import Foundation
|
||||
import os.log
|
||||
|
||||
extension Logger {
|
||||
private static var subsystem = Bundle.main.bundleIdentifier!
|
||||
|
||||
static let appState = Logger(subsystem: subsystem, category: "appState")
|
||||
static let helperClient = Logger(subsystem: subsystem, category: "helperClient")
|
||||
static let subprocess = Logger(subsystem: subsystem, category: "subprocess")
|
||||
}
|
||||
|
|
@ -22,9 +22,9 @@ public enum XcodesNotificationType: String, Identifiable, CaseIterable, CustomSt
|
|||
public var description: String {
|
||||
switch self {
|
||||
case .newVersionAvailable:
|
||||
return "New version is available"
|
||||
return localizeString("Notification.NewVersionAvailable")
|
||||
case .finishedInstalling:
|
||||
return "Finished installing"
|
||||
return localizeString("Notification.FinishedInstalling")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,4 +16,31 @@ extension Path {
|
|||
static var cacheFile: Path {
|
||||
return xcodesApplicationSupport/"available-xcodes.json"
|
||||
}
|
||||
|
||||
static let defaultInstallDirectory = Path.root/"Applications"
|
||||
|
||||
static var installDirectory: Path {
|
||||
guard let savedInstallDirectory = Current.defaults.string(forKey: "installPath") else {
|
||||
return defaultInstallDirectory
|
||||
}
|
||||
guard let path = Path(savedInstallDirectory) else {
|
||||
return defaultInstallDirectory
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
static var runtimeCacheFile: Path {
|
||||
return xcodesApplicationSupport/"downloadable-runtimes.json"
|
||||
}
|
||||
|
||||
static var xcodesCaches: Path {
|
||||
return caches/"com.xcodesorg.xcodesapp"
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setCurrentUserAsOwner() -> Path {
|
||||
let user = ProcessInfo.processInfo.environment["SUDO_USER"] ?? NSUserName()
|
||||
try? FileManager.default.setAttributes([.ownerAccountName: user], ofItemAtPath: string)
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@ import Combine
|
|||
import Foundation
|
||||
import os.log
|
||||
import Path
|
||||
import XcodesKit
|
||||
|
||||
public typealias ProcessOutput = (status: Int32, out: String, err: String)
|
||||
|
||||
extension Process {
|
||||
@discardableResult
|
||||
static func run(_ executable: Path, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) -> AnyPublisher<ProcessOutput, Error> {
|
||||
static func run(_ executable: any Pathish, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) -> AnyPublisher<ProcessOutput, Error> {
|
||||
return run(executable.url, workingDirectory: workingDirectory, input: input, arguments)
|
||||
}
|
||||
|
||||
|
|
@ -67,9 +68,3 @@ extension Process {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
struct ProcessExecutionError: Error {
|
||||
let process: Process
|
||||
let standardOutput: String
|
||||
let standardError: String
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,5 +70,38 @@ extension Progress {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
func updateFromXcodebuild(text: String) {
|
||||
self.totalUnitCount = 100
|
||||
self.completedUnitCount = 0
|
||||
self.localizedAdditionalDescription = "" // to not show the addtional
|
||||
|
||||
do {
|
||||
|
||||
let downloadPattern = #"(\d+\.\d+)% \(([\d.]+ (?:MB|GB)) of ([\d.]+ GB)\)"#
|
||||
let downloadRegex = try NSRegularExpression(pattern: downloadPattern)
|
||||
|
||||
// Search for matches in the text
|
||||
if let match = downloadRegex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) {
|
||||
// Extract the percentage - simpler then trying to extract size MB/GB and convert to bytes.
|
||||
if let percentRange = Range(match.range(at: 1), in: text), let percentDouble = Double(text[percentRange]) {
|
||||
let percent = Int64(percentDouble.rounded())
|
||||
self.completedUnitCount = percent
|
||||
}
|
||||
}
|
||||
|
||||
// "Downloading tvOS 18.1 Simulator (22J5567a): Installing..." or
|
||||
// "Downloading tvOS 18.1 Simulator (22J5567a): Installing (registering download)..."
|
||||
if text.range(of: "Installing") != nil {
|
||||
// sets the progress to indeterminite to show animating progress
|
||||
self.totalUnitCount = 0
|
||||
self.completedUnitCount = 0
|
||||
}
|
||||
|
||||
} catch {
|
||||
Logger.appState.error("Invalid regular expression")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
57
Xcodes/Backend/SDKs+Xcode.swift
Normal file
57
Xcodes/Backend/SDKs+Xcode.swift
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
//
|
||||
// SDKs+Xcode.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Matt Kiazyk on 2023-06-05.
|
||||
// Copyright © 2023 Robots and Pencils. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import XcodesKit
|
||||
import SwiftUI
|
||||
|
||||
extension SDKs {
|
||||
/// Loops through all SDK's and returns an array of buildNumbers (to be used to correlate runtimes)
|
||||
func allBuilds() -> [String] {
|
||||
var buildNumbers: [String] = []
|
||||
|
||||
if let iOS = self.iOS?.compactMap({ $0.build }) {
|
||||
buildNumbers += iOS
|
||||
}
|
||||
if let tvOS = self.tvOS?.compactMap({ $0.build }) {
|
||||
buildNumbers += tvOS
|
||||
}
|
||||
if let macOS = self.macOS?.compactMap({ $0.build }) {
|
||||
buildNumbers += macOS
|
||||
}
|
||||
if let watchOS = self.watchOS?.compactMap({ $0.build }) {
|
||||
buildNumbers += watchOS
|
||||
}
|
||||
if let visionOS = self.visionOS?.compactMap({ $0.build }) {
|
||||
buildNumbers += visionOS
|
||||
}
|
||||
|
||||
return buildNumbers
|
||||
}
|
||||
}
|
||||
|
||||
extension DownloadableRuntime {
|
||||
func icon() -> Image {
|
||||
switch self.platform {
|
||||
case .iOS:
|
||||
return Image(systemName: "iphone")
|
||||
case .macOS:
|
||||
return Image(systemName: "macwindow")
|
||||
case .watchOS:
|
||||
return Image(systemName: "applewatch")
|
||||
case .tvOS:
|
||||
return Image(systemName: "appletv")
|
||||
case .visionOS:
|
||||
if #available(macOS 14, *) {
|
||||
return Image(systemName: "visionpro")
|
||||
} else {
|
||||
return Image(systemName: "eyeglasses")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
Xcodes/Backend/SelectedActionType.swift
Normal file
31
Xcodes/Backend/SelectedActionType.swift
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
//
|
||||
// SelectedActionType.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Matt Kiazyk on 2022-07-24.
|
||||
// Copyright © 2022 Robots and Pencils. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
public enum SelectedActionType: String, CaseIterable, Identifiable, CustomStringConvertible {
|
||||
case none
|
||||
case rename
|
||||
|
||||
public var id: Self { self }
|
||||
|
||||
public static var `default` = SelectedActionType.none
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .none: return localizeString("OnSelectDoNothing")
|
||||
case .rename: return localizeString("OnSelectRenameXcode")
|
||||
}
|
||||
}
|
||||
|
||||
public var detailedDescription: String {
|
||||
switch self {
|
||||
case .none: return localizeString("OnSelectDoNothingDescription")
|
||||
case .rename: return localizeString("OnSelectRenameXcodeDescription")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ extension URL {
|
|||
static let download = URL(string: "https://developer.apple.com/download")!
|
||||
static let downloads = URL(string: "https://developer.apple.com/services-account/QH65B2/downloadws/listDownloads.action")!
|
||||
static let downloadXcode = URL(string: "https://developer.apple.com/devcenter/download.action")!
|
||||
static let downloadADCAuth = URL(string: "https://developerservices2.apple.com/services/download")!
|
||||
}
|
||||
|
||||
extension URLRequest {
|
||||
|
|
@ -25,4 +26,13 @@ extension URLRequest {
|
|||
request.allHTTPHeaderFields?["Accept"] = "*/*"
|
||||
return request
|
||||
}
|
||||
|
||||
static func downloadADCAuth(path: String) -> URLRequest {
|
||||
var components = URLComponents(url: .downloadADCAuth, resolvingAgainstBaseURL: false)!
|
||||
components.queryItems = [URLQueryItem(name: "path", value: path)]
|
||||
var request = URLRequest(url: components.url!)
|
||||
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
|
||||
request.allHTTPHeaderFields?["Accept"] = "*/*"
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import Version
|
||||
import struct XCModel.Xcode
|
||||
import XcodesKit
|
||||
|
||||
extension Version {
|
||||
/// Initialize a Version from an XcodeReleases' XCModel.Xcode
|
||||
///
|
||||
/// This is kinda quick-and-dirty, and it would probably be better for us to adopt something closer to XCModel.Xcode under the hood and map the scraped data to it instead.
|
||||
init?(xcReleasesXcode: XCModel.Xcode) {
|
||||
init?(xcReleasesXcode: XcodeRelease) {
|
||||
var versionString = xcReleasesXcode.version.number ?? ""
|
||||
|
||||
// Append trailing ".0" in order to get a fully-specified version string
|
||||
|
|
|
|||
|
|
@ -1,14 +1,30 @@
|
|||
import AppKit
|
||||
import Foundation
|
||||
import Version
|
||||
import struct XCModel.SDKs
|
||||
import struct XCModel.Compilers
|
||||
import Path
|
||||
import XcodesKit
|
||||
|
||||
public struct XcodeID: Codable, Hashable, Identifiable {
|
||||
public let version: Version
|
||||
public let architectures: [Architecture]?
|
||||
|
||||
public var id: String {
|
||||
let architectures = architectures?.map { $0.rawValue}.joined() ?? ""
|
||||
return version.description + architectures
|
||||
}
|
||||
|
||||
public init(version: Version, architectures: [Architecture]? = nil) {
|
||||
self.version = version
|
||||
self.architectures = architectures
|
||||
}
|
||||
}
|
||||
|
||||
struct Xcode: Identifiable, CustomStringConvertible {
|
||||
let version: Version
|
||||
var version: Version {
|
||||
return id.version
|
||||
}
|
||||
/// Other Xcode versions that have the same build identifier
|
||||
let identicalBuilds: [Version]
|
||||
let identicalBuilds: [XcodeID]
|
||||
var installState: XcodeInstallState
|
||||
let selected: Bool
|
||||
let icon: NSImage?
|
||||
|
|
@ -18,10 +34,12 @@ struct Xcode: Identifiable, CustomStringConvertible {
|
|||
let sdks: SDKs?
|
||||
let compilers: Compilers?
|
||||
let downloadFileSize: Int64?
|
||||
let architectures: [Architecture]?
|
||||
let id: XcodeID
|
||||
|
||||
init(
|
||||
version: Version,
|
||||
identicalBuilds: [Version] = [],
|
||||
identicalBuilds: [XcodeID] = [],
|
||||
installState: XcodeInstallState,
|
||||
selected: Bool,
|
||||
icon: NSImage?,
|
||||
|
|
@ -30,9 +48,9 @@ struct Xcode: Identifiable, CustomStringConvertible {
|
|||
releaseDate: Date? = nil,
|
||||
sdks: SDKs? = nil,
|
||||
compilers: Compilers? = nil,
|
||||
downloadFileSize: Int64? = nil
|
||||
downloadFileSize: Int64? = nil,
|
||||
architectures: [Architecture]? = nil
|
||||
) {
|
||||
self.version = version
|
||||
self.identicalBuilds = identicalBuilds
|
||||
self.installState = installState
|
||||
self.selected = selected
|
||||
|
|
@ -43,10 +61,10 @@ struct Xcode: Identifiable, CustomStringConvertible {
|
|||
self.sdks = sdks
|
||||
self.compilers = compilers
|
||||
self.downloadFileSize = downloadFileSize
|
||||
self.architectures = architectures
|
||||
self.id = XcodeID(version: version, architectures: architectures)
|
||||
}
|
||||
|
||||
var id: Version { version }
|
||||
|
||||
var description: String {
|
||||
version.appleDescription
|
||||
}
|
||||
|
|
@ -67,4 +85,5 @@ struct Xcode: Identifiable, CustomStringConvertible {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import SwiftUI
|
||||
import XcodesKit
|
||||
|
||||
// MARK: - CommandMenu
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ struct XcodeCommands: Commands {
|
|||
OpenCommand()
|
||||
RevealCommand()
|
||||
CopyPathCommand()
|
||||
CreateSymbolicLinkCommand()
|
||||
|
||||
Divider()
|
||||
|
||||
|
|
@ -33,18 +35,21 @@ struct XcodeCommands: Commands {
|
|||
|
||||
struct InstallButton: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
||||
let xcode: Xcode?
|
||||
|
||||
|
||||
var body: some View {
|
||||
Button(action: install) {
|
||||
Button {
|
||||
install()
|
||||
} label: {
|
||||
Text("Install")
|
||||
.help("Install")
|
||||
.help("InstallDescription")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func install() {
|
||||
guard let xcode = xcode else { return }
|
||||
appState.install(id: xcode.id)
|
||||
appState.checkMinVersionAndInstall(id: xcode.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,9 +59,10 @@ struct CancelInstallButton: View {
|
|||
|
||||
var body: some View {
|
||||
Button(action: cancelInstall) {
|
||||
Text("Cancel")
|
||||
.help("Stop installation")
|
||||
Label("Cancel", systemImage: "xmark")
|
||||
}
|
||||
.help(localizeString("StopInstallation"))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func cancelInstall() {
|
||||
|
|
@ -65,6 +71,23 @@ struct CancelInstallButton: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct CancelRuntimeInstallButton: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
let runtime: DownloadableRuntime?
|
||||
|
||||
var body: some View {
|
||||
Button(action: cancelInstall) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
}.help(localizeString("StopInstallation"))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func cancelInstall() {
|
||||
guard let runtime = runtime else { return }
|
||||
appState.presentedAlert = .cancelRuntimeInstall(runtime: runtime)
|
||||
}
|
||||
}
|
||||
|
||||
struct SelectButton: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
let xcode: Xcode?
|
||||
|
|
@ -74,7 +97,7 @@ struct SelectButton: View {
|
|||
if xcode?.selected == true {
|
||||
Text("Active")
|
||||
} else {
|
||||
Text("Make active")
|
||||
Text("MakeActive")
|
||||
}
|
||||
}
|
||||
.disabled(xcode?.selected != false)
|
||||
|
|
@ -91,16 +114,34 @@ struct OpenButton: View {
|
|||
@EnvironmentObject var appState: AppState
|
||||
let xcode: Xcode?
|
||||
|
||||
var openInRosetta: Bool {
|
||||
appState.showOpenInRosettaOption && Hardware.isAppleSilicon()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: open) {
|
||||
Text("Open")
|
||||
if openInRosetta {
|
||||
Menu("Open") {
|
||||
Button(action: open) {
|
||||
Text("Open")
|
||||
}
|
||||
.help("Open")
|
||||
Button(action: open) {
|
||||
Text("Open In Rosetta")
|
||||
}
|
||||
.help("Open In Rosetta")
|
||||
}
|
||||
} else {
|
||||
Button(action: open) {
|
||||
Text("Open")
|
||||
}
|
||||
.help("Open")
|
||||
}
|
||||
.help("Open")
|
||||
|
||||
}
|
||||
|
||||
private func open() {
|
||||
guard let xcode = xcode else { return }
|
||||
appState.open(xcode: xcode)
|
||||
appState.open(xcode: xcode, openInRosetta: openInRosetta)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -125,14 +166,14 @@ struct RevealButton: View {
|
|||
|
||||
var body: some View {
|
||||
Button(action: reveal) {
|
||||
Text("Reveal in Finder")
|
||||
Text("RevealInFinder")
|
||||
}
|
||||
.help("Reveal in Finder")
|
||||
.help("RevealInFinder")
|
||||
}
|
||||
|
||||
private func reveal() {
|
||||
guard let xcode = xcode else { return }
|
||||
appState.reveal(xcode: xcode)
|
||||
appState.reveal(xcode.installedPath)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,9 +183,9 @@ struct CopyPathButton: View {
|
|||
|
||||
var body: some View {
|
||||
Button(action: copyPath) {
|
||||
Text("Copy Path")
|
||||
Text("CopyPath")
|
||||
}
|
||||
.help("Copy path")
|
||||
.help("CopyPath")
|
||||
}
|
||||
|
||||
private func copyPath() {
|
||||
|
|
@ -153,6 +194,76 @@ struct CopyPathButton: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct CopyReleaseNoteButton: View {
|
||||
let url: URL?
|
||||
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
||||
var body: some View {
|
||||
Button(action: copyReleaseNote) {
|
||||
Text("CopyReleaseNoteURL")
|
||||
}
|
||||
.help("CopyReleaseNoteURL")
|
||||
}
|
||||
|
||||
private func copyReleaseNote() {
|
||||
guard let url = url else { return }
|
||||
appState.copyReleaseNote(from: url)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct CreateSymbolicLinkButton: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
let xcode: Xcode?
|
||||
|
||||
var body: some View {
|
||||
Button(action: createSymbolicLink) {
|
||||
Text("CreateSymLink")
|
||||
}
|
||||
.help("CreateSymLink")
|
||||
}
|
||||
|
||||
private func createSymbolicLink() {
|
||||
guard let xcode = xcode else { return }
|
||||
appState.createSymbolicLink(xcode: xcode)
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadRuntimeButton: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
let runtime: DownloadableRuntime?
|
||||
|
||||
var body: some View {
|
||||
Button(action: install) {
|
||||
Text("Install")
|
||||
.help("Install")
|
||||
}
|
||||
}
|
||||
|
||||
private func install() {
|
||||
guard let runtime = runtime else { return }
|
||||
appState.downloadRuntime(runtime: runtime)
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateSymbolicBetaLinkButton: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
let xcode: Xcode?
|
||||
|
||||
var body: some View {
|
||||
Button(action: createSymbolicBetaLink) {
|
||||
Text("CreateSymLinkBeta")
|
||||
}
|
||||
.help("CreateSymLinkBeta")
|
||||
}
|
||||
|
||||
private func createSymbolicBetaLink() {
|
||||
guard let xcode = xcode else { return }
|
||||
appState.createSymbolicLink(xcode: xcode, isBeta: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Commands
|
||||
|
||||
struct InstallCommand: View {
|
||||
|
|
@ -225,3 +336,15 @@ struct UninstallCommand: View {
|
|||
.disabled(selectedXcode.unwrapped?.installState.installed != true)
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateSymbolicLinkCommand: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@FocusedValue(\.selectedXcode) private var selectedXcode: SelectedXcode?
|
||||
|
||||
var body: some View {
|
||||
CreateSymbolicLinkButton(xcode: selectedXcode.unwrapped)
|
||||
.keyboardShortcut("s", modifiers: [.command, .option])
|
||||
.disabled(selectedXcode.unwrapped?.installState.installed != true)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import Foundation
|
||||
import Path
|
||||
import XcodesKit
|
||||
|
||||
enum XcodeInstallState: Equatable {
|
||||
case notInstalled
|
||||
case installing(InstallationStep)
|
||||
case installing(XcodeInstallationStep)
|
||||
case installed(Path)
|
||||
|
||||
var notInstalled: Bool {
|
||||
|
|
|
|||
|
|
@ -12,16 +12,13 @@ struct AboutView: View {
|
|||
Text(Bundle.main.bundleName!)
|
||||
.font(.largeTitle)
|
||||
|
||||
Text("Version \(Bundle.main.shortVersion!) (\(Bundle.main.version!))")
|
||||
|
||||
Color.clear
|
||||
.frame(width: 300, height: 16)
|
||||
Text(String(format: localizeString("VersionWithBuild"), Bundle.main.shortVersion!, Bundle.main.version!))
|
||||
|
||||
HStack(spacing: 32) {
|
||||
Button(action: {
|
||||
openURL(URL(string: "https://github.com/RobotsAndPencils/XcodesApp/")!)
|
||||
}) {
|
||||
Label("GitHub Repo", systemImage: "link")
|
||||
Label("GithubRepo", systemImage: "link")
|
||||
}
|
||||
.buttonStyle(LinkButtonStyle())
|
||||
|
||||
|
|
@ -30,9 +27,36 @@ struct AboutView: View {
|
|||
}
|
||||
.buttonStyle(LinkButtonStyle())
|
||||
}
|
||||
|
||||
Text(Bundle.main.humanReadableCopyright!)
|
||||
.font(.footnote)
|
||||
Color.clear
|
||||
.frame(width: 300, height: 0)
|
||||
Label("UnxipExperiment", systemImage: "lightbulb")
|
||||
HStack(spacing: 32) {
|
||||
Button(action: {
|
||||
openURL(URL(string: "https://github.com/saagarjha/unxip/")!)
|
||||
}) {
|
||||
Label("GithubRepo", systemImage: "link")
|
||||
}
|
||||
.buttonStyle(LinkButtonStyle())
|
||||
|
||||
Button(action: {
|
||||
openURL(URL(string: "https://github.com/saagarjha/unxip/blob/main/LICENSE")!)
|
||||
}) {
|
||||
Label("License", systemImage: "link")
|
||||
}
|
||||
.buttonStyle(LinkButtonStyle())
|
||||
}
|
||||
HStack {
|
||||
Text(Bundle.main.humanReadableCopyright!)
|
||||
.font(.footnote)
|
||||
Button(action: {
|
||||
openURL(URL(string: "https://opencollective.com/xcodesapp")!)
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "heart.circle")
|
||||
Text("Support.Xcodes")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ struct AcknowledgmentsView: View {
|
|||
)!
|
||||
.addingAttribute(.foregroundColor, value: NSColor.labelColor)
|
||||
)
|
||||
.frame(minWidth: 500, minHeight: 500)
|
||||
.frame(minWidth: 600, minHeight: 500)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
35
Xcodes/Frontend/Common/NavigationSplitViewWrapper.swift
Normal file
35
Xcodes/Frontend/Common/NavigationSplitViewWrapper.swift
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// NavigationSplitViewWrapper.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Matt Kiazyk on 2023-12-12.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct NavigationSplitViewWrapper<Sidebar, Detail>: View where Sidebar: View, Detail: View {
|
||||
private var sidebar: Sidebar
|
||||
private var detail: Detail
|
||||
|
||||
init(
|
||||
@ViewBuilder sidebar: () -> Sidebar,
|
||||
@ViewBuilder detail: () -> Detail
|
||||
) {
|
||||
self.sidebar = sidebar()
|
||||
self.detail = detail()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
if #available(macOS 14, *) {
|
||||
sidebar
|
||||
.navigationSplitViewColumnWidth(min: 290, ideal: 290)
|
||||
} else {
|
||||
sidebar
|
||||
}
|
||||
} detail: {
|
||||
detail
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ public struct ObservingProgressIndicator: View {
|
|||
self.progress = progress
|
||||
cancellable = progress.publisher(for: \.fractionCompleted)
|
||||
.combineLatest(progress.publisher(for: \.localizedAdditionalDescription))
|
||||
.combineLatest(progress.publisher(for: \.isIndeterminate))
|
||||
.throttle(for: 1.0, scheduler: DispatchQueue.main, latest: true)
|
||||
.sink { [weak self] _ in self?.objectWillChange.send() }
|
||||
}
|
||||
|
|
@ -46,11 +47,11 @@ public struct ObservingProgressIndicator: View {
|
|||
isIndeterminate: progress.progress.isIndeterminate,
|
||||
style: style
|
||||
)
|
||||
.help("Downloading: \(Int((progress.progress.fractionCompleted * 100)))% complete")
|
||||
.help(String(format: localizeString("DownloadingPercentDescription"), Int((progress.progress.fractionCompleted * 100))))
|
||||
|
||||
if showsAdditionalDescription, progress.progress.xcodesLocalizedDescription.isEmpty == false {
|
||||
Text(progress.progress.xcodesLocalizedDescription)
|
||||
.font(.subheadline)
|
||||
.font(.subheadline.monospacedDigit())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
|
@ -82,6 +83,18 @@ struct ObservingProgressBar_Previews: PreviewProvider {
|
|||
style: .bar,
|
||||
showsAdditionalDescription: true
|
||||
)
|
||||
|
||||
ObservingProgressIndicator(
|
||||
configure(Progress()) {
|
||||
$0.kind = .file
|
||||
$0.fileOperationKind = .downloading
|
||||
$0.totalUnitCount = 0
|
||||
$0.completedUnitCount = 0
|
||||
},
|
||||
controlSize: .regular,
|
||||
style: .bar,
|
||||
showsAdditionalDescription: true
|
||||
)
|
||||
}
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ struct ProgressIndicator: NSViewRepresentable {
|
|||
nsView.doubleValue = doubleValue
|
||||
nsView.controlSize = controlSize
|
||||
nsView.isIndeterminate = isIndeterminate
|
||||
nsView.usesThreadedAnimation = true
|
||||
|
||||
nsView.style = style
|
||||
nsView.startAnimation(nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
24
Xcodes/Frontend/Common/TagView.swift
Normal file
24
Xcodes/Frontend/Common/TagView.swift
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
//
|
||||
// TagView.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Matt Kiazyk on 2025-06-25.//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TagView: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.primary)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(.quaternary)
|
||||
)
|
||||
}
|
||||
}
|
||||
22
Xcodes/Frontend/Common/TrailingIconLabelStyle.swift
Normal file
22
Xcodes/Frontend/Common/TrailingIconLabelStyle.swift
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
//
|
||||
// TrailingIconLabelStyle.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Daniel Chick on 3/11/24.
|
||||
// Copyright © 2024 Robots and Pencils. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TrailingIconLabelStyle: LabelStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
HStack {
|
||||
configuration.title
|
||||
configuration.icon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LabelStyle where Self == TrailingIconLabelStyle {
|
||||
static var trailingIcon: Self { Self() }
|
||||
}
|
||||
|
|
@ -1,15 +1,36 @@
|
|||
import Foundation
|
||||
import XcodesKit
|
||||
|
||||
enum XcodesAlert: Identifiable {
|
||||
case cancelInstall(xcode: Xcode)
|
||||
case cancelRuntimeInstall(runtime: DownloadableRuntime)
|
||||
case privilegedHelper
|
||||
case generic(title: String, message: String)
|
||||
case checkMinSupportedVersion(xcode: AvailableXcode, macOS: String)
|
||||
case unauthenticated
|
||||
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .cancelInstall: return 1
|
||||
case .privilegedHelper: return 2
|
||||
case .generic: return 3
|
||||
case .checkMinSupportedVersion: return 4
|
||||
case .cancelRuntimeInstall: return 5
|
||||
case .unauthenticated: return 6
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Splitting out alerts that are shown on the preference screen as by default we are showing on the MainWindow()
|
||||
// and users awkwardly switch screens, sometimes losing the preference screen
|
||||
enum XcodesPreferencesAlert: Identifiable {
|
||||
case deletePlatform(runtime: DownloadableRuntime)
|
||||
case generic(title: String, message: String)
|
||||
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .deletePlatform: return 1
|
||||
case .generic: return 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,43 @@
|
|||
import Foundation
|
||||
import AppleAPI
|
||||
|
||||
enum XcodesSheet: Identifiable {
|
||||
case signIn
|
||||
case twoFactor
|
||||
case twoFactor(SecondFactorData)
|
||||
case securityKeyTouchToConfirm
|
||||
|
||||
var id: Int { hashValue }
|
||||
var id: Int { Kind(self).hashValue }
|
||||
|
||||
struct SecondFactorData {
|
||||
let option: TwoFactorOption
|
||||
let authOptions: AuthOptionsResponse
|
||||
let sessionData: AppleSessionData
|
||||
}
|
||||
}
|
||||
|
||||
extension XcodesSheet {
|
||||
private enum Kind: Hashable {
|
||||
case signIn, twoFactor(TwoFactorOption), securityKeyTouchToConfirm
|
||||
|
||||
enum TwoFactorOption {
|
||||
case smsSent
|
||||
case codeSent
|
||||
case smsPendingChoice
|
||||
case securityKeyPin
|
||||
}
|
||||
|
||||
init(_ sheet: XcodesSheet) {
|
||||
switch sheet {
|
||||
case .signIn: self = .signIn
|
||||
case .twoFactor(let data):
|
||||
switch data.option {
|
||||
case .smsSent: self = .twoFactor(.smsSent)
|
||||
case .smsPendingChoice: self = .twoFactor(.smsPendingChoice)
|
||||
case .codeSent: self = .twoFactor(.codeSent)
|
||||
case .securityKey: self = .twoFactor(.securityKeyPin)
|
||||
}
|
||||
case .securityKeyTouchToConfirm: self = .securityKeyTouchToConfirm
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
Xcodes/Frontend/InfoPane/CompatibilityView.swift
Normal file
43
Xcodes/Frontend/InfoPane/CompatibilityView.swift
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
//
|
||||
// CompatibilityView.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Duong Thai on 13/10/2023.
|
||||
// Copyright © 2023 Robots and Pencils. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CompatibilityView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
||||
let requiredMacOSVersion: String?
|
||||
|
||||
var body: some View {
|
||||
if let requiredMacOSVersion = requiredMacOSVersion {
|
||||
HStack(alignment: .top){
|
||||
VStack(alignment: .leading) {
|
||||
Text("Compatibility")
|
||||
.font(.headline)
|
||||
Text(String(format: localizeString("MacOSRequirement"), requiredMacOSVersion))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(appState.hasMinSupportedOS(requiredMacOSVersion: requiredMacOSVersion) ? .red : .primary)
|
||||
}
|
||||
Spacer()
|
||||
if appState.hasMinSupportedOS(requiredMacOSVersion: requiredMacOSVersion) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.largeTitle)
|
||||
}
|
||||
}
|
||||
.xcodesBackground()
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CompatibilityView(requiredMacOSVersion: "10.15.4")
|
||||
.padding()
|
||||
.environmentObject(AppState())
|
||||
}
|
||||
60
Xcodes/Frontend/InfoPane/CompilersView.swift
Normal file
60
Xcodes/Frontend/InfoPane/CompilersView.swift
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
//
|
||||
// CompilersView.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Duong Thai on 13/10/2023.
|
||||
// Copyright © 2023 Robots and Pencils. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import XcodesKit
|
||||
|
||||
struct CompilersView: View {
|
||||
let compilers: Compilers?
|
||||
|
||||
var body: some View {
|
||||
if let compilers = compilers {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Compilers").font(.headline)
|
||||
Text(Self.content(from: compilers))
|
||||
.font(.subheadline)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
static func content(from compilers: Compilers) -> String {
|
||||
[ ("Swift", compilers.swift),
|
||||
("Clang", compilers.clang),
|
||||
("LLVM", compilers.llvm),
|
||||
("LLVM GCC", compilers.llvm_gcc),
|
||||
("GCC", compilers.gcc)
|
||||
].compactMap { // remove nil compiler
|
||||
guard $0.1 != nil, // has version array
|
||||
!$0.1!.isEmpty // has at least 1 version
|
||||
else { return nil }
|
||||
|
||||
let numbers = $0.1!.compactMap { $0.number } // remove nil number
|
||||
guard !numbers.isEmpty // has at least 1 number
|
||||
else { return nil }
|
||||
|
||||
// description for each type of compilers
|
||||
return "\($0.0): \(numbers.joined(separator: ", "))"
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let compilers = Compilers(
|
||||
gcc: .init(number: "4"),
|
||||
llvm_gcc: .init(number: "213"),
|
||||
llvm: .init(number: "2.3"),
|
||||
clang: .init(number: "7.3"),
|
||||
swift: .init(number: "5.3.2")
|
||||
)
|
||||
|
||||
return CompilersView(compilers: compilers)
|
||||
.padding()
|
||||
}
|
||||
35
Xcodes/Frontend/InfoPane/CornerRadiusModifier.swift
Normal file
35
Xcodes/Frontend/InfoPane/CornerRadiusModifier.swift
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// CornerRadiusModifier.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Matt Kiazyk on 2023-12-19.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct CornerRadiusModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(.background)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func xcodesBackground() -> some View {
|
||||
self.modifier(
|
||||
CornerRadiusModifier()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct Previews_CornerRadius_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
HStack {
|
||||
Text(verbatim: "XCODES RULES!")
|
||||
}.xcodesBackground()
|
||||
}
|
||||
}
|
||||
44
Xcodes/Frontend/InfoPane/IconView.swift
Normal file
44
Xcodes/Frontend/InfoPane/IconView.swift
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
//
|
||||
// IconView.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Duong Thai on 11/10/2023.
|
||||
// Copyright © 2023 Robots and Pencils. All rights reserved.
|
||||
//
|
||||
|
||||
import Path
|
||||
import SwiftUI
|
||||
import Version
|
||||
|
||||
struct IconView: View {
|
||||
let xcode: Xcode
|
||||
|
||||
var body: some View {
|
||||
if case let .installed(path) = xcode.installState {
|
||||
Image(nsImage: NSWorkspace.shared.icon(forFile: path.string))
|
||||
} else {
|
||||
Image(xcode.version.isPrerelease ? "xcode-beta" : "xcode")
|
||||
.resizable()
|
||||
.frame(width: 32, height: 32)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Installed") {
|
||||
IconView(xcode: Xcode(version: Version("12.3.0")!, installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: true, icon: nil))
|
||||
.frame(width: 300, height: 100)
|
||||
.padding()
|
||||
}
|
||||
|
||||
#Preview("Installed") {
|
||||
IconView(xcode: Xcode(version: Version("12.3.0")!, installState: .notInstalled, selected: true, icon: nil))
|
||||
.frame(width: 300, height: 100)
|
||||
.padding()
|
||||
}
|
||||
|
||||
#Preview("Not Installed") {
|
||||
IconView(xcode: Xcode(version: Version("12.0.0-1234A")!, installState: .notInstalled, selected: false, icon: nil))
|
||||
.frame(width: 300, height: 100)
|
||||
.padding()
|
||||
}
|
||||
63
Xcodes/Frontend/InfoPane/IdenticalBuildView.swift
Normal file
63
Xcodes/Frontend/InfoPane/IdenticalBuildView.swift
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
//
|
||||
// IdenticalBuildView.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Duong Thai on 11/10/2023.
|
||||
// Copyright © 2023 Robots and Pencils. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Version
|
||||
|
||||
struct IdenticalBuildsView: View {
|
||||
let builds: [Version]
|
||||
private let isEmpty: Bool
|
||||
private let accessibilityDescription: String
|
||||
|
||||
var body: some View {
|
||||
if isEmpty {
|
||||
EmptyView()
|
||||
} else {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text("IdenticalBuilds")
|
||||
Image(systemName: "square.fill.on.square.fill")
|
||||
.foregroundColor(.secondary)
|
||||
.accessibility(hidden: true)
|
||||
.help("IdenticalBuilds.help")
|
||||
}
|
||||
.font(.headline)
|
||||
|
||||
ForEach(builds, id: \.description) { version in
|
||||
Text(verbatim: "• \(version.appleDescription)")
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
.xcodesBackground()
|
||||
.accessibilityElement()
|
||||
.accessibility(label: Text("IdenticalBuilds"))
|
||||
.accessibility(value: Text(accessibilityDescription))
|
||||
.accessibility(hint: Text("IdenticalBuilds.help"))
|
||||
}
|
||||
}
|
||||
|
||||
init(builds: [Version]) {
|
||||
self.builds = builds
|
||||
self.isEmpty = builds.isEmpty
|
||||
self.accessibilityDescription = builds
|
||||
.map(\.appleDescription)
|
||||
.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
let builds: [Version] = [.init(xcodeVersion: "15.0")!, .init(xcodeVersion: "15.1")!]
|
||||
|
||||
#Preview("Has Some Builds") {
|
||||
IdenticalBuildsView(builds: builds)
|
||||
.padding()
|
||||
}
|
||||
|
||||
#Preview("No Build") {
|
||||
IdenticalBuildsView(builds: [])
|
||||
.padding()
|
||||
}
|
||||
|
|
@ -1,364 +1,210 @@
|
|||
import AppKit
|
||||
import XcodesKit
|
||||
import Path
|
||||
import SwiftUI
|
||||
import Version
|
||||
import struct XCModel.SDKs
|
||||
import struct XCModel.Compilers
|
||||
|
||||
struct InfoPane: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
let selectedXcodeID: Xcode.ID?
|
||||
@SwiftUI.Environment(\.openURL) var openURL: OpenURLAction
|
||||
|
||||
let xcode: Xcode
|
||||
var body: some View {
|
||||
if let xcode = appState.allXcodes.first(where: { $0.id == selectedXcodeID }) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
icon(for: xcode)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
Text(verbatim: "Xcode \(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)")
|
||||
.font(.title)
|
||||
|
||||
switch xcode.installState {
|
||||
case .notInstalled:
|
||||
InstallButton(xcode: xcode)
|
||||
downloadFileSize(for: xcode)
|
||||
case .installing(let installationStep):
|
||||
InstallationStepDetailView(installationStep: installationStep)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
CancelInstallButton(xcode: xcode)
|
||||
case let .installed(path):
|
||||
HStack {
|
||||
Text(path.string)
|
||||
Button(action: { appState.reveal(xcode: xcode) }) {
|
||||
Image(systemName: "arrow.right.circle.fill")
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.help("Reveal in Finder")
|
||||
}
|
||||
|
||||
HStack {
|
||||
SelectButton(xcode: xcode)
|
||||
.disabled(xcode.selected)
|
||||
.help("Selected")
|
||||
|
||||
OpenButton(xcode: xcode)
|
||||
.help("Open")
|
||||
|
||||
Spacer()
|
||||
UninstallButton(xcode: xcode)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Group{
|
||||
releaseNotes(for: xcode)
|
||||
releaseDate(for: xcode)
|
||||
identicalBuilds(for: xcode)
|
||||
compatibility(for: xcode)
|
||||
sdks(for: xcode)
|
||||
compilers(for: xcode)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
if #available(macOS 14.0, *) {
|
||||
mainContent
|
||||
.contentMargins(10, for: .scrollContent)
|
||||
} else {
|
||||
mainContent
|
||||
.padding()
|
||||
}
|
||||
.frame(minWidth: 200, maxWidth: .infinity)
|
||||
} else {
|
||||
empty
|
||||
.frame(minWidth: 200, maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func icon(for xcode: Xcode) -> some View {
|
||||
if case let .installed(path) = xcode.installState {
|
||||
Image(nsImage: NSWorkspace.shared.icon(forFile: path.string))
|
||||
} else {
|
||||
Image(systemName: "app.fill")
|
||||
.resizable()
|
||||
.frame(width: 32, height: 32)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func identicalBuilds(for xcode: Xcode) -> some View {
|
||||
if !xcode.identicalBuilds.isEmpty {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text("Identical Builds")
|
||||
Image(systemName: "square.fill.on.square.fill")
|
||||
.foregroundColor(.secondary)
|
||||
.accessibility(hidden: true)
|
||||
.help("Sometimes a prerelease and release version are the exact same build. Xcodes will automatically display these versions together.")
|
||||
}
|
||||
.font(.headline)
|
||||
|
||||
ForEach(xcode.identicalBuilds, id: \.description) { version in
|
||||
Text("• \(version.appleDescription)")
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityElement()
|
||||
.accessibility(label: Text("Identical Builds"))
|
||||
.accessibility(value: Text(xcode.identicalBuilds.map(\.appleDescription).joined(separator: ", ")))
|
||||
.accessibility(hint: Text("Sometimes a prerelease and release version are the exact same build. Xcodes will automatically display these versions together."))
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func releaseDate(for xcode: Xcode) -> some View {
|
||||
if let releaseDate = xcode.releaseDate {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Release Date")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(DateFormatter.downloadsReleaseDate.string(from: releaseDate))
|
||||
.font(.subheadline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func releaseNotes(for xcode: Xcode) -> some View {
|
||||
if let releaseNotesURL = xcode.releaseNotesURL {
|
||||
Button(action: { openURL(releaseNotesURL) }) {
|
||||
Label("Release Notes", systemImage: "link")
|
||||
}
|
||||
.buttonStyle(LinkButtonStyle())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.help("View Release Notes")
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func compatibility(for xcode: Xcode) -> some View {
|
||||
if let requiredMacOSVersion = xcode.requiredMacOSVersion {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Compatibility")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text("Requires macOS \(requiredMacOSVersion) or later")
|
||||
.font(.subheadline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sdks(for xcode: Xcode) -> some View {
|
||||
if let sdks = xcode.sdks {
|
||||
VStack(alignment: .leading) {
|
||||
Text("SDKs")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
ForEach([
|
||||
("macOS", \SDKs.macOS),
|
||||
("iOS", \.iOS),
|
||||
("watchOS", \.watchOS),
|
||||
("tvOS", \.tvOS),
|
||||
], id: \.0) { row in
|
||||
if let sdk = sdks[keyPath: row.1] {
|
||||
Text("\(row.0): \(sdk.compactMap { $0.number }.joined(separator: ", "))")
|
||||
.font(.subheadline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
private var mainContent: some View {
|
||||
ScrollView(.vertical) {
|
||||
HStack(alignment: .top) {
|
||||
VStack {
|
||||
VStack(spacing: 5) {
|
||||
HStack {
|
||||
IconView(xcode: xcode)
|
||||
|
||||
Text(verbatim: "Xcode \(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)")
|
||||
.font(.title)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
InfoPaneControls(xcode: xcode)
|
||||
}
|
||||
.xcodesBackground()
|
||||
|
||||
PlatformsView(xcode: xcode)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func compilers(for xcode: Xcode) -> some View {
|
||||
if let compilers = xcode.compilers {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Compilers")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(minWidth: 380)
|
||||
|
||||
ForEach([
|
||||
("Swift", \Compilers.swift),
|
||||
("Clang", \.clang),
|
||||
("LLVM", \.llvm),
|
||||
("LLVM GCC", \.llvm_gcc),
|
||||
("GCC", \.gcc),
|
||||
], id: \.0) { row in
|
||||
if let sdk = compilers[keyPath: row.1] {
|
||||
Text("\(row.0): \(sdk.compactMap { $0.number }.joined(separator: ", "))")
|
||||
.font(.subheadline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
ReleaseDateView(date: xcode.releaseDate, url: xcode.releaseNotesURL)
|
||||
CompatibilityView(requiredMacOSVersion: xcode.requiredMacOSVersion)
|
||||
IdenticalBuildsView(builds: xcode.identicalBuilds.map { $0.version })
|
||||
SDKandCompilers
|
||||
}
|
||||
.frame(width: 200)
|
||||
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func downloadFileSize(for xcode: Xcode) -> some View {
|
||||
// if we've downloaded it no need to show the download size
|
||||
if let downloadFileSize = xcode.downloadFileSizeString, case .notInstalled = xcode.installState {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Download Size")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text("\(downloadFileSize)")
|
||||
.font(.subheadline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
var SDKandCompilers: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SDKsView(sdks: xcode.sdks)
|
||||
CompilersView(compilers: xcode.compilers)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var empty: some View {
|
||||
Text("No Xcode Selected")
|
||||
.font(.title)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(.background)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
struct InfoPane_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
InfoPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 0))
|
||||
.environmentObject(configure(AppState()) {
|
||||
$0.allXcodes = [
|
||||
.init(
|
||||
version: Version(major: 12, minor: 3, patch: 0),
|
||||
installState: .installed(Path("/Applications/Xcode-12.3.0.app")!),
|
||||
selected: true,
|
||||
icon: NSWorkspace.shared.icon(forFile: "/Applications/Xcode-12.3.0.app"),
|
||||
requiredMacOSVersion: "10.15.4",
|
||||
releaseNotesURL: URL(string: "https://developer.apple.com/documentation/xcode-release-notes/xcode-12_3-release-notes/")!,
|
||||
releaseDate: Date(),
|
||||
sdks: SDKs(
|
||||
macOS: .init(number: "11.1"),
|
||||
iOS: .init(number: "14.3"),
|
||||
watchOS: .init(number: "7.3"),
|
||||
tvOS: .init(number: "14.3")
|
||||
),
|
||||
compilers: Compilers(
|
||||
gcc: .init(number: "4"),
|
||||
llvm_gcc: .init(number: "213"),
|
||||
llvm: .init(number: "2.3"),
|
||||
clang: .init(number: "7.3"),
|
||||
swift: .init(number: "5.3.2")
|
||||
),
|
||||
downloadFileSize: 242342424
|
||||
)
|
||||
]
|
||||
})
|
||||
.previewDisplayName("Populated, Installed, Selected")
|
||||
#Preview(XcodePreviewName.allCases[0].rawValue) { makePreviewContent(for: 0) }
|
||||
#Preview(XcodePreviewName.allCases[1].rawValue) { makePreviewContent(for: 1) }
|
||||
#Preview(XcodePreviewName.allCases[2].rawValue) { makePreviewContent(for: 2) }
|
||||
#Preview(XcodePreviewName.allCases[3].rawValue) { makePreviewContent(for: 3) }
|
||||
#Preview(XcodePreviewName.allCases[4].rawValue) { makePreviewContent(for: 4) }
|
||||
#Preview(XcodePreviewName.allCases[5].rawValue) { makePreviewContent(for: 5) }
|
||||
|
||||
InfoPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 0))
|
||||
.environmentObject(configure(AppState()) {
|
||||
$0.allXcodes = [
|
||||
.init(
|
||||
version: Version(major: 12, minor: 3, patch: 0),
|
||||
installState: .installed(Path("/Applications/Xcode-12.3.0.app")!),
|
||||
selected: false,
|
||||
icon: NSWorkspace.shared.icon(forFile: "/Applications/Xcode-12.3.0.app"),
|
||||
sdks: SDKs(
|
||||
macOS: .init(number: "11.1"),
|
||||
iOS: .init(number: "14.3"),
|
||||
watchOS: .init(number: "7.3"),
|
||||
tvOS: .init(number: "14.3")
|
||||
),
|
||||
compilers: Compilers(
|
||||
gcc: .init(number: "4"),
|
||||
llvm_gcc: .init(number: "213"),
|
||||
llvm: .init(number: "2.3"),
|
||||
clang: .init(number: "7.3"),
|
||||
swift: .init(number: "5.3.2")
|
||||
),
|
||||
downloadFileSize: 242342424)
|
||||
]
|
||||
})
|
||||
.previewDisplayName("Populated, Installed, Unselected")
|
||||
|
||||
InfoPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 0))
|
||||
.environmentObject(configure(AppState()) {
|
||||
$0.allXcodes = [
|
||||
.init(
|
||||
version: Version(major: 12, minor: 3, patch: 0),
|
||||
installState: .notInstalled,
|
||||
selected: false,
|
||||
icon: nil,
|
||||
sdks: SDKs(
|
||||
macOS: .init(number: "11.1"),
|
||||
iOS: .init(number: "14.3"),
|
||||
watchOS: .init(number: "7.3"),
|
||||
tvOS: .init(number: "14.3")
|
||||
),
|
||||
compilers: Compilers(
|
||||
gcc: .init(number: "4"),
|
||||
llvm_gcc: .init(number: "213"),
|
||||
llvm: .init(number: "2.3"),
|
||||
clang: .init(number: "7.3"),
|
||||
swift: .init(number: "5.3.2")
|
||||
),
|
||||
downloadFileSize: 242342424)
|
||||
]
|
||||
})
|
||||
.previewDisplayName("Populated, Uninstalled")
|
||||
|
||||
InfoPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 1, buildMetadataIdentifiers: ["1234A"]))
|
||||
.environmentObject(configure(AppState()) {
|
||||
$0.allXcodes = [
|
||||
.init(
|
||||
version: Version(major: 12, minor: 3, patch: 1, buildMetadataIdentifiers: ["1234A"]),
|
||||
installState: .installed(Path("/Applications/Xcode-12.3.0.app")!),
|
||||
selected: false,
|
||||
icon: nil,
|
||||
sdks: nil,
|
||||
compilers: nil)
|
||||
]
|
||||
})
|
||||
.previewDisplayName("Basic, installed")
|
||||
|
||||
InfoPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 1, buildMetadataIdentifiers: ["1234A"]))
|
||||
.environmentObject(configure(AppState()) {
|
||||
$0.allXcodes = [
|
||||
.init(
|
||||
version: Version(major: 12, minor: 3, patch: 1, buildMetadataIdentifiers: ["1234A"]),
|
||||
installState: .installing(.downloading(progress: configure(Progress(totalUnitCount: 100)) { $0.completedUnitCount = 40; $0.throughput = 232323232; $0.fileCompletedCount = 2323004; $0.fileTotalCount = 1193939393 })),
|
||||
selected: false,
|
||||
icon: nil,
|
||||
sdks: nil,
|
||||
compilers: nil)
|
||||
]
|
||||
})
|
||||
.previewDisplayName("Basic, installing")
|
||||
|
||||
InfoPane(selectedXcodeID: nil)
|
||||
.environmentObject(configure(AppState()) {
|
||||
$0.allXcodes = [
|
||||
]
|
||||
})
|
||||
.previewDisplayName("Empty")
|
||||
}
|
||||
.frame(maxWidth: 300)
|
||||
}
|
||||
private func makePreviewContent(for index: Int) -> some View {
|
||||
let name = XcodePreviewName.allCases[index]
|
||||
return InfoPane(xcode: xcodeDict[name]!)
|
||||
.environmentObject(configure(AppState()) {
|
||||
$0.allXcodes = [xcodeDict[name]!]
|
||||
})
|
||||
.frame(width: 600, height: 400)
|
||||
.padding()
|
||||
}
|
||||
|
||||
enum XcodePreviewName: String, CaseIterable, Identifiable {
|
||||
case Populated_Installed_Selected
|
||||
case Populated_Installed_Unselected
|
||||
case Populated_Uninstalled
|
||||
case Basic_Installed
|
||||
case Basic_Installing
|
||||
case Basic_Unarchiving
|
||||
|
||||
var id: XcodePreviewName { self }
|
||||
}
|
||||
|
||||
var xcodeDict: [XcodePreviewName: Xcode] = [
|
||||
.Populated_Installed_Selected: .init(
|
||||
version: _versionNoMeta,
|
||||
installState: .installed(Path(_path)!),
|
||||
selected: true,
|
||||
icon: NSWorkspace.shared.icon(forFile: _path),
|
||||
requiredMacOSVersion: _requiredMacOSVersion,
|
||||
releaseNotesURL: URL(string: "https://developer.apple.com/documentation/xcode-release-notes/xcode-12_3-release-notes/")!,
|
||||
releaseDate: Date(),
|
||||
sdks: _sdks,
|
||||
compilers: _compilers,
|
||||
downloadFileSize: _downloadFileSize
|
||||
),
|
||||
.Populated_Installed_Unselected: .init(
|
||||
version: _versionNoMeta,
|
||||
installState: .installed(Path(_path)!),
|
||||
selected: false,
|
||||
icon: NSWorkspace.shared.icon(forFile: _path),
|
||||
sdks: _sdks,
|
||||
compilers: _compilers,
|
||||
downloadFileSize: _downloadFileSize
|
||||
),
|
||||
.Populated_Uninstalled: .init(
|
||||
version: Version(major: 12, minor: 3, patch: 0),
|
||||
installState: .notInstalled,
|
||||
selected: false,
|
||||
icon: nil,
|
||||
sdks: _sdks,
|
||||
compilers: _compilers,
|
||||
downloadFileSize: _downloadFileSize
|
||||
),
|
||||
.Basic_Installed: .init(
|
||||
version: _versionWithMeta,
|
||||
installState: .installed(Path(_path)!),
|
||||
selected: false,
|
||||
icon: nil,
|
||||
sdks: nil,
|
||||
compilers: nil
|
||||
),
|
||||
.Basic_Installing: .init(
|
||||
version: _versionWithMeta,
|
||||
installState: .installing(.downloading(
|
||||
progress: configure(Progress()) {
|
||||
$0.kind = .file
|
||||
$0.fileOperationKind = .downloading
|
||||
$0.estimatedTimeRemaining = 123
|
||||
$0.totalUnitCount = 11_944_848_484
|
||||
$0.completedUnitCount = 848_444_920
|
||||
$0.throughput = 9_211_681
|
||||
}
|
||||
)),
|
||||
selected: false,
|
||||
icon: nil,
|
||||
sdks: nil,
|
||||
compilers: nil
|
||||
),
|
||||
.Basic_Unarchiving: .init(
|
||||
version: _versionWithMeta,
|
||||
installState: .installing(.unarchiving),
|
||||
selected: false,
|
||||
icon: nil,
|
||||
sdks: nil,
|
||||
compilers: nil
|
||||
),
|
||||
]
|
||||
|
||||
var downloadableRuntimes: [DownloadableRuntime] = {
|
||||
var runtimes = try! JSONDecoder().decode([DownloadableRuntime].self, from: Current.files.contents(atPath: Path.runtimeCacheFile.string)!)
|
||||
// set iOS to installed
|
||||
let iOSIndex = 0//runtimes.firstIndex { $0.sdkBuildUpdate.contains == "19E239" }!
|
||||
var iOSRuntime = runtimes[iOSIndex]
|
||||
iOSRuntime.installState = .installed
|
||||
runtimes[iOSIndex] = iOSRuntime
|
||||
|
||||
let watchOSIndex = 0//runtimes.firstIndex { $0.sdkBuildUpdate.first == "20R362" }!
|
||||
var runtime = runtimes[watchOSIndex]
|
||||
runtime.installState = .installing(
|
||||
RuntimeInstallationStep.downloading(
|
||||
progress:configure(Progress()) {
|
||||
$0.kind = .file
|
||||
$0.fileOperationKind = .downloading
|
||||
$0.estimatedTimeRemaining = 123
|
||||
$0.totalUnitCount = 11_944_848_484
|
||||
$0.completedUnitCount = 848_444_920
|
||||
$0.throughput = 9_211_681
|
||||
}
|
||||
)
|
||||
)
|
||||
runtimes[watchOSIndex] = runtime
|
||||
|
||||
return runtimes
|
||||
}()
|
||||
|
||||
var installedRuntimes: [CoreSimulatorImage] = {
|
||||
[CoreSimulatorImage(uuid: "85B22F5B-048B-4331-B6E2-F4196D8B7475", path: ["relative" : "file:///Library/Developer/CoreSimulator/Images/85B22F5B-048B-4331-B6E2-F4196D8B7475.dmg"], runtimeInfo: CoreSimulatorRuntimeInfo(build: "19E240")),
|
||||
CoreSimulatorImage(uuid: "85B22F5B-048B-4331-B6E2-F4196D8B7473", path: ["relative" : "file:///Library/Developer/CoreSimulator/Images/85B22F5B-048B-4331-B6E2-F4196D8B7475.dmg"], runtimeInfo: CoreSimulatorRuntimeInfo(build: "21N5233f"))]
|
||||
}()
|
||||
|
||||
|
||||
private let _versionNoMeta = Version(major: 12, minor: 3, patch: 0)
|
||||
private let _versionWithMeta = Version(major: 12, minor: 3, patch: 1, buildMetadataIdentifiers: ["1234A"])
|
||||
private let _path = "/Applications/Xcode-12.3.0.app"
|
||||
private let _requiredMacOSVersion = "10.15.4"
|
||||
private let _sdks = SDKs(
|
||||
macOS: .init(number: "11.1"),
|
||||
iOS: .init(number: "15.4", "19E239"),
|
||||
watchOS: .init(number: "7.3", "20R362"),
|
||||
tvOS: .init(number: "14.3", "20K67"),
|
||||
visionOS: .init(number: "1.0", "21N5233e")
|
||||
)
|
||||
private let _compilers = Compilers(
|
||||
gcc: .init(number: "4"),
|
||||
llvm_gcc: .init(number: "213"),
|
||||
llvm: .init(number: "2.3"),
|
||||
clang: .init(number: "7.3"),
|
||||
swift: .init(number: "5.3.2")
|
||||
)
|
||||
private let _downloadFileSize: Int64 = 242_342_424
|
||||
|
|
|
|||
54
Xcodes/Frontend/InfoPane/InfoPaneControls.swift
Normal file
54
Xcodes/Frontend/InfoPane/InfoPaneControls.swift
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
//
|
||||
// InfoPaneControls.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Duong Thai on 14/10/2023.
|
||||
// Copyright © 2023 Robots and Pencils. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct InfoPaneControls: View {
|
||||
let xcode: Xcode
|
||||
|
||||
var body: some View {
|
||||
VStack (alignment: .leading) {
|
||||
switch xcode.installState {
|
||||
case .notInstalled:
|
||||
HStack {
|
||||
Spacer()
|
||||
NotInstalledStateButtons(
|
||||
downloadFileSizeString: xcode.downloadFileSizeString,
|
||||
id: xcode.id)
|
||||
}
|
||||
|
||||
case .installing(let installationStep):
|
||||
HStack(alignment: .top) {
|
||||
InstallationStepDetailView(installationStep: installationStep)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
CancelInstallButton(xcode: xcode)
|
||||
}
|
||||
case .installed(_):
|
||||
InstalledStateButtons(xcode: xcode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview(XcodePreviewName.allCases[0].rawValue) { makePreviewContent(for: 0) }
|
||||
#Preview(XcodePreviewName.allCases[1].rawValue) { makePreviewContent(for: 1) }
|
||||
#Preview(XcodePreviewName.allCases[2].rawValue) { makePreviewContent(for: 2) }
|
||||
#Preview(XcodePreviewName.allCases[3].rawValue) { makePreviewContent(for: 3) }
|
||||
#Preview(XcodePreviewName.allCases[4].rawValue) { makePreviewContent(for: 4) }
|
||||
#Preview(XcodePreviewName.allCases[5].rawValue) { makePreviewContent(for: 5) }
|
||||
|
||||
private func makePreviewContent(for index: Int) -> some View {
|
||||
let name = XcodePreviewName.allCases[index]
|
||||
|
||||
return InfoPaneControls(xcode: xcodeDict[name]!)
|
||||
.environmentObject(configure(AppState()) {
|
||||
$0.allXcodes = [xcodeDict[name]!]
|
||||
})
|
||||
.frame(width: 500)
|
||||
.padding()
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import SwiftUI
|
||||
import XcodesKit
|
||||
|
||||
struct InstallationStepDetailView: View {
|
||||
let installationStep: InstallationStep
|
||||
let installationStep: XcodeInstallationStep
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Step \(installationStep.stepNumber) of \(installationStep.stepCount): \(installationStep.message)")
|
||||
Text(String(format: localizeString("InstallationStepDescription"), installationStep.stepNumber, installationStep.stepCount, installationStep.message))
|
||||
|
||||
switch installationStep {
|
||||
case let .downloading(progress):
|
||||
|
|
@ -16,7 +17,7 @@ struct InstallationStepDetailView: View {
|
|||
showsAdditionalDescription: true
|
||||
)
|
||||
|
||||
case .unarchiving, .moving, .trashingArchive, .checkingSecurity, .finishing:
|
||||
case .authenticating, .unarchiving, .moving, .trashingArchive, .checkingSecurity, .finishing:
|
||||
ProgressView()
|
||||
.scaleEffect(0.5)
|
||||
}
|
||||
|
|
@ -24,25 +25,25 @@ struct InstallationStepDetailView: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct InstallDetailView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
InstallationStepDetailView(
|
||||
installationStep: .downloading(
|
||||
progress: configure(Progress()) {
|
||||
$0.kind = .file
|
||||
$0.fileOperationKind = .downloading
|
||||
$0.estimatedTimeRemaining = 123
|
||||
$0.totalUnitCount = 11944848484
|
||||
$0.completedUnitCount = 848444920
|
||||
$0.throughput = 9211681
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
InstallationStepDetailView(
|
||||
installationStep: .unarchiving
|
||||
)
|
||||
}
|
||||
}
|
||||
#Preview("Downloading") {
|
||||
InstallationStepDetailView(
|
||||
installationStep: .downloading(
|
||||
progress: configure(Progress()) {
|
||||
$0.kind = .file
|
||||
$0.fileOperationKind = .downloading
|
||||
$0.estimatedTimeRemaining = 123
|
||||
$0.totalUnitCount = 11944848484
|
||||
$0.completedUnitCount = 848444920
|
||||
$0.throughput = 9211681
|
||||
}
|
||||
)
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
|
||||
#Preview("Unarchiving") {
|
||||
InstallationStepDetailView(
|
||||
installationStep: .unarchiving
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
|
|
|
|||
76
Xcodes/Frontend/InfoPane/InstalledStateButtons.swift
Normal file
76
Xcodes/Frontend/InfoPane/InstalledStateButtons.swift
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
//
|
||||
// InstallingStateButtons.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Duong Thai on 13/10/2023.
|
||||
// Copyright © 2023 Robots and Pencils. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Version
|
||||
import XcodesKit
|
||||
import Path
|
||||
|
||||
struct InstalledStateButtons: View {
|
||||
let xcode: Xcode
|
||||
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text(xcode.installedPath?.string ?? "")
|
||||
Button(action: { appState.reveal(xcode.installedPath) }) {
|
||||
Image(systemName: "arrow.right.circle.fill")
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.help("RevealInFinder")
|
||||
}
|
||||
|
||||
HStack {
|
||||
SelectButton(xcode: xcode)
|
||||
.disabled(xcode.selected)
|
||||
.help("Selected")
|
||||
|
||||
OpenButton(xcode: xcode)
|
||||
.help("Open")
|
||||
|
||||
Spacer()
|
||||
UninstallButton(xcode: xcode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
InstalledStateButtons(xcode: xcode)
|
||||
.environmentObject(configure(AppState()) {
|
||||
$0.allXcodes = [xcode]
|
||||
})
|
||||
.padding()
|
||||
.frame(width: 300)
|
||||
}
|
||||
|
||||
private let xcode = Xcode(
|
||||
version: Version(major: 12, minor: 3, patch: 0),
|
||||
installState: .installed(Path("/Applications/Xcode-12.3.0.app")!),
|
||||
selected: true,
|
||||
icon: NSWorkspace.shared.icon(forFile: "/Applications/Xcode-12.3.0.app"),
|
||||
requiredMacOSVersion: "10.15.4",
|
||||
releaseNotesURL: URL(string: "https://developer.apple.com/documentation/xcode-release-notes/xcode-12_3-release-notes/")!,
|
||||
releaseDate: Date(),
|
||||
sdks: SDKs(
|
||||
macOS: .init(number: "11.1"),
|
||||
iOS: .init(number: "14.3"),
|
||||
watchOS: .init(number: "7.3"),
|
||||
tvOS: .init(number: "14.3")
|
||||
),
|
||||
compilers: Compilers(
|
||||
gcc: .init(number: "4"),
|
||||
llvm_gcc: .init(number: "213"),
|
||||
llvm: .init(number: "2.3"),
|
||||
clang: .init(number: "7.3"),
|
||||
swift: .init(number: "5.3.2")
|
||||
),
|
||||
downloadFileSize: 242342424
|
||||
)
|
||||
48
Xcodes/Frontend/InfoPane/NotInstalledStateButtons.swift
Normal file
48
Xcodes/Frontend/InfoPane/NotInstalledStateButtons.swift
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
//
|
||||
// NotInstalledStateButtonsView.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Duong Thai on 13/10/2023.
|
||||
// Copyright © 2023 Robots and Pencils. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Version
|
||||
|
||||
struct NotInstalledStateButtons: View {
|
||||
let downloadFileSizeString: String?
|
||||
let id: XcodeID
|
||||
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Button {
|
||||
appState.checkMinVersionAndInstall(id: id)
|
||||
} label: {
|
||||
if id.architectures?.isAppleSilicon ?? false {
|
||||
Text("Install Apple Silicon").help("Install")
|
||||
} else {
|
||||
Text("Install Universal").help("Install")
|
||||
}
|
||||
}
|
||||
|
||||
if let size = downloadFileSizeString {
|
||||
Text("DownloadSize")
|
||||
.font(.headline)
|
||||
Text(size)
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NotInstalledStateButtons(
|
||||
downloadFileSizeString: "1,19 GB",
|
||||
id: XcodeID(version: Version(major: 12, minor: 3, patch: 0), architectures: nil)
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
142
Xcodes/Frontend/InfoPane/PlatformsView.swift
Normal file
142
Xcodes/Frontend/InfoPane/PlatformsView.swift
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
//
|
||||
// PlatformsView.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Matt Kiazyk on 2023-12-18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import XcodesKit
|
||||
|
||||
struct PlatformsView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@AppStorage("selectedRuntimeArchitecture") private var selectedVariant: ArchitectureVariant = .universal
|
||||
|
||||
let xcode: Xcode
|
||||
|
||||
var body: some View {
|
||||
|
||||
let builds = xcode.sdks?.allBuilds()
|
||||
let runtimes = builds?.flatMap { sdkBuild in
|
||||
appState.downloadableRuntimes.filter {
|
||||
$0.sdkBuildUpdate?.contains(sdkBuild) ?? false &&
|
||||
($0.architectures?.isEmpty ?? true ||
|
||||
($0.architectures?.isUniversal ?? false && selectedVariant == .universal) ||
|
||||
($0.architectures?.isAppleSilicon ?? false && selectedVariant == .appleSilicon)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let architectures = Set((runtimes ?? []).flatMap { $0.architectures ?? [] })
|
||||
|
||||
VStack {
|
||||
HStack {
|
||||
Text("Platforms")
|
||||
.font(.title3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if !architectures.isEmpty {
|
||||
Spacer()
|
||||
Picker("Architecture", selection: $selectedVariant) {
|
||||
ForEach(ArchitectureVariant.allCases, id: \.self) { arch in
|
||||
Label(arch.displayString, systemImage: arch.iconName)
|
||||
.tag(arch)
|
||||
}
|
||||
.labelStyle(.trailingIcon)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.menuStyle(.button)
|
||||
.buttonStyle(.borderless)
|
||||
.fixedSize()
|
||||
.labelsHidden()
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(runtimes ?? [], id: \.identifier) { runtime in
|
||||
runtimeView(runtime: runtime)
|
||||
.frame(minWidth: 200)
|
||||
.padding()
|
||||
.background(.quinary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
|
||||
}
|
||||
}
|
||||
.xcodesBackground()
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func runtimeView(runtime: DownloadableRuntime) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
HStack {
|
||||
runtime.icon()
|
||||
Text("\(runtime.visibleIdentifier)")
|
||||
.font(.headline)
|
||||
ForEach(runtime.architectures ?? [], id: \.self) { architecture in
|
||||
TagView(text: architecture.displayString)
|
||||
}
|
||||
|
||||
pathIfAvailable(xcode: xcode, runtime: runtime)
|
||||
|
||||
if runtime.installState == .notInstalled {
|
||||
// TODO: Update the downloadableRuntimes with the appropriate installState so we don't have to check path awkwardly
|
||||
if appState.runtimeInstallPath(xcode: xcode, runtime: runtime) != nil {
|
||||
EmptyView()
|
||||
} else {
|
||||
HStack {
|
||||
Spacer()
|
||||
DownloadRuntimeButton(runtime: runtime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Text(runtime.downloadFileSizeString)
|
||||
.font(.subheadline)
|
||||
.frame(width: 70, alignment: .trailing)
|
||||
}
|
||||
|
||||
if case let .installing(installationStep) = runtime.installState {
|
||||
HStack(alignment: .top, spacing: 5){
|
||||
RuntimeInstallationStepDetailView(installationStep: installationStep)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer()
|
||||
CancelRuntimeInstallButton(runtime: runtime)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func pathIfAvailable(xcode: Xcode, runtime: DownloadableRuntime) -> some View {
|
||||
if let path = appState.runtimeInstallPath(xcode: xcode, runtime: runtime) {
|
||||
Button(action: { appState.reveal(path: path.string) }) {
|
||||
Image(systemName: "arrow.right.circle.fill")
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.help("RevealInFinder")
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview(XcodePreviewName.allCases[0].rawValue) { makePreviewContent(for: 0) }
|
||||
|
||||
private func makePreviewContent(for index: Int) -> some View {
|
||||
let name = XcodePreviewName.allCases[index]
|
||||
let runtimes = downloadableRuntimes
|
||||
|
||||
return PlatformsView(xcode: xcodeDict[name]!)
|
||||
.environmentObject({ () -> AppState in
|
||||
let a = AppState()
|
||||
a.allXcodes = [xcodeDict[name]!]
|
||||
a.installedRuntimes = installedRuntimes
|
||||
a.downloadableRuntimes = runtimes
|
||||
|
||||
return a
|
||||
|
||||
}())
|
||||
.frame(width: 300)
|
||||
.padding()
|
||||
}
|
||||
46
Xcodes/Frontend/InfoPane/ReleaseDateView.swift
Normal file
46
Xcodes/Frontend/InfoPane/ReleaseDateView.swift
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//
|
||||
// ReleaseDateView.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Duong Thai on 11/10/2023.
|
||||
// Copyright © 2023 Robots and Pencils. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ReleaseDateView: View {
|
||||
let date: Date?
|
||||
let url: URL?
|
||||
var body: some View {
|
||||
if let date = date {
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text("ReleaseDate")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if let url {
|
||||
ReleaseNotesView(url: url)
|
||||
}
|
||||
}
|
||||
|
||||
Text("\(date, style: .date)")
|
||||
.font(.subheadline)
|
||||
|
||||
}
|
||||
|
||||
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(.background)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ReleaseDateView(date: Date(), url: URL(string: "https://www.xcodes.app")!)
|
||||
.padding()
|
||||
}
|
||||
38
Xcodes/Frontend/InfoPane/ReleaseNotesView.swift
Normal file
38
Xcodes/Frontend/InfoPane/ReleaseNotesView.swift
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
//
|
||||
// ReleaseNotesView.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Duong Thai on 13/10/2023.
|
||||
// Copyright © 2023 Robots and Pencils. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ReleaseNotesView: View {
|
||||
let url: URL?
|
||||
|
||||
@SwiftUI.Environment(\.openURL) var openURL: OpenURLAction
|
||||
|
||||
var body: some View {
|
||||
if let url = url {
|
||||
Button(action: { openURL(url) }) {
|
||||
Image(systemName: "link.circle.fill")
|
||||
.font(.title)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu(menuItems: {
|
||||
CopyReleaseNoteButton(url: url)
|
||||
})
|
||||
.help("ReleaseNotes.help")
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let url = URL(string: "https://developer.apple.com/documentation/xcode-release-notes/xcode-12_3-release-notes/")!
|
||||
|
||||
return ReleaseNotesView(url: url)
|
||||
.padding()
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
//
|
||||
// RuntimeInstallationStepDetailView.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Matt Kiazyk on 2023-11-23.
|
||||
// Copyright © 2023 Robots and Pencils. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import XcodesKit
|
||||
|
||||
struct RuntimeInstallationStepDetailView: View {
|
||||
let installationStep: RuntimeInstallationStep
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(String(format: localizeString("InstallationStepDescription"), installationStep.stepNumber, installationStep.stepCount, installationStep.message))
|
||||
|
||||
switch installationStep {
|
||||
case let .downloading(progress):
|
||||
ObservingProgressIndicator(
|
||||
progress,
|
||||
controlSize: .regular,
|
||||
style: .bar,
|
||||
showsAdditionalDescription: true
|
||||
)
|
||||
|
||||
case .installing, .trashingArchive:
|
||||
ObservingProgressIndicator(
|
||||
Progress(),
|
||||
controlSize: .regular,
|
||||
style: .bar,
|
||||
showsAdditionalDescription: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Downloading") {
|
||||
RuntimeInstallationStepDetailView(
|
||||
installationStep: .downloading(
|
||||
progress: configure(Progress()) {
|
||||
$0.kind = .file
|
||||
$0.fileOperationKind = .downloading
|
||||
$0.estimatedTimeRemaining = 123
|
||||
$0.totalUnitCount = 11944848484
|
||||
$0.completedUnitCount = 848444920
|
||||
$0.throughput = 9211681
|
||||
}
|
||||
))
|
||||
}
|
||||
#Preview("Installing") {
|
||||
RuntimeInstallationStepDetailView(
|
||||
installationStep: .installing
|
||||
)
|
||||
}
|
||||
70
Xcodes/Frontend/InfoPane/SDKsView.swift
Normal file
70
Xcodes/Frontend/InfoPane/SDKsView.swift
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
//
|
||||
// SDKsView.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Duong Thai on 13/10/2023.
|
||||
// Copyright © 2023 Robots and Pencils. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import XcodesKit
|
||||
|
||||
struct SDKsView: View {
|
||||
let content: String
|
||||
|
||||
var body: some View {
|
||||
if content.isEmpty {
|
||||
EmptyView()
|
||||
} else {
|
||||
VStack(alignment: .leading) {
|
||||
Text("SDKs").font(.headline)
|
||||
Text(content)
|
||||
.font(.subheadline)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(sdks: SDKs?) {
|
||||
guard let sdks = sdks else {
|
||||
self.content = ""
|
||||
return
|
||||
}
|
||||
let content = Self.content(from: sdks)
|
||||
self.content = content
|
||||
}
|
||||
|
||||
static private func content(from sdks: SDKs) -> String {
|
||||
let content: String = [
|
||||
("macOS", sdks.macOS),
|
||||
("iOS", sdks.iOS),
|
||||
("watchOS", sdks.watchOS),
|
||||
("tvOS", sdks.tvOS)
|
||||
].compactMap { // remove nil compiler
|
||||
guard $0.1 != nil, // has version array
|
||||
!$0.1!.isEmpty // has at least 1 version
|
||||
else { return nil }
|
||||
|
||||
let numbers = $0.1!.compactMap { $0.number } // remove nil number
|
||||
guard !numbers.isEmpty // has at least 1 number
|
||||
else { return nil }
|
||||
|
||||
// description for each type of compilers
|
||||
return "\($0.0): \(numbers.joined(separator: ", "))"
|
||||
}.joined(separator: "\n")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let sdks = SDKs(
|
||||
macOS: .init(number: "11.1"),
|
||||
iOS: .init(number: "14.3"),
|
||||
watchOS: .init(number: "7.3"),
|
||||
tvOS: .init(number: "14.3"))
|
||||
|
||||
return SDKsView(sdks: sdks)
|
||||
.padding()
|
||||
}
|
||||
26
Xcodes/Frontend/InfoPane/UnselectedView.swift
Normal file
26
Xcodes/Frontend/InfoPane/UnselectedView.swift
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
//
|
||||
// UnselectedView.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Duong Thai on 13/10/2023.
|
||||
// Copyright © 2023 Robots and Pencils. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct UnselectedView: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("NoXcodeSelected")
|
||||
.font(.title)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
UnselectedView()
|
||||
.padding()
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import ErrorHandling
|
||||
import SwiftUI
|
||||
import XcodesKit
|
||||
import Path
|
||||
import Version
|
||||
|
||||
struct MainWindow: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
|
@ -12,31 +15,58 @@ struct MainWindow: View {
|
|||
// FB8979533 SceneStorage doesn't restore value after app is quit by user
|
||||
@AppStorage("isShowingInfoPane") private var isShowingInfoPane = false
|
||||
@AppStorage("xcodeListCategory") private var category: XcodeListCategory = .all
|
||||
@AppStorage("xcodeListArchitecture") private var architecture: XcodeListArchitecture = .universal
|
||||
@AppStorage("isInstalledOnly") private var isInstalledOnly = false
|
||||
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
XcodeListView(selectedXcodeID: $selectedXcodeID, searchText: searchText, category: category, isInstalledOnly: isInstalledOnly)
|
||||
.frame(minWidth: 300)
|
||||
NavigationSplitViewWrapper {
|
||||
XcodeListView(selectedXcodeID: $selectedXcodeID, searchText: searchText, category: category, isInstalledOnly: isInstalledOnly, architecture: architecture)
|
||||
.layoutPriority(1)
|
||||
.alert(item: $appState.xcodeBeingConfirmedForUninstallation) { xcode in
|
||||
Alert(title: Text("Uninstall Xcode \(xcode.description)?"),
|
||||
message: Text("It will be moved to the Trash, but won't be emptied."),
|
||||
Alert(title: Text(String(format: localizeString("Alert.Uninstall.Title"), xcode.description)),
|
||||
message: Text("Alert.Uninstall.Message"),
|
||||
primaryButton: .destructive(Text("Uninstall"), action: { self.appState.uninstall(xcode: xcode) }),
|
||||
secondaryButton: .cancel(Text("Cancel")))
|
||||
}
|
||||
|
||||
if isShowingInfoPane {
|
||||
InfoPane(selectedXcodeID: selectedXcodeID)
|
||||
.frame(minWidth: 300, maxWidth: .infinity)
|
||||
.searchable(text: $searchText, placement: .sidebar)
|
||||
.mainToolbar(
|
||||
category: $category,
|
||||
isInstalledOnly: $isInstalledOnly,
|
||||
isShowingInfoPane: $isShowingInfoPane,
|
||||
architecture: $architecture
|
||||
)
|
||||
} detail: {
|
||||
Group {
|
||||
if let xcode = xcode {
|
||||
InfoPane(xcode: xcode)
|
||||
} else {
|
||||
UnselectedView()
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItemGroup {
|
||||
Button(action: { appState.presentedSheet = .signIn }, label: {
|
||||
Label("Login", systemImage: "person.circle")
|
||||
})
|
||||
.help("LoginDescription")
|
||||
if #available(macOS 14, *) {
|
||||
SettingsLink(label: {
|
||||
Label("Preferences", systemImage: "gearshape")
|
||||
})
|
||||
.help("PreferencesDescription")
|
||||
} else {
|
||||
Button(action: {
|
||||
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
|
||||
}, label: {
|
||||
Label("Preferences", systemImage: "gearshape")
|
||||
})
|
||||
.help("PreferencesDescription")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.mainToolbar(
|
||||
category: $category,
|
||||
isInstalledOnly: $isInstalledOnly,
|
||||
isShowingInfoPane: $isShowingInfoPane,
|
||||
searchText: $searchText
|
||||
)
|
||||
.bottomStatusBar()
|
||||
.padding([.top], 0)
|
||||
.navigationSubtitle(subtitleText)
|
||||
.frame(minWidth: 600, maxWidth: .infinity, minHeight: 300, maxHeight: .infinity)
|
||||
.emittingError($appState.error, recoveryHandler: { _ in })
|
||||
|
|
@ -45,8 +75,11 @@ struct MainWindow: View {
|
|||
case .signIn:
|
||||
signInView()
|
||||
.environmentObject(appState)
|
||||
case .twoFactor:
|
||||
secondFactorView(appState.secondFactorData!)
|
||||
case .twoFactor(let secondFactorData):
|
||||
secondFactorView(secondFactorData)
|
||||
.environmentObject(appState)
|
||||
case .securityKeyTouchToConfirm:
|
||||
SignInSecurityKeyTouchView(isPresented: $appState.presentedSheet.isNotNil)
|
||||
.environmentObject(appState)
|
||||
}
|
||||
}
|
||||
|
|
@ -57,24 +90,30 @@ struct MainWindow: View {
|
|||
// FB8954571 focusedValue(_:_:) on List row doesn't propagate value to @FocusedValue
|
||||
.focusedValue(\.selectedXcode, SelectedXcode(appState.allXcodes.first { $0.id == selectedXcodeID }))
|
||||
}
|
||||
|
||||
|
||||
private var xcode: Xcode? {
|
||||
appState.allXcodes.first(where: { $0.id == selectedXcodeID })
|
||||
}
|
||||
|
||||
private var subtitleText: Text {
|
||||
if let lastUpdated = lastUpdated.map(Date.init(timeIntervalSince1970:)) {
|
||||
return Text("Updated at \(lastUpdated, style: .date) \(lastUpdated, style: .time)")
|
||||
return Text("\(localizeString("UpdatedAt")) \(lastUpdated, style: .date) \(lastUpdated, style: .time)")
|
||||
} else {
|
||||
return Text("")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func secondFactorView(_ secondFactorData: AppState.SecondFactorData) -> some View {
|
||||
private func secondFactorView(_ secondFactorData: XcodesSheet.SecondFactorData) -> some View {
|
||||
switch secondFactorData.option {
|
||||
case .codeSent:
|
||||
SignIn2FAView(isPresented: $appState.secondFactorData.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
|
||||
SignIn2FAView(isPresented: $appState.presentedSheet.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
|
||||
case .smsSent(let trustedPhoneNumber):
|
||||
SignInSMSView(isPresented: $appState.secondFactorData.isNotNil, trustedPhoneNumber: trustedPhoneNumber, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
|
||||
SignInSMSView(isPresented: $appState.presentedSheet.isNotNil, trustedPhoneNumber: trustedPhoneNumber, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
|
||||
case .smsPendingChoice:
|
||||
SignInPhoneListView(isPresented: $appState.secondFactorData.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
|
||||
SignInPhoneListView(isPresented: $appState.presentedSheet.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
|
||||
case .securityKey:
|
||||
SignInSecurityKeyPinView(isPresented: $appState.presentedSheet.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -101,10 +140,10 @@ struct MainWindow: View {
|
|||
switch alertType {
|
||||
case let .cancelInstall(xcode):
|
||||
return Alert(
|
||||
title: Text("Are you sure you want to stop the installation of Xcode \(xcode.description)?"),
|
||||
message: Text("Any progress will be discarded."),
|
||||
title: Text(String(format: localizeString("Alert.CancelInstall.Title"), xcode.description)),
|
||||
message: Text("Alert.CancelInstall.Message"),
|
||||
primaryButton: .destructive(
|
||||
Text("Stop Installation"),
|
||||
Text("Alert.CancelInstall.PrimaryButton"),
|
||||
action: {
|
||||
self.appState.cancelInstall(id: xcode.id)
|
||||
}
|
||||
|
|
@ -113,8 +152,8 @@ struct MainWindow: View {
|
|||
)
|
||||
case .privilegedHelper:
|
||||
return Alert(
|
||||
title: Text("Privileged Helper"),
|
||||
message: Text("Xcodes uses a separate privileged helper to perform tasks as root. These are things that would require sudo on the command line, including post-install steps and switching Xcode versions with xcode-select.\n\nYou'll be prompted for your macOS account password to install it."),
|
||||
title: Text("Alert.PrivilegedHelper.Title"),
|
||||
message: Text("Alert.PrivilegedHelper.Message"),
|
||||
primaryButton: .default(Text("Install"), action: {
|
||||
// The isPreparingUserForActionRequiringHelper closure is set to nil by the alert's binding when its dismissed.
|
||||
// We need to capture it to be invoked after that happens.
|
||||
|
|
@ -147,16 +186,66 @@ struct MainWindow: View {
|
|||
title: Text(title),
|
||||
message: Text(message),
|
||||
dismissButton: .default(
|
||||
Text("Ok"),
|
||||
Text("OK"),
|
||||
action: { appState.presentedAlert = nil }
|
||||
)
|
||||
)
|
||||
case .unauthenticated:
|
||||
return Alert(
|
||||
title: Text("Alert.Install.Error.Title"),
|
||||
message: Text("Alert.Install.AuthError.Message"),
|
||||
primaryButton: .default(
|
||||
Text("OK"),
|
||||
action: {
|
||||
appState.presentedSheet = .signIn
|
||||
}
|
||||
),
|
||||
secondaryButton: .cancel(
|
||||
Text("Cancel")
|
||||
)
|
||||
)
|
||||
case let .checkMinSupportedVersion(xcode, deviceVersion):
|
||||
return Alert(
|
||||
title: Text("Alert.MinSupported.Title"),
|
||||
message: Text(String(format: localizeString("Alert.MinSupported.Message"), xcode.xcodeID.version.descriptionWithoutBuildMetadata, xcode.requiredMacOSVersion ?? "", deviceVersion)),
|
||||
primaryButton: .default(
|
||||
Text("Install"),
|
||||
action: {
|
||||
self.appState.install(id: xcode.xcodeID)
|
||||
}
|
||||
),
|
||||
secondaryButton: .cancel(Text("Cancel"))
|
||||
)
|
||||
|
||||
case let .cancelRuntimeInstall(runtime):
|
||||
return Alert(
|
||||
title: Text(String(format: localizeString("Alert.CancelInstall.Runtimes.Title"), runtime.name)),
|
||||
message: Text("Alert.CancelInstall.Message"),
|
||||
primaryButton: .destructive(
|
||||
Text("Alert.CancelInstall.PrimaryButton"),
|
||||
action: {
|
||||
self.appState.cancelRuntimeInstall(runtime: runtime)
|
||||
}
|
||||
),
|
||||
secondaryButton: .cancel(Text("Cancel"))
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
struct MainWindow_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
MainWindow()
|
||||
MainWindow().environmentObject({ () -> AppState in
|
||||
let a = AppState()
|
||||
a.allXcodes = [
|
||||
Xcode(version: Version("12.0.0+1234A")!, identicalBuilds: [XcodeID(version: Version("12.0.0+1234A")!), XcodeID(version: Version("12.0.0-RC+1234A")!)], installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: false, icon: nil),
|
||||
Xcode(version: Version("12.3.0")!, installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: true, icon: nil),
|
||||
Xcode(version: Version("12.2.0")!, installState: .notInstalled, selected: false, icon: nil),
|
||||
Xcode(version: Version("12.1.0")!, installState: .installing(.downloading(progress: configure(Progress(totalUnitCount: 100)) { $0.completedUnitCount = 40 })), selected: false, icon: nil),
|
||||
Xcode(version: Version("12.0.0")!, installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: false, icon: nil),
|
||||
]
|
||||
return a
|
||||
}())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,24 +4,58 @@ import Path
|
|||
|
||||
struct AdvancedPreferencePane: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
||||
@AppStorage("dataSource") var dataSource: DataSource = .xcodeReleases
|
||||
@AppStorage("downloader") var downloader: Downloader = .aria2
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
|
||||
GroupBox(label: Text("Local Cache Path")) {
|
||||
GroupBox(label: Text("InstallDirectory")) {
|
||||
VStack(alignment: .leading) {
|
||||
HStack(alignment: .top, spacing: 5) {
|
||||
Text(appState.installPath).font(.footnote)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(2)
|
||||
Button(action: { appState.reveal(path: appState.installPath) }) {
|
||||
Image(systemName: "arrow.right.circle.fill")
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.help("RevealInFinder")
|
||||
.fixedSize()
|
||||
}
|
||||
Button("Change") {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
panel.canCreateDirectories = true
|
||||
panel.allowedContentTypes = [.folder]
|
||||
panel.directoryURL = URL(fileURLWithPath: appState.installPath)
|
||||
|
||||
if panel.runModal() == .OK {
|
||||
|
||||
guard let pathURL = panel.url, let path = Path(url: pathURL) else { return }
|
||||
self.appState.installPath = path.string
|
||||
}
|
||||
}
|
||||
.disabled(appState.disableInstallPathChange)
|
||||
Text("InstallPathDescription")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.groupBoxStyle(PreferencesGroupBoxStyle())
|
||||
|
||||
GroupBox(label: Text("LocalCachePath")) {
|
||||
VStack(alignment: .leading) {
|
||||
HStack(alignment: .top, spacing: 5) {
|
||||
Text(appState.localPath).font(.footnote)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(2)
|
||||
Button(action: { appState.reveal(path: appState.localPath) }) {
|
||||
Image(systemName: "arrow.right.circle.fill")
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.help("Reveal in Finder")
|
||||
.help("RevealInFinder")
|
||||
.fixedSize()
|
||||
}
|
||||
Button("Change") {
|
||||
|
|
@ -34,70 +68,86 @@ struct AdvancedPreferencePane: View {
|
|||
panel.directoryURL = URL(fileURLWithPath: appState.localPath)
|
||||
|
||||
if panel.runModal() == .OK {
|
||||
|
||||
|
||||
guard let pathURL = panel.url, let path = Path(url: pathURL) else { return }
|
||||
self.appState.localPath = path.string
|
||||
}
|
||||
}
|
||||
Text("Xcodes caches available Xcode versions and temporary downloads new versions to a directory")
|
||||
.disabled(appState.disableLocalPathChange)
|
||||
Text("LocalCachePathDescription")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
}
|
||||
.groupBoxStyle(PreferencesGroupBoxStyle())
|
||||
|
||||
GroupBox(label: Text("Data Source")) {
|
||||
GroupBox(label: Text("Active/Select")) {
|
||||
VStack(alignment: .leading) {
|
||||
Picker("Data Source", selection: $dataSource) {
|
||||
ForEach(DataSource.allCases) { dataSource in
|
||||
Text(dataSource.description)
|
||||
.tag(dataSource)
|
||||
}
|
||||
Picker(selection: $appState.onSelectActionType) {
|
||||
|
||||
Text(SelectedActionType.none.description)
|
||||
.tag(SelectedActionType.none)
|
||||
Text(SelectedActionType.rename.description)
|
||||
.tag(SelectedActionType.rename)
|
||||
} label: {
|
||||
Text(verbatim: "OnSelect")
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.inline)
|
||||
.disabled(appState.onSelectActionTypeDisabled)
|
||||
|
||||
Text(appState.onSelectActionType.detailedDescription)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
|
||||
AttributedText(dataSourceFootnote)
|
||||
Toggle("AutomaticallyCreateSymbolicLink", isOn: $appState.createSymLinkOnSelect)
|
||||
.disabled(appState.createSymLinkOnSelectDisabled)
|
||||
Text("AutomaticallyCreateSymbolicLinkDescription")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.groupBoxStyle(PreferencesGroupBoxStyle())
|
||||
|
||||
GroupBox(label: Text("Downloader")) {
|
||||
VStack(alignment: .leading) {
|
||||
Picker("Downloader", selection: $downloader) {
|
||||
ForEach(Downloader.allCases) { downloader in
|
||||
Text(downloader.description)
|
||||
.tag(downloader)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
|
||||
AttributedText(downloaderFootnote)
|
||||
if Hardware.isAppleSilicon() {
|
||||
GroupBox(label: Text("Apple Silicon")) {
|
||||
Toggle("ShowOpenInRosetta", isOn: $appState.showOpenInRosettaOption)
|
||||
.disabled(appState.createSymLinkOnSelectDisabled)
|
||||
Text("ShowOpenInRosettaDescription")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
.groupBoxStyle(PreferencesGroupBoxStyle())
|
||||
}
|
||||
.groupBoxStyle(PreferencesGroupBoxStyle())
|
||||
|
||||
GroupBox(label: Text("Privileged Helper")) {
|
||||
GroupBox(label: Text("PrivilegedHelper")) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
switch appState.helperInstallState {
|
||||
case .unknown:
|
||||
ProgressView()
|
||||
.scaleEffect(0.5, anchor: .center)
|
||||
case .installed:
|
||||
Text("Helper is installed")
|
||||
Text("HelperInstalled")
|
||||
case .notInstalled:
|
||||
HStack {
|
||||
Text("Helper is not installed")
|
||||
Button("Install helper") {
|
||||
VStack(alignment: .leading) {
|
||||
Button("InstallHelper") {
|
||||
appState.installHelperIfNecessary()
|
||||
}
|
||||
Text("HelperNotInstalled")
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Xcodes uses a separate privileged helper to perform tasks as root. These are things that would require sudo on the command line, including post-install steps and switching Xcode versions with xcode-select.\n\nYou'll be prompted for your macOS account password to install it.")
|
||||
Text("PrivilegedHelperDescription")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer()
|
||||
|
|
@ -105,41 +155,6 @@ struct AdvancedPreferencePane: View {
|
|||
}
|
||||
.groupBoxStyle(PreferencesGroupBoxStyle())
|
||||
}
|
||||
.frame(width: 500)
|
||||
}
|
||||
|
||||
private var dataSourceFootnote: NSAttributedString {
|
||||
let string = """
|
||||
The Apple data source scrapes the Apple Developer website. It will always show the latest releases that are available, but is more fragile.
|
||||
|
||||
Xcode Releases is an unofficial list of Xcode releases. It's provided as well-formed data, contains extra information that is not readily available from Apple, and is less likely to break if Apple redesigns their developer website.
|
||||
"""
|
||||
let attributedString = NSMutableAttributedString(
|
||||
string: string,
|
||||
attributes: [
|
||||
.font: NSFont.preferredFont(forTextStyle: .footnote, options: [:]),
|
||||
.foregroundColor: NSColor.labelColor
|
||||
]
|
||||
)
|
||||
attributedString.addAttribute(.link, value: URL(string: "https://xcodereleases.com")!, range: NSRange(string.range(of: "Xcode Releases")!, in: string))
|
||||
return attributedString
|
||||
}
|
||||
|
||||
private var downloaderFootnote: NSAttributedString {
|
||||
let string = """
|
||||
aria2 uses up to 16 connections to download Xcode 3-5x faster than URLSession. It's bundled as an executable along with its source code within Xcodes to comply with its GPLv2 license.
|
||||
|
||||
URLSession is the default Apple API for making URL requests.
|
||||
"""
|
||||
let attributedString = NSMutableAttributedString(
|
||||
string: string,
|
||||
attributes: [
|
||||
.font: NSFont.preferredFont(forTextStyle: .footnote, options: [:]),
|
||||
.foregroundColor: NSColor.labelColor
|
||||
]
|
||||
)
|
||||
attributedString.addAttribute(.link, value: URL(string: "https://github.com/aria2/aria2")!, range: NSRange(string.range(of: "aria2")!, in: string))
|
||||
return attributedString
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -148,7 +163,9 @@ struct AdvancedPreferencePane_Previews: PreviewProvider {
|
|||
Group {
|
||||
AdvancedPreferencePane()
|
||||
.environmentObject(AppState())
|
||||
.frame(maxWidth: 600)
|
||||
}
|
||||
.frame(width: 600, height: 700, alignment: .center)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -156,11 +173,8 @@ struct AdvancedPreferencePane_Previews: PreviewProvider {
|
|||
struct PreferencesGroupBoxStyle: GroupBoxStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
HStack {
|
||||
Spacer()
|
||||
configuration.label
|
||||
}
|
||||
.frame(width: 120)
|
||||
configuration.label
|
||||
.frame(width: 180, alignment: .trailing)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
configuration.content
|
||||
|
|
|
|||
64
Xcodes/Frontend/Preferences/DownloadPreferencePane.swift
Normal file
64
Xcodes/Frontend/Preferences/DownloadPreferencePane.swift
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import AppleAPI
|
||||
import SwiftUI
|
||||
|
||||
struct DownloadPreferencePane: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
||||
@AppStorage("dataSource") var dataSource: DataSource = .xcodeReleases
|
||||
@AppStorage("downloader") var downloader: Downloader = .aria2
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
GroupBox(label: Text("DataSource")) {
|
||||
VStack(alignment: .leading) {
|
||||
Picker("DataSource", selection: $dataSource) {
|
||||
ForEach(DataSource.allCases) { dataSource in
|
||||
Text(dataSource.description)
|
||||
.tag(dataSource)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
|
||||
Text("DataSourceDescription")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.groupBoxStyle(PreferencesGroupBoxStyle())
|
||||
.disabled(dataSource.isManaged)
|
||||
|
||||
GroupBox(label: Text("Downloader")) {
|
||||
VStack(alignment: .leading) {
|
||||
Picker("Downloader", selection: $downloader) {
|
||||
ForEach(Downloader.allCases) { downloader in
|
||||
Text(downloader.description)
|
||||
.tag(downloader)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
|
||||
Text("DownloaderDescription")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.groupBoxStyle(PreferencesGroupBoxStyle())
|
||||
.disabled(downloader.isManaged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadPreferencePane_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
DownloadPreferencePane()
|
||||
.environmentObject(AppState())
|
||||
.frame(maxWidth: 600)
|
||||
.frame(minHeight: 300)
|
||||
}
|
||||
}
|
||||
}
|
||||
37
Xcodes/Frontend/Preferences/ExperiementsPreferencePane.swift
Normal file
37
Xcodes/Frontend/Preferences/ExperiementsPreferencePane.swift
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import AppleAPI
|
||||
import Path
|
||||
import SwiftUI
|
||||
|
||||
struct ExperimentsPreferencePane: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
GroupBox(label: Text("FasterUnxip")) {
|
||||
VStack(alignment: .leading) {
|
||||
Toggle(
|
||||
"UseUnxipExperiment",
|
||||
isOn: $appState.unxipExperiment
|
||||
)
|
||||
.disabled(appState.disableUnxipExperiment)
|
||||
Text("FasterUnxipDescription")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.groupBoxStyle(PreferencesGroupBoxStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ExperimentsPreferencePane_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
ExperimentsPreferencePane()
|
||||
.environmentObject(AppState())
|
||||
.frame(maxWidth: 600)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,11 +6,11 @@ struct GeneralPreferencePane: View {
|
|||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
GroupBox(label: Text("Apple ID")) {
|
||||
GroupBox(label: Text("AppleID")) {
|
||||
if appState.authenticationState == .authenticated {
|
||||
SignedInView()
|
||||
} else {
|
||||
Button("Sign In", action: { self.appState.presentedSheet = .signIn })
|
||||
Button("SignIn", action: { self.appState.presentedSheet = .signIn })
|
||||
}
|
||||
}
|
||||
.groupBoxStyle(PreferencesGroupBoxStyle())
|
||||
|
|
@ -20,8 +20,13 @@ struct GeneralPreferencePane: View {
|
|||
NotificationsView().environmentObject(appState)
|
||||
}
|
||||
.groupBoxStyle(PreferencesGroupBoxStyle())
|
||||
Divider()
|
||||
|
||||
GroupBox(label: Text("Misc")) {
|
||||
Toggle("TerminateAfterLastWindowClosed", isOn: $appState.terminateAfterLastWindowClosed)
|
||||
}
|
||||
.groupBoxStyle(PreferencesGroupBoxStyle())
|
||||
}
|
||||
.frame(width: 500)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -30,6 +35,7 @@ struct GeneralPreferencePane_Previews: PreviewProvider {
|
|||
Group {
|
||||
GeneralPreferencePane()
|
||||
.environmentObject(AppState())
|
||||
.frame(maxWidth: 600)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,17 +8,17 @@ struct NotificationsView: View {
|
|||
|
||||
switch Current.notificationManager.notificationStatus {
|
||||
case .shownAndAccepted:
|
||||
Text("Access Granted. You will receive notifications from Xcodes.")
|
||||
Text("AccessGranted")
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
case .shownAndDenied:
|
||||
Text("⚠️ Access Denied ⚠️\n\nPlease open your Notification Settings and select Xcodes if you wish to allow access.")
|
||||
Text("AccessDenied")
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Button("Notification Settings", action: {
|
||||
Button("NotificationSettings", action: {
|
||||
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.notifications")!)
|
||||
})
|
||||
|
||||
default:
|
||||
Button("Enable Notifications", action: {
|
||||
Button("EnableNotifications", action: {
|
||||
Current.notificationManager.requestAccess()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
88
Xcodes/Frontend/Preferences/PlatformsListView.swift
Normal file
88
Xcodes/Frontend/Preferences/PlatformsListView.swift
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
//
|
||||
// PlatformsListView.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Matt Kiazyk on 2023-12-20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Path
|
||||
import XcodesKit
|
||||
import OrderedCollections
|
||||
|
||||
struct PlatformsListView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@State private var runtimes: OrderedDictionary<DownloadableRuntime.Platform, [DownloadableRuntime]> = [:]
|
||||
@State private var selectedRuntime: DownloadableRuntime?
|
||||
|
||||
var body: some View {
|
||||
List(selection: $selectedRuntime) {
|
||||
Text("PlatformsList.Title")
|
||||
.font(.body)
|
||||
ForEach(runtimes.elements.sorted(\.key.order), id: \.key) { platform, runtimeList in
|
||||
Section {
|
||||
ForEach(runtimeList, id: \.self) { runtime in
|
||||
HStack {
|
||||
Text(runtime.name)
|
||||
Spacer()
|
||||
Text(runtime.downloadFileSizeString)
|
||||
Button {
|
||||
deleteRuntime(runtime: runtime)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.foregroundStyle(.red)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.frame(height: 30)
|
||||
}
|
||||
|
||||
} header: {
|
||||
HStack {
|
||||
runtimeList.first!.icon()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 20)
|
||||
Text(platform.shortName)
|
||||
.font(.headline)
|
||||
}
|
||||
} footer: {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
.task {
|
||||
loadRuntimes()
|
||||
}
|
||||
.onChange(of: appState.installedRuntimes) { _ in
|
||||
loadRuntimes()
|
||||
}
|
||||
}
|
||||
|
||||
func loadRuntimes() {
|
||||
let filteredRuntimes = appState.downloadableRuntimes.filter { runtime in
|
||||
appState.installedRuntimes.contains { $0.runtimeInfo.build == runtime.simulatorVersion.buildUpdate
|
||||
}
|
||||
}
|
||||
runtimes = OrderedDictionary(grouping: filteredRuntimes, by: { $0.platform })
|
||||
}
|
||||
|
||||
func deleteRuntime(runtime: DownloadableRuntime) {
|
||||
appState.presentedPreferenceAlert = .deletePlatform(runtime: runtime)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
PlatformsListView()
|
||||
.environmentObject({ () -> AppState in
|
||||
let a = AppState()
|
||||
|
||||
a.installedRuntimes = installedRuntimes
|
||||
a.downloadableRuntimes = downloadableRuntimes
|
||||
|
||||
return a
|
||||
|
||||
}())
|
||||
}
|
||||
|
|
@ -2,9 +2,10 @@ import SwiftUI
|
|||
|
||||
struct PreferencesView: View {
|
||||
private enum Tabs: Hashable {
|
||||
case general, updates, advanced
|
||||
case general, updates, advanced, experiment
|
||||
}
|
||||
@EnvironmentObject var appState: AppState
|
||||
@EnvironmentObject var updater: ObservableUpdater
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
|
|
@ -15,17 +16,29 @@ struct PreferencesView: View {
|
|||
}
|
||||
.tag(Tabs.general)
|
||||
UpdatesPreferencePane()
|
||||
.environmentObject(updater)
|
||||
.tabItem {
|
||||
Label("Updates", systemImage: "arrow.triangle.2.circlepath.circle")
|
||||
}
|
||||
.tag(Tabs.updates)
|
||||
DownloadPreferencePane()
|
||||
.environmentObject(appState)
|
||||
.tabItem {
|
||||
Label("Downloads", systemImage: "icloud.and.arrow.down")
|
||||
}
|
||||
AdvancedPreferencePane()
|
||||
.environmentObject(appState)
|
||||
.tabItem {
|
||||
Label("Advanced", systemImage: "gearshape.2")
|
||||
}
|
||||
.tag(Tabs.advanced)
|
||||
ExperimentsPreferencePane()
|
||||
.tabItem {
|
||||
Label("Experiments", systemImage: "lightbulb")
|
||||
}
|
||||
.tag(Tabs.experiment)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 600)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import Sparkle
|
|||
import SwiftUI
|
||||
|
||||
struct UpdatesPreferencePane: View {
|
||||
@StateObject var updater = ObservableUpdater()
|
||||
@EnvironmentObject var updater: ObservableUpdater
|
||||
|
||||
@AppStorage("autoInstallation") var autoInstallationType: AutoInstallationType = .none
|
||||
|
||||
|
|
@ -12,14 +12,16 @@ struct UpdatesPreferencePane: View {
|
|||
GroupBox(label: Text("Versions")) {
|
||||
VStack(alignment: .leading) {
|
||||
Toggle(
|
||||
"Automatically install new versions of Xcode",
|
||||
"AutomaticInstallNewVersion",
|
||||
isOn: $autoInstallationType.isAutoInstalling
|
||||
)
|
||||
|
||||
.disabled(updater.disableAutoInstallNewVersions)
|
||||
|
||||
Toggle(
|
||||
"Include prerelease/beta versions",
|
||||
"IncludePreRelease",
|
||||
isOn: $autoInstallationType.isAutoInstallingBeta
|
||||
)
|
||||
.disabled(updater.disableIncludePrereleaseVersions)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
|
@ -27,37 +29,42 @@ struct UpdatesPreferencePane: View {
|
|||
|
||||
Divider()
|
||||
|
||||
GroupBox(label: Text("Xcodes.app Updates")) {
|
||||
GroupBox(label: Text("AppUpdates")) {
|
||||
VStack(alignment: .leading) {
|
||||
Toggle(
|
||||
"Automatically check for app updates",
|
||||
"CheckForAppUpdates",
|
||||
isOn: $updater.automaticallyChecksForUpdates
|
||||
)
|
||||
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.disabled(updater.disableAutoUpdateXcodesApp)
|
||||
|
||||
Toggle(
|
||||
"Include prerelease app versions",
|
||||
"IncludePreRelease",
|
||||
isOn: $updater.includePrereleaseVersions
|
||||
)
|
||||
|
||||
Button("Check Now") {
|
||||
SUUpdater.shared()?.checkForUpdates(nil)
|
||||
.disabled(updater.disableAutoUpdateXcodesAppPrereleaseVersions)
|
||||
|
||||
Button("CheckNow") {
|
||||
updater.checkForUpdates()
|
||||
}
|
||||
|
||||
Text("Last checked: \(lastUpdatedString)")
|
||||
.padding(.top)
|
||||
.disabled(updater.disableAutoUpdateXcodesApp)
|
||||
|
||||
Text(String(format: localizeString("LastChecked"), lastUpdatedString))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.groupBoxStyle(PreferencesGroupBoxStyle())
|
||||
}
|
||||
.frame(width: 500)
|
||||
}
|
||||
|
||||
private var lastUpdatedString: String {
|
||||
if let lastUpdatedDate = updater.lastUpdateCheckDate {
|
||||
return Self.formatter.string(from: lastUpdatedDate)
|
||||
} else {
|
||||
return "Never"
|
||||
return localizeString("Never")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -68,9 +75,12 @@ struct UpdatesPreferencePane: View {
|
|||
}
|
||||
|
||||
class ObservableUpdater: ObservableObject {
|
||||
private let updater: SPUUpdater
|
||||
private let updaterDelegate = UpdaterDelegate()
|
||||
|
||||
@Published var automaticallyChecksForUpdates = false {
|
||||
didSet {
|
||||
SUUpdater.shared()?.automaticallyChecksForUpdates = automaticallyChecksForUpdates
|
||||
updater.automaticallyChecksForUpdates = automaticallyChecksForUpdates
|
||||
}
|
||||
}
|
||||
private var automaticallyChecksForUpdatesObservation: NSKeyValueObservation?
|
||||
|
|
@ -78,18 +88,26 @@ class ObservableUpdater: ObservableObject {
|
|||
private var lastUpdateCheckDateObservation: NSKeyValueObservation?
|
||||
@Published var includePrereleaseVersions = false {
|
||||
didSet {
|
||||
UserDefaults.standard.setValue(includePrereleaseVersions, forKey: "includePrereleaseVersions")
|
||||
Current.defaults.set(includePrereleaseVersions, forKey: "includePrereleaseVersions")
|
||||
|
||||
if includePrereleaseVersions {
|
||||
SUUpdater.shared()?.feedURL = .prereleaseAppcast
|
||||
} else {
|
||||
SUUpdater.shared()?.feedURL = .appcast
|
||||
}
|
||||
updaterDelegate.includePrereleaseVersions = includePrereleaseVersions
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var disableAutoInstallNewVersions: Bool { PreferenceKey.autoInstallation.isManaged() }
|
||||
var disableIncludePrereleaseVersions: Bool { PreferenceKey.autoInstallation.isManaged() }
|
||||
|
||||
var disableAutoUpdateXcodesApp: Bool { PreferenceKey.SUEnableAutomaticChecks.isManaged() }
|
||||
var disableAutoUpdateXcodesAppPrereleaseVersions: Bool { PreferenceKey.includePrereleaseVersions.isManaged() }
|
||||
|
||||
init() {
|
||||
automaticallyChecksForUpdatesObservation = SUUpdater.shared()?.observe(
|
||||
updater = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: updaterDelegate, userDriverDelegate: nil).updater
|
||||
|
||||
// upgrade from an old sparkle version which set feeds via the updater
|
||||
// now it uses the `updaterDelegate`
|
||||
updater.clearFeedURLFromUserDefaults()
|
||||
|
||||
automaticallyChecksForUpdatesObservation = updater.observe(
|
||||
\.automaticallyChecksForUpdates,
|
||||
options: [.initial, .new, .old],
|
||||
changeHandler: { [unowned self] updater, change in
|
||||
|
|
@ -97,20 +115,37 @@ class ObservableUpdater: ObservableObject {
|
|||
self.automaticallyChecksForUpdates = updater.automaticallyChecksForUpdates
|
||||
}
|
||||
)
|
||||
lastUpdateCheckDateObservation = SUUpdater.shared()?.observe(
|
||||
lastUpdateCheckDateObservation = updater.observe(
|
||||
\.lastUpdateCheckDate,
|
||||
options: [.initial, .new, .old],
|
||||
changeHandler: { [unowned self] updater, change in
|
||||
self.lastUpdateCheckDate = updater.lastUpdateCheckDate
|
||||
}
|
||||
)
|
||||
includePrereleaseVersions = UserDefaults.standard.bool(forKey: "includePrereleaseVersions")
|
||||
includePrereleaseVersions = Current.defaults.bool(forKey: "includePrereleaseVersions") ?? false
|
||||
}
|
||||
|
||||
func checkForUpdates() {
|
||||
updater.checkForUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
extension URL {
|
||||
static let appcast = URL(string: "https://robotsandpencils.github.io/XcodesApp/appcast.xml")!
|
||||
static let prereleaseAppcast = URL(string: "https://robotsandpencils.github.io/XcodesApp/appcast_pre.xml")!
|
||||
class UpdaterDelegate: NSObject, SPUUpdaterDelegate {
|
||||
var includePrereleaseVersions: Bool = false
|
||||
|
||||
func feedURLString(for updater: SPUUpdater) -> String? {
|
||||
if includePrereleaseVersions {
|
||||
return .prereleaseAppcast
|
||||
} else {
|
||||
return .appcast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension String {
|
||||
static let appcast = "https://www.xcodes.app/appcast.xml"
|
||||
static let prereleaseAppcast = "https://www.xcodes.app/appcast_pre.xml"
|
||||
}
|
||||
|
||||
struct UpdatesPreferencePane_Previews: PreviewProvider {
|
||||
|
|
@ -118,6 +153,9 @@ struct UpdatesPreferencePane_Previews: PreviewProvider {
|
|||
Group {
|
||||
UpdatesPreferencePane()
|
||||
.environmentObject(AppState())
|
||||
.environmentObject(ObservableUpdater())
|
||||
.frame(maxWidth: 600)
|
||||
.frame(minHeight: 300)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ struct PinCodeTextField: NSViewRepresentable {
|
|||
|
||||
@Binding var code: String
|
||||
let numberOfDigits: Int
|
||||
let complete: (String) -> Void
|
||||
|
||||
func makeNSView(context: Context) -> NSViewType {
|
||||
let view = PinCodeTextView(numberOfDigits: numberOfDigits, itemSpacing: 10)
|
||||
view.codeDidChange = { c in code = c }
|
||||
view.codeDidChange = { c in code = c }
|
||||
view.codeDidComplete = { complete($0) }
|
||||
return view
|
||||
}
|
||||
|
||||
|
|
@ -29,8 +31,9 @@ struct PinCodeTextField_Previews: PreviewProvider {
|
|||
struct PreviewContainer: View {
|
||||
@State private var code = "1234567890"
|
||||
var body: some View {
|
||||
PinCodeTextField(code: $code, numberOfDigits: 11)
|
||||
.padding()
|
||||
PinCodeTextField(code: $code, numberOfDigits: 11) {
|
||||
print("Input is complete \($0)")
|
||||
}.padding()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -52,10 +55,16 @@ class PinCodeTextView: NSControl, NSTextFieldDelegate {
|
|||
handler(String(code.compactMap { $0 }))
|
||||
}
|
||||
updateText()
|
||||
|
||||
if code.compactMap({ $0 }).count == numberOfDigits,
|
||||
let handler = codeDidComplete {
|
||||
handler(String(code.compactMap { $0 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
var codeDidChange: ((String) -> Void)? = nil
|
||||
|
||||
var codeDidComplete: ((String) -> Void)? = nil
|
||||
|
||||
private let numberOfDigits: Int
|
||||
private let stackView: NSStackView = .init(frame: .zero)
|
||||
private var characterViews: [PinCodeCharacterTextField] = []
|
||||
|
|
|
|||
|
|
@ -10,11 +10,14 @@ struct SignIn2FAView: View {
|
|||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Enter the \(authOptions.securityCode.length) digit code from one of your trusted devices:")
|
||||
Text(String(format: localizeString("DigitCodeDescription"), authOptions.securityCode!.length))
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
PinCodeTextField(code: $code, numberOfDigits: authOptions.securityCode.length)
|
||||
PinCodeTextField(code: $code, numberOfDigits: authOptions.securityCode!.length) {
|
||||
appState.submitSecurityCode(.device(code: $0), sessionData: sessionData)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
|
|
@ -22,14 +25,14 @@ struct SignIn2FAView: View {
|
|||
HStack {
|
||||
Button("Cancel", action: { isPresented = false })
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button("Send SMS", action: { appState.choosePhoneNumberForSMS(authOptions: authOptions, sessionData: sessionData) })
|
||||
Button("SendSMS", action: { appState.choosePhoneNumberForSMS(authOptions: authOptions, sessionData: sessionData) })
|
||||
Spacer()
|
||||
ProgressButton(isInProgress: appState.isProcessingAuthRequest,
|
||||
action: { appState.submitSecurityCode(.device(code: code), sessionData: sessionData) }) {
|
||||
Text("Continue")
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(code.count != authOptions.securityCode.length)
|
||||
.disabled(code.count != authOptions.securityCode!.length)
|
||||
}
|
||||
.frame(height: 25)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,33 @@
|
|||
import SwiftUI
|
||||
|
||||
struct SignInCredentialsView: View {
|
||||
private enum FocusedField {
|
||||
case username, password
|
||||
}
|
||||
|
||||
@EnvironmentObject var appState: AppState
|
||||
@State private var username: String = ""
|
||||
@State private var password: String = ""
|
||||
@FocusState private var focusedField: FocusedField?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Sign in with your Apple ID.")
|
||||
Text("SignInWithApple")
|
||||
.bold()
|
||||
.padding(.vertical)
|
||||
HStack {
|
||||
Text("Apple ID:")
|
||||
Text("AppleID")
|
||||
.frame(minWidth: 100, alignment: .trailing)
|
||||
TextField("example@icloud.com", text: $username)
|
||||
TextField(text: $username) {
|
||||
Text(verbatim: "example@icloud.com")
|
||||
}
|
||||
.focused($focusedField, equals: .username)
|
||||
}
|
||||
HStack {
|
||||
Text("Password:")
|
||||
Text("Password")
|
||||
.frame(minWidth: 100, alignment: .trailing)
|
||||
SecureField("Required", text: $password)
|
||||
.focused($focusedField, equals: .password)
|
||||
}
|
||||
if appState.authError != nil {
|
||||
HStack {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import SwiftUI
|
||||
import AppleAPI
|
||||
import SwiftUI
|
||||
|
||||
struct SignInPhoneListView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
|
@ -7,29 +7,33 @@ struct SignInPhoneListView: View {
|
|||
@State private var selectedPhoneNumberID: AuthOptionsResponse.TrustedPhoneNumber.ID?
|
||||
let authOptions: AuthOptionsResponse
|
||||
let sessionData: AppleSessionData
|
||||
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
if let phoneNumbers = authOptions.trustedPhoneNumbers, !phoneNumbers.isEmpty {
|
||||
Text("Select a trusted phone number to receive a \(authOptions.securityCode.length) digit code via SMS:")
|
||||
|
||||
Text(String(format: localizeString("SelectTrustedPhone"), authOptions.securityCode!.length))
|
||||
|
||||
List(phoneNumbers, selection: $selectedPhoneNumberID) {
|
||||
Text($0.numberWithDialCode)
|
||||
}
|
||||
.onAppear {
|
||||
if phoneNumbers.count == 1 {
|
||||
selectedPhoneNumberID = phoneNumbers.first?.id
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AttributedText(
|
||||
NSAttributedString(string: "Your account doesn't have any trusted phone numbers, but they're required for two-factor authentication.\n\nSee https://support.apple.com/en-ca/HT204915.")
|
||||
.convertingURLsToLinkAttributes()
|
||||
)
|
||||
Text("NoTrustedPhones")
|
||||
.font(.callout)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
|
||||
HStack {
|
||||
Button("Cancel", action: { isPresented = false })
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
ProgressButton(isInProgress: appState.isProcessingAuthRequest,
|
||||
action: { appState.requestSMS(to: authOptions.trustedPhoneNumbers!.first { $0.id == selectedPhoneNumberID }!, authOptions: authOptions, sessionData: sessionData) }) {
|
||||
action: { appState.requestSMS(to: authOptions.trustedPhoneNumbers!.first { $0.id == selectedPhoneNumberID }!, authOptions: authOptions, sessionData: sessionData) })
|
||||
{
|
||||
Text("Continue")
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
|
|
@ -49,9 +53,10 @@ struct SignInPhoneListView_Previews: PreviewProvider {
|
|||
SignInPhoneListView(
|
||||
isPresented: .constant(true),
|
||||
authOptions: AuthOptionsResponse(
|
||||
trustedPhoneNumbers: [.init(id: 0, numberWithDialCode: "(•••) •••-••90")],
|
||||
trustedPhoneNumbers: [.init(id: 0, numberWithDialCode: "(•••) •••-••90")],
|
||||
trustedDevices: nil,
|
||||
securityCode: .init(length: 6)),
|
||||
securityCode: .init(length: 6)
|
||||
),
|
||||
sessionData: AppleSessionData(serviceKey: "", sessionID: "", scnt: "")
|
||||
)
|
||||
.environmentObject(AppState())
|
||||
|
|
@ -59,9 +64,10 @@ struct SignInPhoneListView_Previews: PreviewProvider {
|
|||
SignInPhoneListView(
|
||||
isPresented: .constant(true),
|
||||
authOptions: AuthOptionsResponse(
|
||||
trustedPhoneNumbers: [],
|
||||
trustedPhoneNumbers: [],
|
||||
trustedDevices: nil,
|
||||
securityCode: .init(length: 6)),
|
||||
securityCode: .init(length: 6)
|
||||
),
|
||||
sessionData: AppleSessionData(serviceKey: "", sessionID: "", scnt: "")
|
||||
)
|
||||
.environmentObject(AppState())
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ struct SignInSMSView: View {
|
|||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Enter the \(authOptions.securityCode.length) digit code sent to \(trustedPhoneNumber.numberWithDialCode): ")
|
||||
Text(String(format: localizeString("EnterDigitCodeDescription"), authOptions.securityCode!.length, trustedPhoneNumber.numberWithDialCode))
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
PinCodeTextField(code: $code, numberOfDigits: authOptions.securityCode.length)
|
||||
PinCodeTextField(code: $code, numberOfDigits: authOptions.securityCode!.length) {
|
||||
appState.submitSecurityCode(.sms(code: $0, phoneNumberId: trustedPhoneNumber.id), sessionData: sessionData)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
|
|
@ -29,7 +31,7 @@ struct SignInSMSView: View {
|
|||
Text("Continue")
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(code.count != authOptions.securityCode.length)
|
||||
.disabled(code.count != authOptions.securityCode!.length)
|
||||
}
|
||||
.frame(height: 25)
|
||||
}
|
||||
|
|
|
|||
70
Xcodes/Frontend/SignIn/SignInSecurityKeyPinView.swift
Normal file
70
Xcodes/Frontend/SignIn/SignInSecurityKeyPinView.swift
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
//
|
||||
// SignInSecurityKeyPin.swift
|
||||
// Xcodes
|
||||
//
|
||||
// Created by Kino on 2024-09-26.
|
||||
// Copyright © 2024 Robots and Pencils. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import AppleAPI
|
||||
|
||||
struct SignInSecurityKeyPinView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@Binding var isPresented: Bool
|
||||
@State private var pin: String = ""
|
||||
let authOptions: AuthOptionsResponse
|
||||
let sessionData: AppleSessionData
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text(localizeString("SecurityKeyPinDescription"))
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
SecureField("PIN", text: $pin)
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
|
||||
HStack {
|
||||
Button("Cancel", action: { isPresented = false })
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
|
||||
Button("PIN not set", action: submitWithoutPinCode)
|
||||
|
||||
ProgressButton(isInProgress: appState.isProcessingAuthRequest,
|
||||
action: submitPinCode) {
|
||||
Text("Continue")
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
// FIDO2 device pin codes must be at least 4 code points
|
||||
// https://docs.yubico.com/yesdk/users-manual/application-fido2/fido2-pin.html
|
||||
.disabled(pin.count < 4)
|
||||
}
|
||||
.frame(height: 25)
|
||||
}
|
||||
.padding()
|
||||
.emittingError($appState.authError, recoveryHandler: { _ in })
|
||||
}
|
||||
|
||||
func submitPinCode() {
|
||||
appState.createAndSubmitSecurityKeyAssertationWithPinCode(pin, sessionData: sessionData, authOptions: authOptions)
|
||||
}
|
||||
|
||||
func submitWithoutPinCode() {
|
||||
appState.createAndSubmitSecurityKeyAssertationWithPinCode(nil, sessionData: sessionData, authOptions: authOptions)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SignInSecurityKeyPinView(isPresented: .constant(true),
|
||||
authOptions: AuthOptionsResponse(
|
||||
trustedPhoneNumbers: nil,
|
||||
trustedDevices: nil,
|
||||
securityCode: .init(length: 6)
|
||||
), sessionData: AppleSessionData(serviceKey: "", sessionID: "", scnt: ""))
|
||||
.environmentObject(AppState())
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue