mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-09 06:43:25 +01:00
Compare commits
1914 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
685d956775 | ||
|
|
00ed6e61be | ||
|
|
7057d59992 | ||
|
|
6d308487c1 | ||
|
|
6c45eb096f | ||
|
|
4b6ffa5d4a | ||
|
|
728b12d004 | ||
|
|
97160569ba | ||
|
|
1b754e137a | ||
|
|
4176b8c83c | ||
|
|
479f877dbf | ||
|
|
4ff643805b | ||
|
|
0e3c3dddfe | ||
|
|
7415fe6204 | ||
|
|
c128c9268b | ||
|
|
131a0289a2 | ||
|
|
116f045e51 | ||
|
|
c68efd2b44 | ||
|
|
e519389780 | ||
|
|
e6135c6518 | ||
|
|
3d444f301d | ||
|
|
7adfd3d3e8 | ||
|
|
591913743e | ||
|
|
77d95fe278 | ||
|
|
7ec6324b62 | ||
|
|
0cd62d371a | ||
|
|
9771f6bb9a | ||
|
|
e553efbad5 | ||
|
|
a4dfd28a3c | ||
|
|
291d7fd8d9 | ||
|
|
a7496e08e3 | ||
|
|
2f3be07b5d | ||
|
|
b57d279670 | ||
|
|
cbde3e6495 | ||
|
|
117f31e158 | ||
|
|
420efcbb11 | ||
|
|
dc29b65bd5 | ||
|
|
1e64f50ab9 | ||
|
|
ae8e9a3506 | ||
|
|
3785891923 | ||
|
|
e85db67ac7 | ||
|
|
39d0835f5b | ||
|
|
c8127fb3b9 | ||
|
|
729fca9100 | ||
|
|
c6703d66c1 | ||
|
|
2197161d55 | ||
|
|
cfaf79f878 | ||
|
|
2bea980d1f | ||
|
|
f11e27c712 | ||
|
|
b316e84f0d | ||
|
|
4668f8499b | ||
|
|
f9aedaba04 | ||
|
|
8cb3de9ab5 | ||
|
|
ca57d58219 | ||
|
|
6a56fbd206 | ||
|
|
cec3825de0 | ||
|
|
b3e99d1ae3 | ||
|
|
7243d79646 | ||
|
|
e50da6603c | ||
|
|
136f07e4b9 | ||
|
|
f4d367b857 | ||
|
|
3e96583525 | ||
|
|
84bb1f7c08 | ||
|
|
a5aec978bb | ||
|
|
d677292bd3 | ||
|
|
722f67d3c7 | ||
|
|
07e0aea24f | ||
|
|
673f2ba693 | ||
|
|
0070519736 | ||
|
|
359b38609b | ||
|
|
348a24d885 | ||
|
|
ebdd0d4cb4 | ||
|
|
614ab4cd33 | ||
|
|
ca38008328 | ||
|
|
e5713fa3a9 | ||
|
|
35e3cf1e14 | ||
|
|
ca8c394ae0 | ||
|
|
5323fdadcf | ||
|
|
e43bff5f8f | ||
|
|
4659d617f8 | ||
|
|
87fe360927 | ||
|
|
71212130c5 | ||
|
|
6370105c85 | ||
|
|
15f4ae7b5a | ||
|
|
08e11eece4 | ||
|
|
1a43ad4aa3 | ||
|
|
a5ec12e3df | ||
|
|
c0400446bc | ||
|
|
13c3d0c1e9 | ||
|
|
92edd4b800 | ||
|
|
eb0e1326b9 | ||
|
|
a8fd1a3e83 | ||
|
|
533655c96b | ||
|
|
a322f9b5e9 | ||
|
|
4805ed8d3b | ||
|
|
caa38cfcae | ||
|
|
77833c6ffc | ||
|
|
61086a681a | ||
|
|
cf81d2876c | ||
|
|
e5144d112a | ||
|
|
bf766c1b84 | ||
|
|
e608211f32 | ||
|
|
ef9135d7ee | ||
|
|
8b2c92d94c | ||
|
|
ac486a4723 | ||
|
|
c3847276f7 | ||
|
|
e43e962bcc | ||
|
|
0245f6072a | ||
|
|
ac63314a91 | ||
|
|
803eb615cd | ||
|
|
b218437388 | ||
|
|
df2ffb1235 | ||
|
|
7ed8c20dfc | ||
|
|
203a7e6f11 | ||
|
|
c6f843ebc3 | ||
|
|
13d924abf6 | ||
|
|
1641f6e93f | ||
|
|
c0a81edf6b | ||
|
|
e9391b7a21 | ||
|
|
8935ba08b4 | ||
|
|
eb539cd7f6 | ||
|
|
172481fee5 | ||
|
|
28de1953c4 | ||
|
|
29ed2afb3d | ||
|
|
332f56324c | ||
|
|
341e498b3f | ||
|
|
1a0a7eb9d7 | ||
|
|
5c55f45c84 | ||
|
|
c62cc3dabd | ||
|
|
acb8af5645 | ||
|
|
0da743e9a6 | ||
|
|
abd3735ae4 | ||
|
|
1eba9b60cb | ||
|
|
3e74e4ae5d | ||
|
|
e8798499d3 | ||
|
|
dc4a543b3b | ||
|
|
1649bb73d9 | ||
|
|
337349c324 | ||
|
|
d81c59ecf9 | ||
|
|
e62d9023f8 | ||
|
|
c5def98e87 | ||
|
|
48d691204b | ||
|
|
e2836fcd70 | ||
|
|
a8395ebcdc | ||
|
|
55f4aa7deb | ||
|
|
61989e7d40 | ||
|
|
d13e469cf2 | ||
|
|
6ed5acdb40 | ||
|
|
ba825d4218 | ||
|
|
720a397dd4 | ||
|
|
e29d9f7904 | ||
|
|
f69b293004 | ||
|
|
4e10527f03 | ||
|
|
46871f63ed | ||
|
|
bb8a1b57cd | ||
|
|
9283ce3289 | ||
|
|
efbcafc7cc | ||
|
|
c721fa01f9 | ||
|
|
bd5b011a62 | ||
|
|
b3382df216 | ||
|
|
5db45565f3 | ||
|
|
4e71e5d879 | ||
|
|
a967a7aaad | ||
|
|
74749b6502 | ||
|
|
bf8a42d490 | ||
|
|
a2f37c8895 | ||
|
|
1ea2f0e5e6 | ||
|
|
ee03d9fa51 | ||
|
|
51f2588d3c | ||
|
|
5416deddbe | ||
|
|
3525fb4afa | ||
|
|
82c04cd423 | ||
|
|
1666c40bd8 | ||
|
|
7ae10c6022 | ||
|
|
a547d2bc8a | ||
|
|
fba5ca4e12 | ||
|
|
2e01116f1f | ||
|
|
2e247f1773 | ||
|
|
e21e116535 | ||
|
|
8a77090586 | ||
|
|
d1be3f1914 | ||
|
|
198fb45c9d | ||
|
|
066d3e11a2 | ||
|
|
68470b61c5 | ||
|
|
6c5cf2ef06 | ||
|
|
73c86661be | ||
|
|
ff4a000406 | ||
|
|
c69c8c69fa | ||
|
|
e165273554 | ||
|
|
e3d08ebf16 | ||
|
|
3f959f111c | ||
|
|
950e54a04b | ||
|
|
a22fb3fd2f | ||
|
|
7971a896c6 | ||
|
|
210d235345 | ||
|
|
b39840e344 | ||
|
|
8b9e473ace | ||
|
|
178145f57e | ||
|
|
7f18e5a678 | ||
|
|
0b43b6d152 | ||
|
|
397bd21ff6 | ||
|
|
a642553b43 | ||
|
|
5e753855a3 | ||
|
|
d6ae65420d | ||
|
|
2c29e3f902 | ||
|
|
e01e31f3d5 | ||
|
|
caf491aa00 | ||
|
|
b80e0757e8 | ||
|
|
1f1b7ff083 | ||
|
|
f2e3a31520 | ||
|
|
79794f7fd5 | ||
|
|
157bfed965 | ||
|
|
e3d0dac09a | ||
|
|
e1d8887907 | ||
|
|
7e45d4fa33 | ||
|
|
015863b4fc | ||
|
|
a7b84bbc20 | ||
|
|
dded866025 | ||
|
|
88519ff5e8 | ||
|
|
3f2f93d2b5 | ||
|
|
c1910f49eb | ||
|
|
571a65a46a | ||
|
|
2c07d14a00 | ||
|
|
a2a199d64e | ||
|
|
893c628e80 | ||
|
|
523c543690 | ||
|
|
ebf055dc7d | ||
|
|
daa5ba1a9f | ||
|
|
54a895c11a | ||
|
|
2eeaeca8f4 | ||
|
|
7af0992a2b | ||
|
|
cf0a2001f0 | ||
|
|
cfe2111844 | ||
|
|
09e39d1ead | ||
|
|
dd8d6d447f | ||
|
|
992b6b7262 | ||
|
|
1f9326b452 | ||
|
|
36f64f6c0a | ||
|
|
901b9ae337 | ||
|
|
a173455e2d | ||
|
|
adee94819a | ||
|
|
c41d25c19b | ||
|
|
1203c9f5f2 | ||
|
|
1b85f6532b | ||
|
|
f080379994 | ||
|
|
a9a698b704 | ||
|
|
f9f56c4d66 | ||
|
|
fd03b33e9b | ||
|
|
2737384147 | ||
|
|
e005846324 | ||
|
|
bfed227940 | ||
|
|
8cfbe309ef | ||
|
|
d0eeefbc34 | ||
|
|
d716d88d33 | ||
|
|
5b69eb7bef | ||
|
|
21ab603756 | ||
|
|
7af8f5c817 | ||
|
|
460389f9c1 | ||
|
|
9b50ed83d3 | ||
|
|
89ec42ca87 | ||
|
|
0ab47360ff | ||
|
|
93ca83528b | ||
|
|
e39b9fe309 | ||
|
|
4cb85f3d59 | ||
|
|
7dc37d82e3 | ||
|
|
b7b5f50e69 | ||
|
|
7642c2f948 | ||
|
|
6cf8d4b48a | ||
|
|
d96056762f | ||
|
|
8f20b5bb8d | ||
|
|
7d2a6a9189 | ||
|
|
f7efb6569e | ||
|
|
4bc76fe93b | ||
|
|
59a5495ec0 | ||
|
|
7353a1f28b | ||
|
|
b757410044 | ||
|
|
5058658b66 | ||
|
|
f542a52bda | ||
|
|
976f4e1041 | ||
|
|
77e223e541 | ||
|
|
82cb14df6c | ||
|
|
6c559325b9 | ||
|
|
e662ba64fa | ||
|
|
49705a71f9 | ||
|
|
78081947c4 | ||
|
|
182dfb3c75 | ||
|
|
90c6c64e71 | ||
|
|
6c60c2092c | ||
|
|
90a1f4dd83 | ||
|
|
9597c7deb6 | ||
|
|
d045c0ed4d | ||
|
|
3ea24fdfea | ||
|
|
c19c68a2cf | ||
|
|
46ccbe5aad | ||
|
|
7ac485def0 | ||
|
|
7f60048b0c | ||
|
|
1d030a9550 | ||
|
|
5b8ca13565 | ||
|
|
ad431fb4b7 | ||
|
|
26b6c8d034 | ||
|
|
8ebb0d0f35 | ||
|
|
17018ea20f | ||
|
|
67f3c3a561 | ||
|
|
a2812c0528 | ||
|
|
a96fb13372 | ||
|
|
76070ffaa1 | ||
|
|
cec00769a9 | ||
|
|
a36043840f | ||
|
|
c0de04183c | ||
|
|
da949ec85f | ||
|
|
5a369574cc | ||
|
|
6650d3b73f | ||
|
|
e50cce0d5e | ||
|
|
148250f29a | ||
|
|
d6fc92cf5f | ||
|
|
99b16b83b6 | ||
|
|
792ca96ff3 | ||
|
|
3494c5b33b | ||
|
|
49502ce1ef | ||
|
|
eafeb97fa6 | ||
|
|
259870c92e | ||
|
|
e1cbec3864 | ||
|
|
779a82f3d4 | ||
|
|
d9d9a9a156 | ||
|
|
a7b31ec7a2 | ||
|
|
63a3203e50 | ||
|
|
e27c5f0b87 | ||
|
|
e298b440e8 | ||
|
|
03f46515ef | ||
|
|
81542d253f | ||
|
|
8bf3aef06c | ||
|
|
8a21c66927 | ||
|
|
44ade05e53 | ||
|
|
fd402f924f | ||
|
|
1f83ea00d3 | ||
|
|
d07b3e6c3a | ||
|
|
c0e780cbbd | ||
|
|
c86e00413b | ||
|
|
d2ed5bff57 | ||
|
|
07ed25ab54 | ||
|
|
2568f41e20 | ||
|
|
8ba28d0cd4 | ||
|
|
aa655fc5a3 | ||
|
|
d3d609550e | ||
|
|
fc5355345e | ||
|
|
aa1ed04bce | ||
|
|
2899e3ea5f | ||
|
|
6ee90f6c2a | ||
|
|
2f603778d6 | ||
|
|
ac62612a18 | ||
|
|
b8b32d501c | ||
|
|
edcdf94383 | ||
|
|
bce824254b | ||
|
|
846285eb1f | ||
|
|
74a231242e | ||
|
|
1c02da8806 | ||
|
|
f85dcdcd4a | ||
|
|
f477115003 | ||
|
|
79fc75edbd | ||
|
|
6d7d06a85e | ||
|
|
afb393b80b | ||
|
|
cfdc1aa82c | ||
|
|
2466c4d5c9 | ||
|
|
673eff4a51 | ||
|
|
3c73418fc3 | ||
|
|
b72b46b864 | ||
|
|
69a01a3262 | ||
|
|
becc626027 | ||
|
|
90fbb28b54 | ||
|
|
4688e9b927 | ||
|
|
7bcd0ea748 | ||
|
|
6a520b3410 | ||
|
|
f2ab214f27 | ||
|
|
de601cfacb | ||
|
|
b7a04d59b4 | ||
|
|
fec02cd80a | ||
|
|
9b1d65b571 | ||
|
|
be640930ce | ||
|
|
31aeec6b38 | ||
|
|
d7aa3b405d | ||
|
|
5d27397f03 | ||
|
|
bea54fa748 | ||
|
|
3391058475 | ||
|
|
55aa893b21 | ||
|
|
2ebd234ec8 | ||
|
|
2b3b60819e | ||
|
|
b0e43b8b97 | ||
|
|
4514fe1c2c | ||
|
|
1fbec33719 | ||
|
|
74b6fb6ec0 | ||
|
|
703db062e6 | ||
|
|
29627504cc | ||
|
|
1992ecd3a2 | ||
|
|
36ac3af7dc | ||
|
|
786bf4ac63 | ||
|
|
86d7afb95d | ||
|
|
81af866268 | ||
|
|
abc7b8d933 | ||
|
|
9ac26a99a8 | ||
|
|
80030acb87 | ||
|
|
0c958dad19 | ||
|
|
9ea94912d4 | ||
|
|
36743c0cf4 | ||
|
|
870ef0c47f | ||
|
|
5c808ec59e | ||
|
|
20b424c97c | ||
|
|
1b8daa59c0 | ||
|
|
71eb77cfda | ||
|
|
5cb40de113 | ||
|
|
9716ee6152 | ||
|
|
cf09843538 | ||
|
|
d625b381d9 | ||
|
|
05332ca122 | ||
|
|
be31611cb7 | ||
|
|
3773a051ab | ||
|
|
142b9c6810 | ||
|
|
cccbe3a80b | ||
|
|
0ad9ceaa95 | ||
|
|
8946ab8a65 | ||
|
|
a981201016 | ||
|
|
5da80863b9 | ||
|
|
850b6890e2 | ||
|
|
e370034e0b | ||
|
|
8add1d0f4a | ||
|
|
ba94886ba9 | ||
|
|
dddb9c5ddb | ||
|
|
389af4d5e6 | ||
|
|
aa9fda7a97 | ||
|
|
9f7f73f835 | ||
|
|
947b31881f | ||
|
|
7e232cafbe | ||
|
|
91ea34110b | ||
|
|
47b69b40aa | ||
|
|
99a3746e1a | ||
|
|
6ba642335b | ||
|
|
869b2dc92a | ||
|
|
f692da047a | ||
|
|
f352aaf9c5 | ||
|
|
299b5ca04c | ||
|
|
d83891d794 | ||
|
|
1b20f17052 | ||
|
|
583de6c0ec | ||
|
|
140193c040 | ||
|
|
50076f6e96 | ||
|
|
a53d45b1dc | ||
|
|
65562602af | ||
|
|
c20ed78cec | ||
|
|
2fa9dbb859 | ||
|
|
edf3281eee | ||
|
|
b89d086e79 | ||
|
|
67271c479c | ||
|
|
7977267107 | ||
|
|
a49e16f591 | ||
|
|
57059967c6 | ||
|
|
c15459e313 | ||
|
|
86ec59d204 | ||
|
|
6fc9ad010d | ||
|
|
932e66deca | ||
|
|
59a72ad096 | ||
|
|
d7384cfae9 | ||
|
|
e33a40ecb1 | ||
|
|
21b2a869a1 | ||
|
|
34c503da4b | ||
|
|
cd42cc827f | ||
|
|
83d8d2e38a | ||
|
|
c3820136a6 | ||
|
|
89347ffffa | ||
|
|
98125e93aa | ||
|
|
2aebaf80e0 | ||
|
|
1d19a31a86 | ||
|
|
ac8f82c30a | ||
|
|
b03b7bfe68 | ||
|
|
f9911d285d | ||
|
|
20cf2326c6 | ||
|
|
bff9eef2dd | ||
|
|
45df1c10cb | ||
|
|
61b2a9bb82 | ||
|
|
df43561494 | ||
|
|
4551451b57 | ||
|
|
727ab0b554 | ||
|
|
d53e36633d | ||
|
|
9ddc27f6ca | ||
|
|
1ece687e37 | ||
|
|
2ccf01cf9c | ||
|
|
c19b541739 | ||
|
|
27ca2f285b | ||
|
|
93b6da4855 | ||
|
|
6adf55b4b6 | ||
|
|
bca5c4e9a4 | ||
|
|
5e48e36ce2 | ||
|
|
4da8316c12 | ||
|
|
81c3825c92 | ||
|
|
3df1297b1c | ||
|
|
d89a15f74b | ||
|
|
55ccb723e5 | ||
|
|
6c0cfdf99e | ||
|
|
9a6272f8e0 | ||
|
|
07b1750a9c | ||
|
|
9cf61bd4df | ||
|
|
e34f3ce201 | ||
|
|
5d5da9e910 | ||
|
|
a341a15a5e | ||
|
|
58b6c0d6ac | ||
|
|
297a71bf91 | ||
|
|
d657ffc8ca | ||
|
|
a1865b6725 | ||
|
|
fa01fa708e | ||
|
|
80fc8e7a1e | ||
|
|
891609b64e | ||
|
|
021b49c436 | ||
|
|
f39ebfb905 | ||
|
|
c4117c0ac9 | ||
|
|
00423bec08 | ||
|
|
46bd977371 | ||
|
|
4410775aec | ||
|
|
b760418252 | ||
|
|
a7b28d5027 | ||
|
|
f83303a6b7 | ||
|
|
76ef018638 | ||
|
|
2aaa7761fc | ||
|
|
ea2600aba9 | ||
|
|
3f6688523a | ||
|
|
641c716d57 | ||
|
|
693969dc28 | ||
|
|
fd8dd20c1b | ||
|
|
7747994c80 | ||
|
|
9b885085c9 | ||
|
|
e605399633 | ||
|
|
a7d52db453 | ||
|
|
c1bbca9ed7 | ||
|
|
be8bf44784 | ||
|
|
15b3cd5f2d | ||
|
|
f926f596aa | ||
|
|
74bccf4caf | ||
|
|
5161c506f0 | ||
|
|
f19ae2f422 | ||
|
|
68a87f55bf | ||
|
|
6d10933a83 | ||
|
|
fa2689454b | ||
|
|
e0222c5f7c | ||
|
|
b60f9f8e08 | ||
|
|
f5b63b52b4 | ||
|
|
e03813c19e | ||
|
|
86ae06e0c8 | ||
|
|
a38eba8449 | ||
|
|
f9bd65a1b5 | ||
|
|
7f9ee81150 | ||
|
|
641e7d5f2e | ||
|
|
f5c40ae571 | ||
|
|
4f6eaf1aac | ||
|
|
c83d486269 | ||
|
|
99db3dc086 | ||
|
|
038efd9f9e | ||
|
|
8c85290c74 | ||
|
|
0fa941e6ef | ||
|
|
26c173c479 | ||
|
|
8f9cf96f3d | ||
|
|
22f3c881a1 | ||
|
|
0d79a01d74 | ||
|
|
a3c373108d | ||
|
|
ea0564126e | ||
|
|
2afaf73fc5 | ||
|
|
e8f676b10b | ||
|
|
9bb6f7eac0 | ||
|
|
07bc34ae7a | ||
|
|
3aa041d2ad | ||
|
|
f7640e35d1 | ||
|
|
efce9a8579 | ||
|
|
5a2f32704c | ||
|
|
177d453491 | ||
|
|
bec6ca9eec | ||
|
|
254a9773ec | ||
|
|
b9dd6432a1 | ||
|
|
d89c0f3e36 | ||
|
|
fd89f35246 | ||
|
|
ee410605e8 | ||
|
|
f884d72a8b | ||
|
|
bd3beb5983 | ||
|
|
44e08b2d66 | ||
|
|
d560e14423 | ||
|
|
7dfbba9b00 | ||
|
|
7ad8db7bdc | ||
|
|
64a9281e6e | ||
|
|
16c71be7f9 | ||
|
|
7d380da5d1 | ||
|
|
a09f4bbd7a | ||
|
|
5b275d6811 | ||
|
|
ea506b904d | ||
|
|
6985d0f476 | ||
|
|
357211e917 | ||
|
|
000cf1ca22 | ||
|
|
a2553f4c3f | ||
|
|
4b9d81cd13 | ||
|
|
e5824ddd35 | ||
|
|
23b6623020 | ||
|
|
bc7311c159 | ||
|
|
39f7e60e8b | ||
|
|
b88044757f | ||
|
|
7f2bd494b5 | ||
|
|
b2dcdc02c7 | ||
|
|
9c7d222a9e | ||
|
|
82cacb1b51 | ||
|
|
f44c3c18a2 | ||
|
|
bfea606bee | ||
|
|
5dfb36ca48 | ||
|
|
654f73f4ee | ||
|
|
5145e355ce | ||
|
|
20cd6d98fc | ||
|
|
12ca34f40f | ||
|
|
fc99fb32a4 | ||
|
|
779887e582 | ||
|
|
6fa2fa16f7 | ||
|
|
bdb1d68b6b | ||
|
|
404bd1450b | ||
|
|
06d28ca663 | ||
|
|
ed1365281f | ||
|
|
824fc48e77 | ||
|
|
8695c412d7 | ||
|
|
e4dfe1125a | ||
|
|
589ece3860 | ||
|
|
a5b7abea0d | ||
|
|
0a58a1fdc3 | ||
|
|
aa2409178b | ||
|
|
960492f1d0 | ||
|
|
726ba873fc | ||
|
|
f1f3e49bc5 | ||
|
|
d00e6de8a2 | ||
|
|
f24f721845 | ||
|
|
b7f5acd332 | ||
|
|
65598e2cd5 | ||
|
|
806421f19f | ||
|
|
9df4026ed4 | ||
|
|
17abda66ba | ||
|
|
f16e9c75b4 | ||
|
|
f9c22ff617 | ||
|
|
2cfc307359 | ||
|
|
66a17bc27f | ||
|
|
5da3974795 | ||
|
|
a8f0d9da9b | ||
|
|
4a3dbc20d6 | ||
|
|
624c4086f1 | ||
|
|
d54b7aa3bf | ||
|
|
1646c7cb83 | ||
|
|
ec0c0df78c | ||
|
|
1d1be0a8f9 | ||
|
|
7afd11fdc6 | ||
|
|
8fcc5622e1 | ||
|
|
8759ed091f | ||
|
|
d2d90ab9da | ||
|
|
2e987647dc | ||
|
|
e96a5114e5 | ||
|
|
6c7223b991 | ||
|
|
b5bcf229ae | ||
|
|
b60536dded | ||
|
|
881091595c | ||
|
|
8f1a91df1b | ||
|
|
2a7926539f | ||
|
|
2017584da4 | ||
|
|
db57de28d6 | ||
|
|
1fcdb18477 | ||
|
|
35561336c6 | ||
|
|
2f9f3e6c72 | ||
|
|
3c02938bfd | ||
|
|
23386c88ea | ||
|
|
0965299e6f | ||
|
|
65485ecdf5 | ||
|
|
3d70271306 | ||
|
|
83d39666d2 | ||
|
|
7409c0ef4e | ||
|
|
b8030ed0a9 | ||
|
|
a537e70459 | ||
|
|
9f38601102 | ||
|
|
f82743af98 | ||
|
|
76f8fc6d9a | ||
|
|
9d5248e2e8 | ||
|
|
9217044b1d | ||
|
|
5d87650553 | ||
|
|
f8d3d4971f | ||
|
|
7e01972cd4 | ||
|
|
f294f1045a | ||
|
|
3086492cbc | ||
|
|
583226392c | ||
|
|
531f8b5a0d | ||
|
|
9f04b3a9f1 | ||
|
|
95a3bbf6b9 | ||
|
|
b458e75098 | ||
|
|
5c526dba82 | ||
|
|
4e84b9ea27 | ||
|
|
cea53ca158 | ||
|
|
40855063c9 | ||
|
|
41a68a1897 | ||
|
|
9c8c1b4311 | ||
|
|
5e383c2148 | ||
|
|
dd88e03b4c | ||
|
|
9a3cb2b5ec | ||
|
|
01084039df | ||
|
|
feb35d2b6e | ||
|
|
3e941cfb0d | ||
|
|
931a34cf7d | ||
|
|
43dc332329 | ||
|
|
c8ae28003f | ||
|
|
cf32f25457 | ||
|
|
fee5309b50 | ||
|
|
b8c7f51d94 | ||
|
|
d00ce2bc11 | ||
|
|
2402655e56 | ||
|
|
283f7a998a | ||
|
|
2b355dbf8c | ||
|
|
ead80e50f9 | ||
|
|
71c53d81c2 | ||
|
|
8498f3df94 | ||
|
|
faa1555294 | ||
|
|
110f70e34c | ||
|
|
cd3b4c46b4 | ||
|
|
60a0657721 | ||
|
|
62c655c927 | ||
|
|
84b0f134b0 | ||
|
|
09902a3f71 | ||
|
|
3db004c733 | ||
|
|
4a566d8823 | ||
|
|
d37e033d35 | ||
|
|
afca84a852 | ||
|
|
9e0c5dab1a | ||
|
|
77eaa2fb5a | ||
|
|
b81ba38d1c | ||
|
|
d5a9050464 | ||
|
|
3c99fe22d3 | ||
|
|
f5b47d0508 | ||
|
|
db84a38519 | ||
|
|
c466e7698a | ||
|
|
6a8283a163 | ||
|
|
1ee334ca68 | ||
|
|
a3a34eb9ef | ||
|
|
db633c90be | ||
|
|
5e0e4dbd4e | ||
|
|
090967c21d | ||
|
|
6c8e9b886d | ||
|
|
2bb2eea226 | ||
|
|
a2bb26a86e | ||
|
|
948a8eb90b | ||
|
|
e66e223189 | ||
|
|
2aee6ac57e | ||
|
|
66abac80d8 | ||
|
|
a1e0d5f834 | ||
|
|
6a1181b21f | ||
|
|
c25ae10873 | ||
|
|
2842c8f669 | ||
|
|
3161892585 | ||
|
|
489843f987 | ||
|
|
dc0b30ab67 | ||
|
|
c3235cc554 | ||
|
|
6568e5918a | ||
|
|
91fba6db99 | ||
|
|
6b7e9a66f1 | ||
|
|
3682b65a4a | ||
|
|
117412645b | ||
|
|
c784ff6925 | ||
|
|
cf477024fc | ||
|
|
d595b7037f | ||
|
|
0c5007c8d8 | ||
|
|
8a87445d1f | ||
|
|
76a693fae4 | ||
|
|
9c150d5f4a | ||
|
|
e5febcdc6c | ||
|
|
1e969a0888 | ||
|
|
72bb549ea3 | ||
|
|
3c7cfdd91f | ||
|
|
b21f80cdd7 | ||
|
|
867a9c77e6 | ||
|
|
bc2d2c18fc | ||
|
|
ab923d245d | ||
|
|
fcf1b9ae03 | ||
|
|
59896e4f89 | ||
|
|
2a9f88c810 | ||
|
|
e98b0a3758 | ||
|
|
0cb6da7be4 | ||
|
|
fc3ff41fc4 | ||
|
|
719ddc8263 | ||
|
|
9f6b1284bb | ||
|
|
bfb4a90fdd | ||
|
|
d1b6bedc30 | ||
|
|
b78c75d829 | ||
|
|
4518f07b5b | ||
|
|
b4e18c50d3 | ||
|
|
d18482a04a | ||
|
|
f3d9dd777d | ||
|
|
e117c4b9a3 | ||
|
|
95666178e5 | ||
|
|
56403466b9 | ||
|
|
c7344ef548 | ||
|
|
71c4abfce8 | ||
|
|
0613af2240 | ||
|
|
dd832ad6df | ||
|
|
8cf7bc9998 | ||
|
|
c1cf11c04c | ||
|
|
0058c40f46 | ||
|
|
1397389f95 | ||
|
|
6a2d3e1d22 | ||
|
|
46ac704013 | ||
|
|
0fdab2a5c5 | ||
|
|
8f4586bfef | ||
|
|
52d0c9861f | ||
|
|
feace61eb4 | ||
|
|
1f5cc8f283 | ||
|
|
60b8520237 | ||
|
|
6a67c5e9a2 | ||
|
|
bcc241518c | ||
|
|
6dfa8f1556 | ||
|
|
19dde692b2 | ||
|
|
14dc93b5d2 | ||
|
|
547620235e | ||
|
|
b43fd0a54b | ||
|
|
8b782c9416 | ||
|
|
aab4e62e24 | ||
|
|
1713fccfc4 | ||
|
|
83ece72ae1 | ||
|
|
d60bcc49e1 | ||
|
|
bc9c37adda | ||
|
|
2583c7f617 | ||
|
|
fea5229e02 | ||
|
|
68be615057 | ||
|
|
370cafcba0 | ||
|
|
f923c1602e | ||
|
|
50a85be872 | ||
|
|
aae4725a3c | ||
|
|
9d76ee9f19 | ||
|
|
34a101b796 | ||
|
|
49b1fd751c | ||
|
|
4c5bf7bb7d | ||
|
|
2d71631d93 | ||
|
|
fa0d933956 | ||
|
|
b5d6384a07 | ||
|
|
d39644a4c9 | ||
|
|
a2feb34dc1 | ||
|
|
7e5fe64153 | ||
|
|
44175d071c | ||
|
|
bae26de444 | ||
|
|
b78707808d | ||
|
|
d41518581a | ||
|
|
4abbfe6142 | ||
|
|
dae813d80c | ||
|
|
af89b178ad | ||
|
|
8c269207fd | ||
|
|
42ecd38517 | ||
|
|
9f7d4dee49 | ||
|
|
458b8e491e | ||
|
|
495e621e69 | ||
|
|
c986512b5f | ||
|
|
d277754ae5 | ||
|
|
2ef2e2f26b | ||
|
|
23a53034fa | ||
|
|
ce57d72a78 | ||
|
|
502b89d890 | ||
|
|
5f0015fad0 | ||
|
|
c81236957b | ||
|
|
970ab38b27 | ||
|
|
8a5c31b81d | ||
|
|
8508fe79b5 | ||
|
|
3859e98801 | ||
|
|
a759c7be9e | ||
|
|
12fc6cf6e2 | ||
|
|
580db6530e | ||
|
|
9c67c237ee | ||
|
|
357d85a72e | ||
|
|
88ad828ce0 | ||
|
|
a95625a34a | ||
|
|
95e00d81f5 | ||
|
|
c2e386a5c5 | ||
|
|
a76aade4ff | ||
|
|
65c9986103 | ||
|
|
9e2b9b6639 | ||
|
|
cf373634d7 | ||
|
|
b3d5d976b4 | ||
|
|
c3c31995ce | ||
|
|
7e92e17429 | ||
|
|
88ab8fa8d7 | ||
|
|
ebe78932bf | ||
|
|
2e613e6d15 | ||
|
|
35ee92db12 | ||
|
|
04d9f760ad | ||
|
|
4f52743be8 | ||
|
|
32cae7a5b2 | ||
|
|
c2c0e3b790 | ||
|
|
6d36a30787 | ||
|
|
48a86ec6de | ||
|
|
5cff914ff3 | ||
|
|
70ea725ce3 | ||
|
|
78f12e45f9 | ||
|
|
e5061acc20 | ||
|
|
2d7bc51d30 | ||
|
|
9128b67ee8 | ||
|
|
551c004476 | ||
|
|
ed6a8d1379 | ||
|
|
766fb89e0b | ||
|
|
c5b8cb4459 | ||
|
|
0deae92829 | ||
|
|
cc5d2f1813 | ||
|
|
41151d0d49 | ||
|
|
52702264a3 | ||
|
|
6e297e1278 | ||
|
|
e3bb9b425f | ||
|
|
79255be79c | ||
|
|
7c836f5ba1 | ||
|
|
938bcd14ad | ||
|
|
229d79fc05 | ||
|
|
2d3dac2e1d | ||
|
|
e23f5e7894 | ||
|
|
571d27c814 | ||
|
|
dde6bd4fe3 | ||
|
|
6e6dbd9329 | ||
|
|
258268f5ef | ||
|
|
9ae49977fb | ||
|
|
d61c54fa60 | ||
|
|
980699af6f | ||
|
|
cc5c280882 | ||
|
|
090456bba1 | ||
|
|
5354d4eb76 | ||
|
|
b986fae611 | ||
|
|
cfcfc3e928 | ||
|
|
f97548fc3a | ||
|
|
36913b425c | ||
|
|
822ea08d89 | ||
|
|
98dd6f3fe7 | ||
|
|
b3f0dbb155 | ||
|
|
6904d931c3 | ||
|
|
529466a9f7 | ||
|
|
77dc695ba1 | ||
|
|
e17776f651 | ||
|
|
0d2f355a74 | ||
|
|
2ce1576016 | ||
|
|
0f3be3c494 | ||
|
|
8c1ca8503a | ||
|
|
32a59c17f4 | ||
|
|
b4b4ceab0b | ||
|
|
be1f27bb9e | ||
|
|
ed10ddb1cb | ||
|
|
dbdb4b0f32 | ||
|
|
59e537362e | ||
|
|
6d96bf414f | ||
|
|
e7ba778a5f | ||
|
|
933d349cd5 | ||
|
|
3de24dcfce | ||
|
|
3275d16b8b | ||
|
|
5bb4cd1dad | ||
|
|
16b14441fa | ||
|
|
93a6272d30 | ||
|
|
0dc526f778 | ||
|
|
183e185812 | ||
|
|
e02453598c | ||
|
|
24af1b5b5f | ||
|
|
5864c283f6 | ||
|
|
be78fa4b91 | ||
|
|
b3abf69a02 | ||
|
|
c530dc11ae | ||
|
|
d368ddbd11 | ||
|
|
e5c6521a15 | ||
|
|
898a59768e | ||
|
|
a85bc93142 | ||
|
|
c6c1f9faa0 | ||
|
|
0eea19c9cc | ||
|
|
ed2270ff46 | ||
|
|
45b6c3b338 | ||
|
|
84e2284f56 | ||
|
|
1c0d0be622 | ||
|
|
a9ce0f487d | ||
|
|
07533e0365 | ||
|
|
ee5ddd4264 | ||
|
|
f519d22d81 | ||
|
|
51ed87086a | ||
|
|
1ca3aa3cdb | ||
|
|
0178c63f6a | ||
|
|
8a97c409fa | ||
|
|
3dd0735305 | ||
|
|
536f775baa | ||
|
|
00f7a684a3 | ||
|
|
d79b166a6a | ||
|
|
b3d827f56a | ||
|
|
40bcef1dcb | ||
|
|
6146f1bdaa | ||
|
|
f5d82d9ef0 | ||
|
|
b2a29ae606 | ||
|
|
98ccba53a2 | ||
|
|
9bfda36647 | ||
|
|
5710cdf19c | ||
|
|
20cf54bfcd | ||
|
|
2ce639e750 | ||
|
|
b1ed413c4f | ||
|
|
b8c3060037 | ||
|
|
c3ea4940d7 | ||
|
|
40e1225b87 | ||
|
|
0c171122b2 | ||
|
|
6d0f4bb3da | ||
|
|
5e2cc6e20c | ||
|
|
99cb43bbea | ||
|
|
ca7d8277f7 | ||
|
|
337d26333e | ||
|
|
ebb64d255b | ||
|
|
7dcb199f68 | ||
|
|
4334e887de | ||
|
|
4e84dc4cc8 | ||
|
|
1a1ed072bf | ||
|
|
ae457f07c4 | ||
|
|
00095942c3 | ||
|
|
d1caa5fc21 | ||
|
|
813e2f97ac | ||
|
|
bcb5a90f5e | ||
|
|
020a1a3149 | ||
|
|
c4d649ec58 | ||
|
|
c02cf2c284 | ||
|
|
c30afd042e | ||
|
|
17640fe6cf | ||
|
|
2e4f6ee420 | ||
|
|
a3768d9221 | ||
|
|
80c3390363 | ||
|
|
a5e3869d8f | ||
|
|
aa7d7c2d02 | ||
|
|
015f205569 | ||
|
|
e59fb15926 | ||
|
|
173c585f2d | ||
|
|
6f8c27793e | ||
|
|
332b81c803 | ||
|
|
4b343b500d | ||
|
|
e87c537642 | ||
|
|
2e6300cce2 | ||
|
|
09514d15a6 | ||
|
|
0de23dcba0 | ||
|
|
bacb153151 | ||
|
|
a01aa299d8 | ||
|
|
44edbddbd8 | ||
|
|
79d677cf3c | ||
|
|
be39b6512f | ||
|
|
fcfeea35da | ||
|
|
7d0eb8c61e | ||
|
|
4d8438a6b6 | ||
|
|
f611244e35 | ||
|
|
546a978d3b | ||
|
|
70b23fb073 | ||
|
|
a56ca597d6 | ||
|
|
679e0228a8 | ||
|
|
e153394323 | ||
|
|
5bd1fcfcfd | ||
|
|
2a392ddc44 | ||
|
|
b5cb8bc0d9 | ||
|
|
fa170bcf98 | ||
|
|
7939d46949 | ||
|
|
ab9df8201a | ||
|
|
4a670ec091 | ||
|
|
10e57e59c4 | ||
|
|
b9ec43ef34 | ||
|
|
42197cd375 | ||
|
|
704852973b | ||
|
|
056b4200df | ||
|
|
250a7d8627 | ||
|
|
1ba51e161e | ||
|
|
32e58af896 | ||
|
|
312fa6fe76 | ||
|
|
afbe0837ba | ||
|
|
36ad2a720f | ||
|
|
901e3b14bb | ||
|
|
588d209f7b | ||
|
|
554c54e6be | ||
|
|
b0fac34ffc | ||
|
|
5ede9f7c6b | ||
|
|
c7254fd23e | ||
|
|
55fcea04af | ||
|
|
c212c0a6b2 | ||
|
|
a31fd6709a | ||
|
|
e367fd2b73 | ||
|
|
1ca67d0241 | ||
|
|
8ffa952ff9 | ||
|
|
da246fa30b | ||
|
|
13f306742e | ||
|
|
f3815dc45e | ||
|
|
d086254012 | ||
|
|
bc4d5ba097 | ||
|
|
c556783fe3 | ||
|
|
5fba4c12aa | ||
|
|
7e0dde3ece | ||
|
|
fc03e83531 | ||
|
|
4c441077c7 | ||
|
|
4a5ca81e9a | ||
|
|
75eebe8f8c | ||
|
|
271a8cdac5 | ||
|
|
25103c1188 | ||
|
|
d81058e606 | ||
|
|
693df54b3b | ||
|
|
ae6ed99dc4 | ||
|
|
14bd58e741 | ||
|
|
6d35a7a4ba | ||
|
|
46b0d1ceac | ||
|
|
67a66d2fcd | ||
|
|
43e90b57ea | ||
|
|
c80740e590 | ||
|
|
54ccb9611e | ||
|
|
8fcb897800 | ||
|
|
699eda5d1b | ||
|
|
d7d0a83550 | ||
|
|
e3c331c911 | ||
|
|
eda4dd6aec | ||
|
|
8ad7be474d | ||
|
|
a64435f155 | ||
|
|
fa160124d2 | ||
|
|
5765cb8330 | ||
|
|
f472b227bb | ||
|
|
d2b419c42e | ||
|
|
09d4de660f | ||
|
|
728dcd8523 | ||
|
|
93cf9bf6a9 | ||
|
|
50841f5e24 | ||
|
|
fc6d92d1fc | ||
|
|
7162a029bb | ||
|
|
d797ddd668 | ||
|
|
989e8c3aa6 | ||
|
|
08b79af242 | ||
|
|
0d2f346a30 | ||
|
|
39f1d5f5fd | ||
|
|
05008bb7f8 | ||
|
|
be90d6fc45 | ||
|
|
a1bcdf9924 | ||
|
|
b0e001393c | ||
|
|
2d08941f6a | ||
|
|
d0fef1f312 | ||
|
|
68342cb0d4 | ||
|
|
2b419212a7 | ||
|
|
b2cbc7e34d | ||
|
|
61247e575b | ||
|
|
31e18266d1 | ||
|
|
df8a8de889 | ||
|
|
8a037d6b29 | ||
|
|
47b555b98c | ||
|
|
0c2dae475e | ||
|
|
dc676d04d8 | ||
|
|
15b54bff50 | ||
|
|
47bd4b4c0b | ||
|
|
3c8b36ddfe | ||
|
|
608df3fddd | ||
|
|
c092c285ee | ||
|
|
93b745e379 | ||
|
|
c18db77ade | ||
|
|
2c0b167e6b | ||
|
|
313254d0c8 | ||
|
|
6f519c97d3 | ||
|
|
17a3e16b1d | ||
|
|
8199358088 | ||
|
|
412928eeaa | ||
|
|
51e1b935bd | ||
|
|
742b51e5e2 | ||
|
|
fdb5e2eebb | ||
|
|
0192f64cd2 | ||
|
|
193298ac87 | ||
|
|
a81cb81799 | ||
|
|
ad8a7fdc9b | ||
|
|
5440afcebe | ||
|
|
715d7e664c | ||
|
|
aa182cfa68 | ||
|
|
f92dd7a872 | ||
|
|
b02b9197d0 | ||
|
|
86d02be70c | ||
|
|
cb990978ee | ||
|
|
a103202c92 | ||
|
|
9d7b133037 | ||
|
|
f727f2a1a9 | ||
|
|
03034768d9 | ||
|
|
aed3e20e08 | ||
|
|
74bac6d986 | ||
|
|
7ebecc353a | ||
|
|
f0302b0d1e | ||
|
|
0b004ad089 | ||
|
|
c9001f068b | ||
|
|
96e0554aae | ||
|
|
31b4aadaba | ||
|
|
f46fa5392a | ||
|
|
3b6a17f193 | ||
|
|
aea77d3b8c | ||
|
|
7cfbe077db | ||
|
|
7bb620f941 | ||
|
|
5b0341a733 | ||
|
|
d99225da1f | ||
|
|
f279180a37 | ||
|
|
9a22018477 | ||
|
|
197119e56d | ||
|
|
dd055ddc5d | ||
|
|
94c3277245 | ||
|
|
fdaf402472 | ||
|
|
bce7764f75 | ||
|
|
70a258aae2 | ||
|
|
77cf00e8e4 | ||
|
|
de05579d1f | ||
|
|
c7d4b722d0 | ||
|
|
02b837c54b | ||
|
|
50e0e88cc2 | ||
|
|
c34245ff21 | ||
|
|
f1a8334f59 | ||
|
|
c8fc4ea500 | ||
|
|
39805bc103 | ||
|
|
2c615682df | ||
|
|
1257e4efac | ||
|
|
5e4a21087e | ||
|
|
2aaef99a54 | ||
|
|
161d3a795d | ||
|
|
9b671cb1a9 | ||
|
|
07d9a9f2c3 | ||
|
|
efabe7f536 | ||
|
|
82aead976e | ||
|
|
17be52c7b6 | ||
|
|
d0a196ec40 | ||
|
|
d484de185d | ||
|
|
96e4e7a4e8 | ||
|
|
4adb34b959 | ||
|
|
819bc12a68 | ||
|
|
eb23e5365f | ||
|
|
84e2faf8a8 | ||
|
|
84f58efc17 | ||
|
|
42254ee4a1 | ||
|
|
7c564aed7a | ||
|
|
8cdcb29274 | ||
|
|
403a369df9 | ||
|
|
5527912cd1 | ||
|
|
c8531dfe37 | ||
|
|
ed8bb2e5a1 | ||
|
|
dd66355488 | ||
|
|
fc3f83231c | ||
|
|
e70c712020 | ||
|
|
1b34aeaec4 | ||
|
|
2566bfa2ed | ||
|
|
ba06f2bbc6 | ||
|
|
1e4fe1680f | ||
|
|
2effb199a1 | ||
|
|
23c139320a | ||
|
|
f65eba606e | ||
|
|
7a2825da9a | ||
|
|
2975eddfe9 | ||
|
|
7f28eae954 | ||
|
|
789be5e942 | ||
|
|
85b114cdfd | ||
|
|
53d063e994 | ||
|
|
0ab081ccbc | ||
|
|
00ce9d64dc | ||
|
|
be20c024aa | ||
|
|
992fb9839a | ||
|
|
96ae2ee7ac | ||
|
|
379cecb08f | ||
|
|
9089b271b3 | ||
|
|
c9d522fad5 | ||
|
|
be80aa1512 | ||
|
|
c1d64a8027 | ||
|
|
1bc2aa9d38 | ||
|
|
e167ee104b | ||
|
|
43b85da314 | ||
|
|
b6c21c9766 | ||
|
|
874da8c8d6 | ||
|
|
989580d196 | ||
|
|
03ef54c37b | ||
|
|
ab56dda275 | ||
|
|
3a91f958e3 | ||
|
|
79913a0c9c | ||
|
|
d0fe64ecfa | ||
|
|
4da69685a1 | ||
|
|
bf560dd10d | ||
|
|
7ce76ee28d | ||
|
|
540e9bad29 | ||
|
|
22c2e2c4e5 | ||
|
|
f67d9dcdfa | ||
|
|
edfcadcbdc | ||
|
|
156bcc7d54 | ||
|
|
63ff912d76 | ||
|
|
6cbfaac4f4 | ||
|
|
6ad6e0d8c0 | ||
|
|
7c38bb03b9 | ||
|
|
5574172d99 | ||
|
|
bc8081ebae | ||
|
|
6ed6132c54 | ||
|
|
76c02c98d8 | ||
|
|
012a7885ff | ||
|
|
2b3d41d982 | ||
|
|
a56a48145b | ||
|
|
8dc097e23c | ||
|
|
0323520389 | ||
|
|
e1e395023d | ||
|
|
850214b103 | ||
|
|
02e9805482 | ||
|
|
0feae8402e | ||
|
|
042da53b54 | ||
|
|
aa1b2bace7 | ||
|
|
6b1b4d6015 | ||
|
|
ebeac417e5 | ||
|
|
be005616ea | ||
|
|
ec3a9b0615 | ||
|
|
0b3e651c4b | ||
|
|
426bdd3aa1 | ||
|
|
75e29d61f8 | ||
|
|
dfab283154 | ||
|
|
986c0d7edc | ||
|
|
918c44bc89 | ||
|
|
b8f680d74a | ||
|
|
76fcf6d545 | ||
|
|
c51d25c58b | ||
|
|
1efdba096c | ||
|
|
c4505b7c42 | ||
|
|
d5235bd40b | ||
|
|
cc3feb4843 | ||
|
|
353d105c04 | ||
|
|
070cb6c873 | ||
|
|
a066dda0f9 | ||
|
|
ac929c2603 | ||
|
|
9102402a18 | ||
|
|
54a0fc21d8 | ||
|
|
e2b8b7369e | ||
|
|
bcfbe515a4 | ||
|
|
46834ab5ce | ||
|
|
646000920f | ||
|
|
5ea83ccea1 | ||
|
|
03c6473685 | ||
|
|
d37890fac4 | ||
|
|
d5057ea8ea | ||
|
|
2cbebbe9b7 | ||
|
|
71b1885f74 | ||
|
|
2a8e3887ad | ||
|
|
2f92ce6bda | ||
|
|
9c58755317 | ||
|
|
9c1fe4d63b | ||
|
|
994d3c74fd | ||
|
|
a413c24b45 | ||
|
|
dc276a6393 | ||
|
|
cf6448845f | ||
|
|
b45c859861 | ||
|
|
fd81092392 | ||
|
|
26ef3073ae | ||
|
|
72a684a22f | ||
|
|
14529030be | ||
|
|
9570b797fd | ||
|
|
cdb5fb34dd | ||
|
|
ddff6a24f3 | ||
|
|
ae3c0acfc0 | ||
|
|
eef23ae49d | ||
|
|
2262f04fb3 | ||
|
|
b7a99ed508 | ||
|
|
38f68de3ea | ||
|
|
6b6f016189 | ||
|
|
82faa89912 | ||
|
|
dfd49de8d1 | ||
|
|
aa8dd80e54 | ||
|
|
751d9419ff | ||
|
|
643d7bf6fa | ||
|
|
afb20f79a9 | ||
|
|
6c2a83964b | ||
|
|
07daff261a | ||
|
|
8ddeb7f9fb | ||
|
|
1f7c089c70 | ||
|
|
f1f6852ab4 | ||
|
|
e5d66defbc | ||
|
|
29913c5b09 | ||
|
|
21d807d0c3 | ||
|
|
a6e5c32166 | ||
|
|
0b3e94b974 | ||
|
|
6db5aec672 | ||
|
|
73ff5fe9dc | ||
|
|
77694aac8e | ||
|
|
a04a27c1e3 | ||
|
|
7a547c70e3 | ||
|
|
b0abf0e7a5 | ||
|
|
48c49c6ec7 | ||
|
|
16564500e2 | ||
|
|
6f6b17b211 | ||
|
|
f1618ad9df | ||
|
|
947a14d6a2 | ||
|
|
f030ecd66f | ||
|
|
ec86fb77b0 | ||
|
|
cfa246adc5 | ||
|
|
ebb236e47c | ||
|
|
37b00d670b | ||
|
|
23516d0466 | ||
|
|
2b4f1ce1c2 | ||
|
|
c786858f17 | ||
|
|
a149cb231b | ||
|
|
3c9ef728e1 | ||
|
|
4257f58f96 | ||
|
|
ddfab31781 | ||
|
|
5e3e8f2809 | ||
|
|
fefa8b174d | ||
|
|
fb3d732a62 | ||
|
|
0658e323ae | ||
|
|
35046b33ff | ||
|
|
8f4c70c9cc | ||
|
|
c9bc14ab7f | ||
|
|
0f023905c8 | ||
|
|
61dc02514a | ||
|
|
590998fbaa | ||
|
|
7a1f631113 | ||
|
|
706229640f | ||
|
|
0397db51f7 | ||
|
|
e53928cf1e | ||
|
|
17b8fd6e6f | ||
|
|
03338b589c | ||
|
|
ac8560afd3 | ||
|
|
ef0cae6953 | ||
|
|
ed396b400d | ||
|
|
e6ef288a69 | ||
|
|
619d16ddd3 | ||
|
|
943fe79d3c | ||
|
|
310d4619b4 | ||
|
|
35e9d8752b | ||
|
|
aa057918ee | ||
|
|
5428ebf129 | ||
|
|
1384037430 | ||
|
|
c2bda2241c | ||
|
|
337d432fdd | ||
|
|
45d104fd2c | ||
|
|
00b2e25b01 | ||
|
|
1b16193e21 | ||
|
|
203aec2854 | ||
|
|
5997ac5424 | ||
|
|
b4f97aadf1 | ||
|
|
b7caaeb788 | ||
|
|
c3ca4fa8f3 | ||
|
|
d5563aafba | ||
|
|
c89c244225 | ||
|
|
08e540e12f | ||
|
|
2849eebb28 | ||
|
|
683307b9af | ||
|
|
5231ea1c1e | ||
|
|
35ae81c76c | ||
|
|
a4d7d94301 | ||
|
|
44b0092b44 | ||
|
|
c6b8f69ef2 | ||
|
|
eac35ef8f4 | ||
|
|
e9eee50b3e | ||
|
|
f7c797e0b0 | ||
|
|
f9d66e0a78 | ||
|
|
a9d1d6edf5 | ||
|
|
babb2c0856 | ||
|
|
0570f2cd5b | ||
|
|
9c72b7ae8f | ||
|
|
e4b0b153e5 | ||
|
|
3edd8d5ebe | ||
|
|
bf68a284bb | ||
|
|
978544ed3f | ||
|
|
98135bc5fd | ||
|
|
626924bc34 | ||
|
|
a0fd2b6d16 | ||
|
|
44c431e9e0 | ||
|
|
6852f892f0 | ||
|
|
ec1eaf00eb | ||
|
|
ae0aa7dc65 | ||
|
|
29f78c7429 | ||
|
|
cd8834e368 | ||
|
|
f3fc967710 | ||
|
|
7d93c64b5b | ||
|
|
d4b957db23 | ||
|
|
d9678855a0 | ||
|
|
65c01e3f6e | ||
|
|
6821cee443 | ||
|
|
15a12da321 | ||
|
|
009d064576 | ||
|
|
3a4e2d9f9b | ||
|
|
e7afa235f7 | ||
|
|
edc5bd5d21 | ||
|
|
c06b09e00c | ||
|
|
3eeba27191 | ||
|
|
c6d1a040a1 | ||
|
|
558a3fc865 | ||
|
|
e0b50ac80c | ||
|
|
07ef7ae18f | ||
|
|
d07bd33e06 | ||
|
|
1616ca1c34 | ||
|
|
52fe74fbea | ||
|
|
8857ccbf86 | ||
|
|
279a290b60 | ||
|
|
128a3fe2f2 | ||
|
|
c97acfc76c | ||
|
|
bc2dae1b21 | ||
|
|
983b8ebe38 | ||
|
|
b6ba4640de | ||
|
|
5214aaafe7 | ||
|
|
39713f95ea | ||
|
|
b88f56e185 | ||
|
|
248444c04d | ||
|
|
dbd27e6113 | ||
|
|
3f09a79645 | ||
|
|
98b3746b25 | ||
|
|
07f8c38820 | ||
|
|
8393c07601 | ||
|
|
22d7595357 | ||
|
|
2157d95c56 | ||
|
|
729b2a1f0d | ||
|
|
cbcd5fbd2c | ||
|
|
a3318b1253 | ||
|
|
e59c7e1124 | ||
|
|
8dc108030d | ||
|
|
6e4feecff0 | ||
|
|
77c085ef1a | ||
|
|
acc202031c | ||
|
|
2354f85998 | ||
|
|
e1a6bd3d53 | ||
|
|
1420cbd86e | ||
|
|
a3a69b5cbd | ||
|
|
b2ee7cfa2c | ||
|
|
84869af81a | ||
|
|
352fb1be73 | ||
|
|
7cfcab312c | ||
|
|
8889923111 | ||
|
|
49f5f96097 | ||
|
|
37aaf2cdb6 | ||
|
|
e632ad0d84 | ||
|
|
fc49bc25f3 | ||
|
|
95eeafa06b | ||
|
|
a2f531a460 | ||
|
|
689d61d7d1 | ||
|
|
1f6edd778b | ||
|
|
4abd4c2f7f | ||
|
|
3ad3fe5cce | ||
|
|
6c4931b0ba | ||
|
|
fc75ed730d | ||
|
|
a767762f49 | ||
|
|
699632caa7 | ||
|
|
e2ce2b3776 | ||
|
|
aedb3012a4 | ||
|
|
915eed3a69 | ||
|
|
f7a2c9f9f0 | ||
|
|
f8f26bfb40 | ||
|
|
2b53e3483a | ||
|
|
1ce9731465 | ||
|
|
3b45ab7f62 | ||
|
|
1948894502 | ||
|
|
bb3b039672 | ||
|
|
66ef234f02 | ||
|
|
a94a6b3f4b | ||
|
|
0d06e028cd | ||
|
|
5d441fd23a | ||
|
|
21a731987e | ||
|
|
831b8cab4d | ||
|
|
80f00e8927 | ||
|
|
5afffb38aa | ||
|
|
67da21ccfc | ||
|
|
f63e88d081 | ||
|
|
aa1bc25ac8 | ||
|
|
291c35c1b3 | ||
|
|
9412f4d24f | ||
|
|
fb3946aad5 | ||
|
|
fe871e0a30 | ||
|
|
f1349964d4 | ||
|
|
488e589943 | ||
|
|
791cad5e9c | ||
|
|
719cee9122 | ||
|
|
3c350e4671 | ||
|
|
2dc872392a | ||
|
|
1f8e16dce8 | ||
|
|
00e8b7c80e | ||
|
|
c8b4ce8d38 | ||
|
|
5000b43533 | ||
|
|
9c04ad846a | ||
|
|
788a77b280 | ||
|
|
8b01a8d67c | ||
|
|
7a0e9d5835 | ||
|
|
668ca66a04 | ||
|
|
546db3fa23 | ||
|
|
12f33c355a | ||
|
|
707c2db508 | ||
|
|
700046e693 | ||
|
|
b291f7b606 | ||
|
|
615d4fb35b | ||
|
|
acc2ca7caf | ||
|
|
cc1ff5b51d | ||
|
|
724f1fc22d | ||
|
|
af7fe484a2 | ||
|
|
361b84e3a1 | ||
|
|
226795eafd | ||
|
|
de174db1bc | ||
|
|
e54d309f39 | ||
|
|
50a5d56856 | ||
|
|
aaaf6ed38d | ||
|
|
8045a23531 | ||
|
|
5abf7a5a11 | ||
|
|
669c6f5bf4 | ||
|
|
9af9347e0c | ||
|
|
25f06cccf1 | ||
|
|
b0c36adedb | ||
|
|
88c8d5f0f8 | ||
|
|
26fe9ca72b | ||
|
|
d1b897e212 | ||
|
|
5df4169a1b | ||
|
|
80a39889ca | ||
|
|
f202e985db | ||
|
|
bfc2ea2c3a | ||
|
|
e506ceb25a | ||
|
|
671a12b89c | ||
|
|
8021ff8871 | ||
|
|
fb9b1a5c7d | ||
|
|
e70c51e36c | ||
|
|
8d2e3f92b5 | ||
|
|
0256079738 | ||
|
|
47d85b7bab | ||
|
|
cace7576e2 | ||
|
|
3d9417c071 | ||
|
|
f1a39e1a1f | ||
|
|
de925e7fea | ||
|
|
e75d184194 | ||
|
|
3def65f501 | ||
|
|
1d160aeeea | ||
|
|
846b2c16d1 | ||
|
|
a6c882e282 | ||
|
|
e03f881f07 | ||
|
|
89705469e1 | ||
|
|
3817f700b9 | ||
|
|
70a475ff5f | ||
|
|
4c3d33efdc | ||
|
|
b7564207b3 | ||
|
|
43395c4db5 | ||
|
|
012917f938 | ||
|
|
f02fcad3a0 | ||
|
|
a3a4af182d | ||
|
|
49d6e66745 | ||
|
|
ad33f6e1fb | ||
|
|
a0aaa680fd | ||
|
|
67166b4421 | ||
|
|
c0f3bd8bb7 | ||
|
|
7262a6a1a0 | ||
|
|
bcf02a4cfe | ||
|
|
cdcc5c941d | ||
|
|
eea409dd03 | ||
|
|
dc1fbe8f63 | ||
|
|
728a4b7123 | ||
|
|
56cf77be42 | ||
|
|
4e07831635 | ||
|
|
ad6bee7801 | ||
|
|
042ad856a9 | ||
|
|
7cace2cacb | ||
|
|
2b00ea5107 | ||
|
|
43be34fd34 | ||
|
|
4d9fad5d53 | ||
|
|
83622b68dc | ||
|
|
d6a33176e6 | ||
|
|
0d37ebd7fd | ||
|
|
5884c78b8e | ||
|
|
bef3eb3964 | ||
|
|
0be1be5769 | ||
|
|
db87d9ca7b | ||
|
|
186ad09ab3 | ||
|
|
fafec6c904 | ||
|
|
496aca642c | ||
|
|
cb4656722a | ||
|
|
70f897699c | ||
|
|
0b36214bb5 | ||
|
|
f9342acb30 | ||
|
|
f96de8d082 | ||
|
|
0bef37e91f | ||
|
|
a69d15f1b1 | ||
|
|
284f90ccd3 | ||
|
|
2411cca51f | ||
|
|
64f8983d29 | ||
|
|
540c9cc8af | ||
|
|
f564fc5190 | ||
|
|
fff128e1ce | ||
|
|
da2370d9ac | ||
|
|
17594a51d1 | ||
|
|
05dc365dff | ||
|
|
39b60a07d9 | ||
|
|
e0dea67380 | ||
|
|
8bd4e25b7f | ||
|
|
b3f2474456 | ||
|
|
60abb9ee07 | ||
|
|
4a893d3c80 | ||
|
|
de34e077ce | ||
|
|
2d87c396f1 | ||
|
|
19bf19350e | ||
|
|
d8f1dcb032 | ||
|
|
753fb740fe | ||
|
|
1582d1b143 | ||
|
|
c403d7c788 | ||
|
|
7c9d8bd90d | ||
|
|
7cbe921020 | ||
|
|
8354794c24 | ||
|
|
b25a0e46cb | ||
|
|
1b8b043290 | ||
|
|
a4d9188bc7 | ||
|
|
47cf59a1ad | ||
|
|
b9b2afa200 | ||
|
|
ea6861b1eb | ||
|
|
a0b5d6d8ae | ||
|
|
484742885f | ||
|
|
2fc19f6741 | ||
|
|
f5fc64be44 | ||
|
|
fe62d6f80f | ||
|
|
c5a97f6c25 | ||
|
|
2ae1ddb2d5 | ||
|
|
29dda98736 | ||
|
|
76008022e7 | ||
|
|
b4299c71fb | ||
|
|
25477422a9 | ||
|
|
cba98ddf57 | ||
|
|
0f9df5af8a | ||
|
|
41b57b7f5e | ||
|
|
bab1fcb7bc | ||
|
|
4f6e194b35 | ||
|
|
6cdbe8e9ff | ||
|
|
7b4acc56fc | ||
|
|
fb03cb34aa | ||
|
|
8ea9c30d7e | ||
|
|
4bdeb53f9f | ||
|
|
f1199abd4a | ||
|
|
c3257bfbb8 | ||
|
|
e0d2bab21e | ||
|
|
7b7613c331 | ||
|
|
274a4aea44 | ||
|
|
98146ca8f3 | ||
|
|
c85da1495d | ||
|
|
1b89b81de0 | ||
|
|
29af9af3f3 | ||
|
|
b8e1921b74 | ||
|
|
664c31aba8 | ||
|
|
40d4899bd1 | ||
|
|
c1aad80578 | ||
|
|
0f939700e2 | ||
|
|
193ca28c98 | ||
|
|
cd89741827 | ||
|
|
4e29c7a38c | ||
|
|
45737250a7 | ||
|
|
197c3b3338 | ||
|
|
162139d52b | ||
|
|
4d75116c2d | ||
|
|
99df5aea3e | ||
|
|
cf46bd0a46 | ||
|
|
c9bffbe74f | ||
|
|
794d26b016 | ||
|
|
e80847f2a9 | ||
|
|
992226f75a | ||
|
|
a90c0c05a0 | ||
|
|
590ce5c928 | ||
|
|
9e465f8eaa | ||
|
|
1fb6be5bbe | ||
|
|
4fcd691fae | ||
|
|
8af1d3f131 | ||
|
|
3b7b6a014b | ||
|
|
0566c152f6 | ||
|
|
63c55b41ec | ||
|
|
a2acbcd5b5 | ||
|
|
4fd2b448bd | ||
|
|
0d735431e9 | ||
|
|
f332060459 | ||
|
|
5afc513180 | ||
|
|
0d65fc9974 | ||
|
|
a6746754b8 | ||
|
|
7474cf4fd1 | ||
|
|
b36c09792d | ||
|
|
a95457cca0 | ||
|
|
800dd79c30 | ||
|
|
bc02cfc8a9 | ||
|
|
06fed802b1 | ||
|
|
5e25593c3d | ||
|
|
4f00018164 | ||
|
|
27bce4e456 | ||
|
|
afdefc23ce | ||
|
|
1290ffba66 | ||
|
|
7a6d9970e8 | ||
|
|
07efd681c1 | ||
|
|
ba842ff718 | ||
|
|
88929a1e98 | ||
|
|
891da58cfd | ||
|
|
e6230e0140 | ||
|
|
0f25c34ec7 | ||
|
|
b091d1da93 | ||
|
|
63a83dac57 | ||
|
|
fba2f0f1f6 | ||
|
|
c33d2daeea | ||
|
|
a763f469e1 | ||
|
|
5045c1057a | ||
|
|
390a770115 | ||
|
|
9a50774f5f | ||
|
|
49c50154be | ||
|
|
eac85a819e | ||
|
|
b0f21605f5 | ||
|
|
269580c127 | ||
|
|
cd5769b294 | ||
|
|
230915e536 | ||
|
|
01e95e1baf | ||
|
|
74f44ddfe8 | ||
|
|
b196981c89 | ||
|
|
e823d5f621 | ||
|
|
bcee0f5577 | ||
|
|
b6ac0b5f06 | ||
|
|
e7930b95d0 | ||
|
|
7fb79f558d | ||
|
|
301d7261c2 | ||
|
|
5f29b38d64 | ||
|
|
345862c770 | ||
|
|
8ba41a9c5b | ||
|
|
c2d1b3628e | ||
|
|
a20feccae2 | ||
|
|
e5061b52c2 | ||
|
|
c49b357868 | ||
|
|
c6a0437577 | ||
|
|
c79281cc32 | ||
|
|
c2048f3814 | ||
|
|
7e2f2a5877 | ||
|
|
bf05c7119c | ||
|
|
b93ea8c5a1 | ||
|
|
2615e217b3 | ||
|
|
ae98105772 | ||
|
|
ce9c222402 | ||
|
|
f1e598b0b6 | ||
|
|
e0a899ee9a | ||
|
|
e3ea200ad5 | ||
|
|
748ad8588d | ||
|
|
0a2a54240d | ||
|
|
9211aef6d1 | ||
|
|
11a4e1a2a7 | ||
|
|
222cae7ede | ||
|
|
2f82d2218c | ||
|
|
ae5ba81138 | ||
|
|
48dfe5b2da | ||
|
|
be1ea160e5 | ||
|
|
9fcee16466 | ||
|
|
95a1399e31 | ||
|
|
a4c8c2ed07 | ||
|
|
7ebe36cce8 | ||
|
|
f0f15e984e | ||
|
|
93fe4f6c2e | ||
|
|
0d8d9ecd3b | ||
|
|
56e1e7df1a | ||
|
|
7b9207ebe2 | ||
|
|
691e08202d | ||
|
|
9535595df1 | ||
|
|
438fc7cfa0 | ||
|
|
9a55ef7117 | ||
|
|
3ba1669e51 | ||
|
|
2ceadeb908 | ||
|
|
201839635b | ||
|
|
77a119f292 | ||
|
|
1650951d53 | ||
|
|
a381565172 | ||
|
|
e249bc564e | ||
|
|
6ab56ad6d1 | ||
|
|
36e8f6dd94 | ||
|
|
249848d978 | ||
|
|
9738612194 | ||
|
|
0afc87cad4 | ||
|
|
79f05b0a89 | ||
|
|
b194b4b642 | ||
|
|
f10f519eab | ||
|
|
991846bd64 | ||
|
|
7485472095 | ||
|
|
aba9f67393 | ||
|
|
6bd3e93bea | ||
|
|
839a6cc534 | ||
|
|
b29faefdec | ||
|
|
e785fc47ee | ||
|
|
1bde885b17 | ||
|
|
1fed0ba710 | ||
|
|
6e6bc1ca64 | ||
|
|
4013029c04 | ||
|
|
6f58cb9579 | ||
|
|
6ea8503c3d | ||
|
|
aa52633491 | ||
|
|
28d27c862f | ||
|
|
5c95f7727a | ||
|
|
c39e9945ca | ||
|
|
3bb3fba017 | ||
|
|
ac8c6567db | ||
|
|
d3103c5513 | ||
|
|
fcbfe7d4df | ||
|
|
a5950617f1 | ||
|
|
92fb428e47 | ||
|
|
6f7d230895 | ||
|
|
e7ef101f99 | ||
|
|
c8d9c2f863 | ||
|
|
e1d9aa1391 | ||
|
|
d3623aa55e | ||
|
|
25ff5b566f | ||
|
|
bd792c3062 | ||
|
|
c4c4f8cff7 | ||
|
|
878dc35c83 | ||
|
|
cb3489f69c | ||
|
|
f1d287294d | ||
|
|
d76543d045 | ||
|
|
7342f6d4b4 | ||
|
|
198e7c7caf | ||
|
|
1d740500f7 | ||
|
|
fb054c440b | ||
|
|
8c7f554909 | ||
|
|
2b0e629dd1 | ||
|
|
7a1f402c5d | ||
|
|
ab56ce6004 | ||
|
|
53e948c0a9 | ||
|
|
b4f8ae00db | ||
|
|
9e610ddb73 | ||
|
|
7fc822948c | ||
|
|
2d279775fe | ||
|
|
820b1fb718 | ||
|
|
f6a797975f | ||
|
|
2977b79dcb | ||
|
|
0ce078a675 | ||
|
|
de74aed83e | ||
|
|
01e2f635f8 | ||
|
|
7b3f78082e | ||
|
|
046b36f4c4 | ||
|
|
1504a277d5 | ||
|
|
865e3778b8 | ||
|
|
4c9480e6de | ||
|
|
14b2a10b4e | ||
|
|
caac63c93b | ||
|
|
32b4611c1e | ||
|
|
993fa3eebb | ||
|
|
3195a3f65d | ||
|
|
b60d693056 | ||
|
|
3faed8cf5c | ||
|
|
6c91db1dcd | ||
|
|
f506988296 | ||
|
|
883e8cfbed | ||
|
|
997376938a | ||
|
|
f51e41efab | ||
|
|
1117c05349 | ||
|
|
26f799de72 | ||
|
|
9ea584c1fb | ||
|
|
73c44c5e29 |
39
.editorconfig
Normal file
39
.editorconfig
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# EditorConfig is awesome: https://EditorConfig.org
|
||||||
|
|
||||||
|
# top-most EditorConfig file
|
||||||
|
root = true
|
||||||
|
|
||||||
|
# Unix-style newlines with a newline ending every file
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
# Matches multiple files with brace expansion notation
|
||||||
|
# Set default charset
|
||||||
|
[*.{js,py}]
|
||||||
|
charset = utf-8# 4 space indentation
|
||||||
|
|
||||||
|
# Swift files
|
||||||
|
[*.swift]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
charset = utf-8# 4 space indentation
|
||||||
|
|
||||||
|
# 4 space indentation
|
||||||
|
[*.py]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
# Tab indentation (no size specified)
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
# Indentation override for all JS under lib directory
|
||||||
|
[lib/**.js]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# Matches the exact files either package.json or .travis.yml
|
||||||
|
[{package.json,.travis.yml}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
1007
.github/.obsolete/reusable-build-workflow.yml
vendored
Normal file
1007
.github/.obsolete/reusable-build-workflow.yml
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* @JoeMatt @lonkelle @nythepegasus @Spidy123222 @SternXD
|
||||||
40
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
40
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Report a bug
|
||||||
|
title: "[BUG] "
|
||||||
|
labels: ["bug"]
|
||||||
|
assignees: []
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## Please note that the issue tracker is not for support
|
||||||
|
Thanks for taking the time to fill out this bug report! Before you continue filling out the report, please **[search in GitHub Issues](https://github.com/SideStore/SideStore/issues?q=is%3Aissue+is%3Aopen) for the bug you are experiencing** in case it has already been reported.
|
||||||
|
|
||||||
|
**Please use [Discord](https://discord.gg/sidestore-949183273383395328) or [GitHub Discussions](https://github.com/SideStore/SideStore/discussions) for support.**
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Describe the bug
|
||||||
|
description: What is the bug and how did you discover it?
|
||||||
|
placeholder: Please be clear and concise with your description.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: how-to-reproduce
|
||||||
|
attributes:
|
||||||
|
label: Instructions to reproduce
|
||||||
|
description: Please include clear and consistent instructions for reproducing the bug to make it easier for us to fix it.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: app-version
|
||||||
|
attributes:
|
||||||
|
label: What version of SideStore are you using?
|
||||||
|
description: To retrieve this, go to `Settings` in the SideStore app and scroll down to the bottom.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: other-info
|
||||||
|
attributes:
|
||||||
|
label: Other info
|
||||||
|
description: If you have any other comments, other info that might be useful, or if you found a workaround, please put it here.
|
||||||
10
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
10
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# force issue template usage
|
||||||
|
blank_issues_enabled: false
|
||||||
|
|
||||||
|
contact_links:
|
||||||
|
- name: Discord
|
||||||
|
url: https://discord.gg/sidestore-949183273383395328
|
||||||
|
about: If you need support, please go here first instead of making an issue!
|
||||||
|
- name: GitHub Discussions
|
||||||
|
url: https://github.com/SideStore/SideStore/discussions
|
||||||
|
about: As an alternative to Discord, you can also make a new GitHub discussion.
|
||||||
32
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
32
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest a feature
|
||||||
|
title: "[FEATURE REQUEST] "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
assignees: []
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to fill out this feature request! Before you continue filling out the form, please **[search in GitHub Issues](https://github.com/SideStore/SideStore/issues?q=is%3Aissue+is%3Aopen) for the feature you are suggestion** in case it has already been suggested.
|
||||||
|
|
||||||
|
**Please use [Discord](https://discord.gg/sidestore-949183273383395328) or [GitHub Discussions](https://github.com/SideStore/SideStore/discussions) for support.**
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Describe the feature
|
||||||
|
description: What is the feature? How would it work?
|
||||||
|
placeholder: Please be clear and concise with your description.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: use-cases
|
||||||
|
attributes:
|
||||||
|
label: Use cases
|
||||||
|
description: Please include multiple use cases where this feature would be useful.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives
|
||||||
|
description: If you have alternative ideas of how this feature could work, you can put them here.
|
||||||
63
.github/maintenance/cache.py
vendored
Normal file
63
.github/maintenance/cache.py
vendored
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import requests
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Your GitHub Personal Access Token
|
||||||
|
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
|
||||||
|
|
||||||
|
# Repository details
|
||||||
|
REPO_OWNER = "SideStore"
|
||||||
|
REPO_NAME = "SideStore"
|
||||||
|
|
||||||
|
|
||||||
|
API_URL = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/actions/caches"
|
||||||
|
|
||||||
|
# Common headers for GitHub API calls
|
||||||
|
HEADERS = {
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"Authorization": f"Bearer {GITHUB_TOKEN}"
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_caches():
|
||||||
|
response = requests.get(API_URL, headers=HEADERS)
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"Failed to list caches. HTTP {response.status_code}")
|
||||||
|
print("Response:", response.text)
|
||||||
|
sys.exit(1)
|
||||||
|
data = response.json()
|
||||||
|
return data.get("actions_caches", [])
|
||||||
|
|
||||||
|
def delete_cache(cache_id):
|
||||||
|
delete_url = f"{API_URL}/{cache_id}"
|
||||||
|
response = requests.delete(delete_url, headers=HEADERS)
|
||||||
|
return response.status_code
|
||||||
|
|
||||||
|
def main():
|
||||||
|
caches = list_caches()
|
||||||
|
if not caches:
|
||||||
|
print("No caches found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Found caches:")
|
||||||
|
for cache in caches:
|
||||||
|
print(f"ID: {cache.get('id')}, Key: {cache.get('key')}")
|
||||||
|
|
||||||
|
print("\nDeleting caches...")
|
||||||
|
for cache in caches:
|
||||||
|
cache_id = cache.get("id")
|
||||||
|
status = delete_cache(cache_id)
|
||||||
|
if status == 204:
|
||||||
|
print(f"Successfully deleted cache with ID: {cache_id}")
|
||||||
|
else:
|
||||||
|
print(f"Failed to delete cache with ID: {cache_id}. HTTP status code: {status}")
|
||||||
|
|
||||||
|
print("All caches processed.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
|
|
||||||
|
### How to use
|
||||||
|
'''
|
||||||
|
just export the GITHUB_TOKEN and then run this script via `python3 cache.py' to delete the caches
|
||||||
|
'''
|
||||||
12
.github/pull_request_template.md
vendored
Normal file
12
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
### Changes
|
||||||
|
|
||||||
|
<!-- Fill this list with what your PR changes. Example: -->
|
||||||
|
- Fix bug
|
||||||
|
- Change UI for QOL
|
||||||
|
|
||||||
|
<!-- If your PR is ready to be merged, you can remove this section. -->
|
||||||
|
### Todo before merge
|
||||||
|
|
||||||
|
<!-- Example: -->
|
||||||
|
- [x] Finish UI changes
|
||||||
|
- [ ] Test
|
||||||
28
.github/workflows/alpha.yml
vendored
Normal file
28
.github/workflows/alpha.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
name: Alpha SideStore build
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop-alpha
|
||||||
|
|
||||||
|
# cancel duplicate run if from same branch
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
Reusable-build:
|
||||||
|
uses: ./.github/workflows/reusable-sidestore-build.yml
|
||||||
|
with:
|
||||||
|
# bundle_id: "com.SideStore.SideStore.Alpha"
|
||||||
|
bundle_id: "com.SideStore.SideStore"
|
||||||
|
# bundle_id_suffix: ".Alpha"
|
||||||
|
is_beta: true
|
||||||
|
publish: ${{ vars.PUBLISH_ALPHA_UPDATES == 'true' }}
|
||||||
|
is_shared_build_num: false
|
||||||
|
release_tag: "alpha"
|
||||||
|
release_name: "Alpha"
|
||||||
|
upstream_tag: "nightly"
|
||||||
|
upstream_name: "Nightly"
|
||||||
|
secrets:
|
||||||
|
CROSS_REPO_PUSH_KEY: ${{ secrets.CROSS_REPO_PUSH_KEY }}
|
||||||
|
BUILD_LOG_ZIP_PASSWORD: ${{ secrets.BUILD_LOG_ZIP_PASSWORD }}
|
||||||
77
.github/workflows/attach_build_products.yml
vendored
Normal file
77
.github/workflows/attach_build_products.yml
vendored
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
name: Add artifact links to pull request and related issues
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [Pull Request SideStore build]
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
artifacts-url-comments:
|
||||||
|
name: add artifact links to pull request and related issues job
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
steps:
|
||||||
|
- name: add artifact links to pull request and related issues step
|
||||||
|
uses: tonyhallett/artifacts-url-comments@v1.1.0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
prefix: Builds for this Pull Request are available at
|
||||||
|
suffix: Have a nice day.
|
||||||
|
format: name
|
||||||
|
addTo: pull
|
||||||
|
# addTo: pullandissues
|
||||||
|
nightly-link-comment:
|
||||||
|
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/github-script@v6
|
||||||
|
with:
|
||||||
|
# This snippet is public-domain, taken from
|
||||||
|
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
|
||||||
|
script: |
|
||||||
|
async function upsertComment(owner, repo, issue_number, purpose, body) {
|
||||||
|
const {data: comments} = await github.rest.issues.listComments(
|
||||||
|
{owner, repo, issue_number});
|
||||||
|
|
||||||
|
const marker = `<!-- bot: ${purpose} -->`;
|
||||||
|
body = marker + "\n" + body;
|
||||||
|
|
||||||
|
const existing = comments.filter((c) => c.body.includes(marker));
|
||||||
|
if (existing.length > 0) {
|
||||||
|
const last = existing[existing.length - 1];
|
||||||
|
core.info(`Updating comment ${last.id}`);
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner, repo,
|
||||||
|
body,
|
||||||
|
comment_id: last.id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
core.info(`Creating a comment in issue / PR #${issue_number}`);
|
||||||
|
await github.rest.issues.createComment({issue_number, body, owner, repo});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {owner, repo} = context.repo;
|
||||||
|
const run_id = ${{github.event.workflow_run.id}};
|
||||||
|
|
||||||
|
const pull_requests = ${{ toJSON(github.event.workflow_run.pull_requests) }};
|
||||||
|
if (!pull_requests.length) {
|
||||||
|
return core.error("This workflow doesn't match any pull requests!");
|
||||||
|
}
|
||||||
|
|
||||||
|
const artifacts = await github.paginate(
|
||||||
|
github.rest.actions.listWorkflowRunArtifacts, {owner, repo, run_id});
|
||||||
|
if (!artifacts.length) {
|
||||||
|
return core.error(`No artifacts found`);
|
||||||
|
}
|
||||||
|
let body = `Download the artifacts for this pull request (nightly.link):\n`;
|
||||||
|
for (const art of artifacts) {
|
||||||
|
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
core.info("Review thread message body:", body);
|
||||||
|
|
||||||
|
for (const pr of pull_requests) {
|
||||||
|
await upsertComment(owner, repo, pr.number,
|
||||||
|
"nightly-link", body);
|
||||||
|
}
|
||||||
103
.github/workflows/beta.yml
vendored
Normal file
103
.github/workflows/beta.yml
vendored
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
name: Beta SideStore build
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '[0-9]+.[0-9]+.[0-9]+-beta.[0-9]+' # example: 1.0.0-beta.1
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build and upload SideStore Beta
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: 'macos-14'
|
||||||
|
version: '15.4'
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: brew install ldid
|
||||||
|
|
||||||
|
- name: Change version to tag
|
||||||
|
run: sed -e '/MARKETING_VERSION = .*/s/= .*/= ${{ github.ref_name }}/' -i '' Build.xcconfig
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: version
|
||||||
|
run: echo "version=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Echo version
|
||||||
|
run: echo "${{ steps.version.outputs.version }}"
|
||||||
|
|
||||||
|
- name: Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@v1
|
||||||
|
with:
|
||||||
|
xcode-version: ${{ matrix.version }}
|
||||||
|
|
||||||
|
|
||||||
|
- name: Cache Build
|
||||||
|
uses: irgaly/xcode-cache@v1
|
||||||
|
with:
|
||||||
|
key: xcode-cache-deriveddata-${{ github.sha }}
|
||||||
|
restore-keys: xcode-cache-deriveddata
|
||||||
|
|
||||||
|
- name: Build SideStore
|
||||||
|
run: make build | xcpretty && exit ${PIPESTATUS[0]}
|
||||||
|
|
||||||
|
- name: Fakesign app
|
||||||
|
run: make fakesign
|
||||||
|
|
||||||
|
- name: Convert to IPA
|
||||||
|
run: make ipa
|
||||||
|
|
||||||
|
- name: Get current date
|
||||||
|
id: date
|
||||||
|
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Get current date in AltStore date form
|
||||||
|
id: date_altstore
|
||||||
|
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Upload to new beta release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
name: ${{ steps.version.outputs.version }}
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
draft: true
|
||||||
|
prerelease: true
|
||||||
|
files: SideStore.ipa
|
||||||
|
body: |
|
||||||
|
<!-- NOTE: to reset SideSource cache, go to `https://apps.sidestore.io/reset-cache/nightly/<sidesource key>`. This is not included in the GitHub Action since it makes draft releases so they can be edited and have a changelog. -->
|
||||||
|
Beta builds are hand-picked builds from development commits that will allow you to try out new features earlier than normal. However, **they might contain bugs and other issues. Use at your own risk!**
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
- TODO
|
||||||
|
|
||||||
|
## Build Info
|
||||||
|
|
||||||
|
Built at (UTC): `${{ steps.date.outputs.date }}`
|
||||||
|
Built at (UTC date): `${{ steps.date_altstore.outputs.date }}`
|
||||||
|
Commit SHA: `${{ github.sha }}`
|
||||||
|
Version: `${{ steps.version.outputs.version }}`
|
||||||
|
|
||||||
|
- name: Add version to IPA file name
|
||||||
|
run: mv SideStore.ipa SideStore-${{ steps.version.outputs.version }}.ipa
|
||||||
|
|
||||||
|
- name: Upload SideStore.ipa Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: SideStore-${{ steps.version.outputs.version }}.ipa
|
||||||
|
path: SideStore-${{ steps.version.outputs.version }}.ipa
|
||||||
|
|
||||||
|
- name: Upload *.dSYM Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: SideStore-${{ steps.version.outputs.version }}-dSYM
|
||||||
|
path: ./*.dSYM/
|
||||||
34
.github/workflows/increase-beta-build-num.sh
vendored
Executable file
34
.github/workflows/increase-beta-build-num.sh
vendored
Executable file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Ensure we are in root directory
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
DATE=`date -u +'%Y.%m.%d'`
|
||||||
|
BUILD_NUM=1
|
||||||
|
|
||||||
|
# Use RELEASE_CHANNEL from the environment variable or default to "beta"
|
||||||
|
RELEASE_CHANNEL=${RELEASE_CHANNEL:-"beta"}
|
||||||
|
|
||||||
|
write() {
|
||||||
|
sed -e "/MARKETING_VERSION = .*/s/$/-$RELEASE_CHANNEL.$DATE.$BUILD_NUM+$(git rev-parse --short HEAD)/" -i '' Build.xcconfig
|
||||||
|
echo "$DATE,$BUILD_NUM" > build_number.txt
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ ! -f "build_number.txt" ]; then
|
||||||
|
write
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
LAST_DATE=`cat build_number.txt | perl -n -e '/([^,]*),([^ ]*)$/ && print $1'`
|
||||||
|
LAST_BUILD_NUM=`cat build_number.txt | perl -n -e '/([^,]*),([^ ]*)$/ && print $2'`
|
||||||
|
|
||||||
|
# if [[ "$DATE" != "$LAST_DATE" ]]; then
|
||||||
|
# write
|
||||||
|
# else
|
||||||
|
# BUILD_NUM=`expr $LAST_BUILD_NUM + 1`
|
||||||
|
# write
|
||||||
|
# fi
|
||||||
|
|
||||||
|
# Build number is always incremental
|
||||||
|
BUILD_NUM=`expr $LAST_BUILD_NUM + 1`
|
||||||
|
write
|
||||||
82
.github/workflows/nightly.yml
vendored
Normal file
82
.github/workflows/nightly.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
name: Nightly SideStore Build
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * *' # Runs every night at midnight UTC
|
||||||
|
workflow_dispatch: # Allows manual trigger
|
||||||
|
|
||||||
|
# cancel duplicate run if from same branch
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-changes:
|
||||||
|
if: github.event_name == 'schedule'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
has_changes: ${{ steps.check.outputs.has_changes }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Ensure full history
|
||||||
|
|
||||||
|
- name: Get last successful workflow run
|
||||||
|
id: get_last_success
|
||||||
|
run: |
|
||||||
|
LAST_SUCCESS=$(gh run list --workflow "Nightly SideStore Build" --json createdAt,conclusion \
|
||||||
|
--jq '[.[] | select(.conclusion=="success")][0].createdAt' || echo "")
|
||||||
|
echo "Last successful run: $LAST_SUCCESS"
|
||||||
|
echo "last_success=$LAST_SUCCESS" >> $GITHUB_ENV
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Check for new commits since last successful build
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
if [ -n "$LAST_SUCCESS" ]; then
|
||||||
|
NEW_COMMITS=$(git rev-list --count --since="$LAST_SUCCESS" origin/develop)
|
||||||
|
COMMIT_LOG=$(git log --since="$LAST_SUCCESS" --pretty=format:"%h %s" origin/develop)
|
||||||
|
else
|
||||||
|
NEW_COMMITS=1
|
||||||
|
COMMIT_LOG=$(git log -n 10 --pretty=format:"%h %s" origin/develop) # Show last 10 commits if no history
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Has changes: $NEW_COMMITS"
|
||||||
|
echo "New commits since last successful build:"
|
||||||
|
echo "$COMMIT_LOG"
|
||||||
|
|
||||||
|
if [ "$NEW_COMMITS" -gt 0 ]; then
|
||||||
|
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
LAST_SUCCESS: ${{ env.last_success }}
|
||||||
|
|
||||||
|
Reusable-build:
|
||||||
|
if: |
|
||||||
|
always() &&
|
||||||
|
(github.event_name == 'push' ||
|
||||||
|
(github.event_name == 'schedule' && needs.check-changes.result == 'success' && needs.check-changes.outputs.has_changes == 'true'))
|
||||||
|
needs: check-changes
|
||||||
|
uses: ./.github/workflows/reusable-sidestore-build.yml
|
||||||
|
with:
|
||||||
|
# bundle_id: "com.SideStore.SideStore.Nightly"
|
||||||
|
bundle_id: "com.SideStore.SideStore"
|
||||||
|
# bundle_id_suffix: ".Nightly"
|
||||||
|
is_beta: true
|
||||||
|
publish: ${{ vars.PUBLISH_NIGHTLY_UPDATES == 'true' }}
|
||||||
|
is_shared_build_num: false
|
||||||
|
release_tag: "nightly"
|
||||||
|
release_name: "Nightly"
|
||||||
|
upstream_tag: "0.5.10"
|
||||||
|
upstream_name: "Stable"
|
||||||
|
secrets:
|
||||||
|
CROSS_REPO_PUSH_KEY: ${{ secrets.CROSS_REPO_PUSH_KEY }}
|
||||||
|
BUILD_LOG_ZIP_PASSWORD: ${{ secrets.BUILD_LOG_ZIP_PASSWORD }}
|
||||||
|
|
||||||
143
.github/workflows/pr.yml
vendored
Normal file
143
.github/workflows/pr.yml
vendored
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
name: Pull Request SideStore build
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
# types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
|
||||||
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build and upload SideStore
|
||||||
|
if: ${{ github.event.pull_request.draft == false }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: 'macos-14'
|
||||||
|
version: '16.1'
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: brew install ldid
|
||||||
|
|
||||||
|
- name: Install xcbeautify
|
||||||
|
run: brew install xcbeautify
|
||||||
|
|
||||||
|
- name: Add PR suffix to version
|
||||||
|
run: sed -e "/MARKETING_VERSION = .*/s/\$/-pr.${{ github.event.pull_request.number }}+$(git rev-parse --short ${COMMIT:-HEAD})/" -i '' Build.xcconfig
|
||||||
|
env:
|
||||||
|
COMMIT: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: version
|
||||||
|
run: echo "version=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Echo version
|
||||||
|
run: echo "${{ steps.version.outputs.version }}"
|
||||||
|
|
||||||
|
- name: Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@v1.6.0
|
||||||
|
with:
|
||||||
|
xcode-version: ${{ matrix.version }}
|
||||||
|
|
||||||
|
- name: Cache Build
|
||||||
|
uses: irgaly/xcode-cache@v1
|
||||||
|
with:
|
||||||
|
key: xcode-cache-deriveddata-${{ github.sha }}
|
||||||
|
restore-keys: xcode-cache-deriveddata-
|
||||||
|
swiftpm-cache-key: xcode-cache-sourcedata-${{ github.sha }}
|
||||||
|
swiftpm-cache-restore-keys: |
|
||||||
|
xcode-cache-sourcedata-
|
||||||
|
|
||||||
|
|
||||||
|
- name: Restore Pods from Cache (Exact match)
|
||||||
|
id: pods-restore
|
||||||
|
uses: actions/cache/restore@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
./Podfile.lock
|
||||||
|
./Pods/
|
||||||
|
./AltStore.xcworkspace/
|
||||||
|
key: pods-cache-${{ hashFiles('Podfile') }}
|
||||||
|
# restore-keys: | # commented out to strictly check cache for this particular podfile
|
||||||
|
# pods-cache-
|
||||||
|
|
||||||
|
- name: Restore Pods from Cache (Last Available)
|
||||||
|
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
|
||||||
|
id: pods-restore-recent
|
||||||
|
uses: actions/cache/restore@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
./Podfile.lock
|
||||||
|
./Pods/
|
||||||
|
./AltStore.xcworkspace/
|
||||||
|
key: pods-cache-
|
||||||
|
|
||||||
|
- name: Install CocoaPods
|
||||||
|
# if: ${{ steps.pods-restore.outputs.cache-hit != 'true'}}
|
||||||
|
id: pods-install
|
||||||
|
run: |
|
||||||
|
pod install
|
||||||
|
|
||||||
|
- name: Save Pods to Cache
|
||||||
|
id: save-pods
|
||||||
|
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
|
||||||
|
uses: actions/cache/save@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
./Podfile.lock
|
||||||
|
./Pods/
|
||||||
|
./AltStore.xcworkspace/
|
||||||
|
key: pods-cache-${{ hashFiles('Podfile') }}
|
||||||
|
|
||||||
|
- name: List Files and derived data
|
||||||
|
run: |
|
||||||
|
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
||||||
|
ls -la .
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Pods <<<<<<<<<<"
|
||||||
|
find Pods -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> SideStore <<<<<<<<<<"
|
||||||
|
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
|
||||||
|
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
|
||||||
|
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
|
||||||
|
- name: Build SideStore
|
||||||
|
run: NSUnbufferedIO=YES make build 2>&1 | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
|
||||||
|
|
||||||
|
- name: Fakesign app
|
||||||
|
run: make fakesign
|
||||||
|
|
||||||
|
- name: Convert to IPA
|
||||||
|
run: make ipa
|
||||||
|
|
||||||
|
- name: Add version to IPA file name
|
||||||
|
run: mv SideStore.ipa SideStore-${{ steps.version.outputs.version }}.ipa
|
||||||
|
|
||||||
|
- name: Upload SideStore.ipa Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: SideStore-${{ steps.version.outputs.version }}.ipa
|
||||||
|
path: SideStore-${{ steps.version.outputs.version }}.ipa
|
||||||
|
|
||||||
|
- name: Upload *.dSYM Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: SideStore-${{ steps.version.outputs.version }}-dSYM
|
||||||
|
path: ./SideStore.xcarchive/dSYMs/*
|
||||||
104
.github/workflows/reusable-sidestore-build.yml
vendored
Normal file
104
.github/workflows/reusable-sidestore-build.yml
vendored
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
name: Reusable SideStore Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
is_beta:
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
publish:
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
is_shared_build_num:
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
type: boolean
|
||||||
|
release_name:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
release_tag:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
upstream_tag:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
upstream_name:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
bundle_id:
|
||||||
|
default: com.SideStore.SideStore
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
bundle_id_suffix:
|
||||||
|
default: ''
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
# GITHUB_TOKEN:
|
||||||
|
# required: true
|
||||||
|
CROSS_REPO_PUSH_KEY:
|
||||||
|
required: true
|
||||||
|
BUILD_LOG_ZIP_PASSWORD:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
|
||||||
|
# since build cache, test-build cache, test-run cache are involved, out of order exec if serialization is on individual jobs will wreak all sorts of havoc
|
||||||
|
# so we serialize on the entire workflow
|
||||||
|
concurrency:
|
||||||
|
group: serialize-workflow
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
shared:
|
||||||
|
uses: ./.github/workflows/sidestore-shared.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: shared
|
||||||
|
uses: ./.github/workflows/sidestore-build.yml
|
||||||
|
with:
|
||||||
|
is_beta: ${{ inputs.is_beta }}
|
||||||
|
is_shared_build_num: ${{ inputs.is_shared_build_num }}
|
||||||
|
release_tag: ${{ inputs.release_tag }}
|
||||||
|
short_commit: ${{ needs.shared.outputs.short-commit }}
|
||||||
|
bundle_id: ${{ inputs.bundle_id }}
|
||||||
|
bundle_id_suffix: ${{ inputs.bundle_id_suffix }}
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
tests-build:
|
||||||
|
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_BUILD == '1' }}
|
||||||
|
needs: shared
|
||||||
|
uses: ./.github/workflows/sidestore-tests-build.yml
|
||||||
|
with:
|
||||||
|
release_tag: ${{ inputs.release_tag }}
|
||||||
|
short_commit: ${{ needs.shared.outputs.short-commit }}
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
tests-run:
|
||||||
|
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }}
|
||||||
|
needs: [shared, tests-build]
|
||||||
|
uses: ./.github/workflows/sidestore-tests-run.yml
|
||||||
|
with:
|
||||||
|
release_tag: ${{ inputs.release_tag }}
|
||||||
|
short_commit: ${{ needs.shared.outputs.short-commit }}
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: [shared, build, tests-build, tests-run] # Keep tests-run in needs
|
||||||
|
if: ${{ always() && (needs.tests-run.result == 'skipped' || needs.tests-run.result == 'success') }}
|
||||||
|
uses: ./.github/workflows/sidestore-deploy.yml
|
||||||
|
with:
|
||||||
|
is_beta: ${{ inputs.is_beta }}
|
||||||
|
publish: ${{ inputs.publish }}
|
||||||
|
release_name: ${{ inputs.release_name }}
|
||||||
|
release_tag: ${{ inputs.release_tag }}
|
||||||
|
upstream_tag: ${{ inputs.upstream_tag }}
|
||||||
|
upstream_name: ${{ inputs.upstream_name }}
|
||||||
|
version: ${{ needs.build.outputs.version }}
|
||||||
|
short_commit: ${{ needs.shared.outputs.short-commit }}
|
||||||
|
release_channel: ${{ needs.build.outputs.release-channel }}
|
||||||
|
marketing_version: ${{ needs.build.outputs.marketing-version }}
|
||||||
|
bundle_id: ${{ inputs.bundle_id }}
|
||||||
|
secrets: inherit
|
||||||
401
.github/workflows/sidestore-build.yml
vendored
Normal file
401
.github/workflows/sidestore-build.yml
vendored
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
name: SideStore Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
is_beta:
|
||||||
|
type: boolean
|
||||||
|
is_shared_build_num:
|
||||||
|
type: boolean
|
||||||
|
release_tag:
|
||||||
|
type: string
|
||||||
|
bundle_id:
|
||||||
|
type: string
|
||||||
|
bundle_id_suffix:
|
||||||
|
type: string
|
||||||
|
short_commit:
|
||||||
|
type: string
|
||||||
|
secrets:
|
||||||
|
CROSS_REPO_PUSH_KEY:
|
||||||
|
required: true
|
||||||
|
BUILD_LOG_ZIP_PASSWORD:
|
||||||
|
required: false
|
||||||
|
outputs:
|
||||||
|
version:
|
||||||
|
value: ${{ jobs.build.outputs.version }}
|
||||||
|
marketing-version:
|
||||||
|
value: ${{ jobs.build.outputs.marketing-version }}
|
||||||
|
release-channel:
|
||||||
|
value: ${{ jobs.build.outputs.release-channel }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build SideStore - ${{ inputs.release_tag }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: 'macos-15'
|
||||||
|
version: '16.2'
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
marketing-version: ${{ steps.marketing-version.outputs.MARKETING_VERSION }}
|
||||||
|
release-channel: ${{ steps.release-channel.outputs.RELEASE_CHANNEL }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Set beta status
|
||||||
|
run: echo "IS_BETA=${{ inputs.is_beta }}" >> $GITHUB_ENV
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install dependencies - ldid & xcbeautify
|
||||||
|
run: |
|
||||||
|
brew install ldid xcbeautify
|
||||||
|
|
||||||
|
- name: Set ref based on is_shared_build_num
|
||||||
|
if: ${{ inputs.is_beta }}
|
||||||
|
id: set_ref
|
||||||
|
run: |
|
||||||
|
if [ "${{ inputs.is_shared_build_num }}" == "true" ]; then
|
||||||
|
echo "ref=main" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "ref=${{ inputs.release_tag }}" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Checkout SideStore/beta-build-num repo
|
||||||
|
if: ${{ inputs.is_beta }}
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: 'SideStore/beta-build-num'
|
||||||
|
ref: ${{ env.ref }}
|
||||||
|
token: ${{ secrets.CROSS_REPO_PUSH_KEY }}
|
||||||
|
path: 'SideStore/beta-build-num'
|
||||||
|
|
||||||
|
- name: Copy build_number.txt to repo root
|
||||||
|
if: ${{ inputs.is_beta }}
|
||||||
|
run: |
|
||||||
|
cp SideStore/beta-build-num/build_number.txt .
|
||||||
|
echo "cat build_number.txt"
|
||||||
|
cat build_number.txt
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Echo Build.xcconfig
|
||||||
|
run: |
|
||||||
|
echo "cat Build.xcconfig"
|
||||||
|
cat Build.xcconfig
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Set Release Channel info for build number bumper
|
||||||
|
id: release-channel
|
||||||
|
run: |
|
||||||
|
RELEASE_CHANNEL="${{ inputs.release_tag }}"
|
||||||
|
echo "RELEASE_CHANNEL=${RELEASE_CHANNEL}" >> $GITHUB_ENV
|
||||||
|
echo "RELEASE_CHANNEL=${RELEASE_CHANNEL}" >> $GITHUB_OUTPUT
|
||||||
|
echo "RELEASE_CHANNEL=${RELEASE_CHANNEL}"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Increase build number for beta builds
|
||||||
|
if: ${{ inputs.is_beta }}
|
||||||
|
run: |
|
||||||
|
bash .github/workflows/increase-beta-build-num.sh
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Extract MARKETING_VERSION from Build.xcconfig
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
version=$(grep MARKETING_VERSION Build.xcconfig | sed -e 's/MARKETING_VERSION = //g')
|
||||||
|
echo "version=$version" >> $GITHUB_OUTPUT
|
||||||
|
echo "version=$version"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Set MARKETING_VERSION
|
||||||
|
if: ${{ inputs.is_beta }}
|
||||||
|
id: marketing-version
|
||||||
|
run: |
|
||||||
|
# Extract version number (e.g., "0.6.0")
|
||||||
|
version=$(echo "${{ steps.version.outputs.version }}" | sed -E 's/^[^0-9]*([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
|
||||||
|
# Extract date (YYYYMMDD) (e.g., "20250205")
|
||||||
|
date=$(echo "${{ steps.version.outputs.version }}" | sed -E 's/.*\.([0-9]{4})\.([0-9]{2})\.([0-9]{2})\..*/\1\2\3/')
|
||||||
|
# Extract build number (e.g., "2")
|
||||||
|
build_num=$(echo "${{ steps.version.outputs.version }}" | sed -E 's/.*\.([0-9]+)\+.*/\1/')
|
||||||
|
|
||||||
|
# Combine them into the final output
|
||||||
|
MARKETING_VERSION="${version}-${date}.${build_num}+${{ inputs.short_commit }}"
|
||||||
|
|
||||||
|
echo "MARKETING_VERSION=$MARKETING_VERSION" >> $GITHUB_ENV
|
||||||
|
echo "MARKETING_VERSION=$MARKETING_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "MARKETING_VERSION=$MARKETING_VERSION"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Echo Updated Build.xcconfig, build_number.txt
|
||||||
|
if: ${{ inputs.is_beta }}
|
||||||
|
run: |
|
||||||
|
cat Build.xcconfig
|
||||||
|
cat build_number.txt
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@v1.6.0
|
||||||
|
with:
|
||||||
|
xcode-version: ${{ matrix.version }}
|
||||||
|
|
||||||
|
- name: (Build) Restore Xcode & SwiftPM Cache (Exact match)
|
||||||
|
id: xcode-cache-restore
|
||||||
|
uses: actions/cache/restore@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/Library/Developer/Xcode/DerivedData
|
||||||
|
~/Library/Caches/org.swift.swiftpm
|
||||||
|
key: xcode-cache-build-${{ github.ref_name }}-${{ github.sha }}
|
||||||
|
|
||||||
|
- name: (Build) Restore Xcode & SwiftPM Cache (Last Available)
|
||||||
|
id: xcode-cache-restore-recent
|
||||||
|
uses: actions/cache/restore@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/Library/Developer/Xcode/DerivedData
|
||||||
|
~/Library/Caches/org.swift.swiftpm
|
||||||
|
key: xcode-cache-build-${{ github.ref_name }}-
|
||||||
|
|
||||||
|
# - name: (Build) Cache Build
|
||||||
|
# uses: irgaly/xcode-cache@v1.8.1
|
||||||
|
# with:
|
||||||
|
# key: xcode-cache-deriveddata-build-${{ github.ref_name }}-${{ github.sha }}
|
||||||
|
# restore-keys: xcode-cache-deriveddata-build-${{ github.ref_name }}-
|
||||||
|
# swiftpm-cache-key: xcode-cache-sourcedata-build-${{ github.ref_name }}-${{ github.sha }}
|
||||||
|
# swiftpm-cache-restore-keys: |
|
||||||
|
# xcode-cache-sourcedata-build-${{ github.ref_name }}-
|
||||||
|
|
||||||
|
- name: (Build) Restore Pods from Cache (Exact match)
|
||||||
|
id: pods-restore
|
||||||
|
uses: actions/cache/restore@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
./Podfile.lock
|
||||||
|
./Pods/
|
||||||
|
./AltStore.xcworkspace/
|
||||||
|
key: pods-cache-build-${{ github.ref_name }}-${{ hashFiles('Podfile') }}
|
||||||
|
# restore-keys: | # commented out to strictly check cache for this particular podfile
|
||||||
|
# pods-cache-
|
||||||
|
|
||||||
|
- name: (Build) Restore Pods from Cache (Last Available)
|
||||||
|
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
|
||||||
|
id: pods-restore-recent
|
||||||
|
uses: actions/cache/restore@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
./Podfile.lock
|
||||||
|
./Pods/
|
||||||
|
./AltStore.xcworkspace/
|
||||||
|
key: pods-cache-build-${{ github.ref_name }}-
|
||||||
|
|
||||||
|
|
||||||
|
- name: (Build) Install CocoaPods
|
||||||
|
run: pod install
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: (Build) Save Pods to Cache
|
||||||
|
id: save-pods
|
||||||
|
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
|
||||||
|
uses: actions/cache/save@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
./Podfile.lock
|
||||||
|
./Pods/
|
||||||
|
./AltStore.xcworkspace/
|
||||||
|
key: pods-cache-build-${{ github.ref_name }}-${{ hashFiles('Podfile') }}
|
||||||
|
|
||||||
|
- name: (Build) Clean previous build artifacts
|
||||||
|
# using 'tee' to intercept stdout and log for detailed build-log
|
||||||
|
run: |
|
||||||
|
make clean
|
||||||
|
mkdir -p build/logs
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: (Build) List Files and derived data
|
||||||
|
if: always()
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
||||||
|
ls -la .
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Pods <<<<<<<<<<"
|
||||||
|
find Pods -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> SideStore <<<<<<<<<<"
|
||||||
|
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
|
||||||
|
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
|
||||||
|
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
- name: Set BundleID Suffix for Sidestore build
|
||||||
|
run: |
|
||||||
|
echo "BUNDLE_ID_SUFFIX=${{ inputs.bundle_id_suffix }}" >> $GITHUB_ENV
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
|
||||||
|
- name: Build SideStore.xcarchive
|
||||||
|
# using 'tee' to intercept stdout and log for detailed build-log
|
||||||
|
run: |
|
||||||
|
NSUnbufferedIO=YES make -B build 2>&1 | tee -a build/logs/build.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Fakesign app
|
||||||
|
run: make fakesign | tee -a build/logs/build.log
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Convert to IPA
|
||||||
|
run: make ipa | tee -a build/logs/build.log
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: (Build) Save Xcode & SwiftPM Cache
|
||||||
|
id: cache-save
|
||||||
|
if: ${{ steps.xcode-cache-restore.outputs.cache-hit != 'true' }}
|
||||||
|
uses: actions/cache/save@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/Library/Developer/Xcode/DerivedData
|
||||||
|
~/Library/Caches/org.swift.swiftpm
|
||||||
|
key: xcode-cache-build-${{ github.ref_name }}-${{ github.sha }}
|
||||||
|
|
||||||
|
- name: (Build) List Files and Build artifacts
|
||||||
|
run: |
|
||||||
|
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
||||||
|
ls -la .
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Build <<<<<<<<<<"
|
||||||
|
find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> SideStore <<<<<<<<<<"
|
||||||
|
find SideStore -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> SideStore.xcarchive <<<<<<<<<<"
|
||||||
|
find SideStore.xcarchive -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
|
||||||
|
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Encrypt build-logs for upload
|
||||||
|
id: encrypt-build-log
|
||||||
|
run: |
|
||||||
|
DEFAULT_BUILD_LOG_PASSWORD=12345
|
||||||
|
|
||||||
|
BUILD_LOG_ZIP_PASSWORD=${{ secrets.BUILD_LOG_ZIP_PASSWORD }}
|
||||||
|
BUILD_LOG_ZIP_PASSWORD=${BUILD_LOG_ZIP_PASSWORD:-$DEFAULT_BUILD_LOG_PASSWORD}
|
||||||
|
|
||||||
|
if [ "$BUILD_LOG_ZIP_PASSWORD" == "$DEFAULT_BUILD_LOG_PASSWORD" ]; then
|
||||||
|
echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'."
|
||||||
|
fi
|
||||||
|
|
||||||
|
pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-build-logs.zip * || popd
|
||||||
|
echo "::set-output name=encrypted::true"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Upload encrypted-build-logs.zip
|
||||||
|
id: attach-encrypted-build-log
|
||||||
|
if: ${{ always() && steps.encrypt-build-log.outputs.encrypted == 'true' }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: encrypted-build-logs-${{ steps.version.outputs.version }}.zip
|
||||||
|
path: encrypted-build-logs.zip
|
||||||
|
|
||||||
|
- name: Upload SideStore.ipa Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: SideStore-${{ steps.version.outputs.version }}.ipa
|
||||||
|
path: SideStore.ipa
|
||||||
|
|
||||||
|
- name: Zip dSYMs
|
||||||
|
run: zip -r -9 ./SideStore.dSYMs.zip ./SideStore.xcarchive/dSYMs
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Upload *.dSYM Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: SideStore-${{ steps.version.outputs.version }}-dSYMs.zip
|
||||||
|
path: SideStore.dSYMs.zip
|
||||||
|
|
||||||
|
- name: Keep rolling the build numbers for each successful build
|
||||||
|
if: ${{ inputs.is_beta }}
|
||||||
|
run: |
|
||||||
|
pushd SideStore/beta-build-num/
|
||||||
|
|
||||||
|
echo "Configure Git user (committer details)"
|
||||||
|
git config user.name "GitHub Actions"
|
||||||
|
git config user.email "github-actions@github.com"
|
||||||
|
|
||||||
|
echo "Adding files to commit"
|
||||||
|
git add --verbose build_number.txt
|
||||||
|
git commit -m " - updated for ${{ inputs.release_tag }} - ${{ inputs.short_commit }} deployment" || echo "No changes to commit"
|
||||||
|
|
||||||
|
echo "Pushing to remote repo"
|
||||||
|
git push --verbose
|
||||||
|
popd
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Get last successful commit
|
||||||
|
id: get_last_commit
|
||||||
|
run: |
|
||||||
|
# Try to get the last successful workflow run commit
|
||||||
|
LAST_SUCCESS_SHA=$(gh run list --branch "${{ github.ref_name }}" --status success --json headSha --jq '.[0].headSha')
|
||||||
|
echo "LAST_SUCCESS_SHA=$LAST_SUCCESS_SHA" >> $GITHUB_OUTPUT
|
||||||
|
echo "LAST_SUCCESS_SHA=$LAST_SUCCESS_SHA" >> $GITHUB_ENV
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Create release notes
|
||||||
|
run: |
|
||||||
|
LAST_SUCCESS_SHA=${{ steps.get_last_commit.outputs.LAST_SUCCESS_SHA}}
|
||||||
|
echo "Last successful commit SHA: $LAST_SUCCESS_SHA"
|
||||||
|
|
||||||
|
FROM_COMMIT=$LAST_SUCCESS_SHA
|
||||||
|
# Check if we got a valid SHA
|
||||||
|
if [ -z "$LAST_SUCCESS_SHA" ] || [ "$LAST_SUCCESS_SHA" = "null" ]; then
|
||||||
|
echo "No successful run found, using initial commit of branch"
|
||||||
|
# Get the first commit of the branch (initial commit)
|
||||||
|
FROM_COMMIT=$(git rev-list --max-parents=0 HEAD)
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 update_release_notes.py $FROM_COMMIT ${{ inputs.release_tag }} ${{ github.ref_name }}
|
||||||
|
# cat release-notes.md
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Upload release-notes.md
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release-notes-${{ inputs.short_commit }}.md
|
||||||
|
path: release-notes.md
|
||||||
|
|
||||||
|
- name: Upload update_release_notes.py
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: update_release_notes-${{ inputs.short_commit }}.py
|
||||||
|
path: update_release_notes.py
|
||||||
|
|
||||||
|
- name: Upload update_apps.py
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: update_apps-${{ inputs.short_commit }}.py
|
||||||
|
path: update_apps.py
|
||||||
235
.github/workflows/sidestore-deploy.yml
vendored
Normal file
235
.github/workflows/sidestore-deploy.yml
vendored
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
name: SideStore Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
is_beta:
|
||||||
|
type: boolean
|
||||||
|
publish:
|
||||||
|
type: boolean
|
||||||
|
release_name:
|
||||||
|
type: string
|
||||||
|
release_tag:
|
||||||
|
type: string
|
||||||
|
upstream_tag:
|
||||||
|
type: string
|
||||||
|
upstream_name:
|
||||||
|
type: string
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
short_commit:
|
||||||
|
type: string
|
||||||
|
marketing_version:
|
||||||
|
type: string
|
||||||
|
release_channel:
|
||||||
|
type: string
|
||||||
|
bundle_id:
|
||||||
|
type: string
|
||||||
|
secrets:
|
||||||
|
CROSS_REPO_PUSH_KEY:
|
||||||
|
required: true
|
||||||
|
# GITHUB_TOKEN:
|
||||||
|
# required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy SideStore - ${{ inputs.release_tag }}
|
||||||
|
runs-on: macos-15
|
||||||
|
steps:
|
||||||
|
- name: Download IPA artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: SideStore-${{ inputs.version }}.ipa
|
||||||
|
|
||||||
|
- name: Download dSYM artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: SideStore-${{ inputs.version }}-dSYMs.zip
|
||||||
|
|
||||||
|
- name: Download encrypted-build-logs artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: encrypted-build-logs-${{ inputs.version }}.zip
|
||||||
|
|
||||||
|
- name: Download encrypted-tests-build-logs artifact
|
||||||
|
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_BUILD == '1' }}
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: encrypted-tests-build-logs-${{ inputs.short_commit }}.zip
|
||||||
|
|
||||||
|
- name: Download encrypted-tests-run-logs artifact
|
||||||
|
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }}
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: encrypted-tests-run-logs-${{ inputs.short_commit }}.zip
|
||||||
|
|
||||||
|
- name: Download tests-recording artifact
|
||||||
|
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }}
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: tests-recording-${{ inputs.short_commit }}.mp4
|
||||||
|
|
||||||
|
- name: Download test-results artifact
|
||||||
|
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }}
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: test-results-${{ inputs.short_commit }}.zip
|
||||||
|
|
||||||
|
- name: Download release-notes.md
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release-notes-${{ inputs.short_commit }}.md
|
||||||
|
|
||||||
|
- name: Download update_release_notes.py
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: update_release_notes-${{ inputs.short_commit }}.py
|
||||||
|
|
||||||
|
- name: Download update_apps.py
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: update_apps-${{ inputs.short_commit }}.py
|
||||||
|
|
||||||
|
- name: Read release notes
|
||||||
|
id: release_notes
|
||||||
|
run: |
|
||||||
|
CONTENT=$(python3 update_release_notes.py --retrieve ${{ inputs.release_tag }})
|
||||||
|
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: List files before upload
|
||||||
|
run: |
|
||||||
|
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
||||||
|
find . -maxdepth 4 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Get current date
|
||||||
|
id: date
|
||||||
|
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Get current date in AltStore date form
|
||||||
|
id: date_altstore
|
||||||
|
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Upload to releases
|
||||||
|
uses: IsaacShelton/update-existing-release@v1.3.1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
release: ${{ inputs.release_name }}
|
||||||
|
tag: ${{ inputs.release_tag }}
|
||||||
|
prerelease: ${{ inputs.is_beta }}
|
||||||
|
files: SideStore.ipa SideStore.dSYMs.zip encrypted-build-logs.zip encrypted-tests-build-logs.zip encrypted-tests-run-logs.zip test-results.zip tests-recording.mp4
|
||||||
|
body: |
|
||||||
|
This is an ⚠️ **EXPERIMENTAL** ⚠️ ${{ inputs.release_name }} build for commit [${{ github.sha }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }}).
|
||||||
|
|
||||||
|
${{ inputs.release_name }} builds are **extremely experimental builds only meant to be used by developers and beta testers. They often contain bugs and experimental features. Use at your own risk!**
|
||||||
|
|
||||||
|
If you want to try out new features early but want a lower chance of bugs, you can look at [SideStore ${{ inputs.upstream_name }}](https://github.com/${{ github.repository }}/releases?q=${{ inputs.upstream_tag }}).
|
||||||
|
|
||||||
|
## Build Info
|
||||||
|
|
||||||
|
Built at (UTC): `${{ steps.date.outputs.date }}`
|
||||||
|
Built at (UTC date): `${{ steps.date_altstore.outputs.date }}`
|
||||||
|
Commit SHA: `${{ github.sha }}`
|
||||||
|
Version: `${{ inputs.version }}`
|
||||||
|
|
||||||
|
${{ steps.release_notes.outputs.content }}
|
||||||
|
|
||||||
|
- name: Get formatted date
|
||||||
|
run: |
|
||||||
|
FORMATTED_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
echo "Formatted date: $FORMATTED_DATE"
|
||||||
|
echo "FORMATTED_DATE=$FORMATTED_DATE" >> $GITHUB_ENV
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Get size of IPA in bytes (macOS/Linux)
|
||||||
|
run: |
|
||||||
|
if [[ "$(uname)" == "Darwin" ]]; then
|
||||||
|
# macOS
|
||||||
|
IPA_SIZE=$(stat -f %z SideStore.ipa)
|
||||||
|
else
|
||||||
|
# Linux
|
||||||
|
IPA_SIZE=$(stat -c %s SideStore.ipa)
|
||||||
|
fi
|
||||||
|
echo "IPA size in bytes: $IPA_SIZE"
|
||||||
|
echo "IPA_SIZE=$IPA_SIZE" >> $GITHUB_ENV
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Compute SHA-256 of IPA
|
||||||
|
run: |
|
||||||
|
SHA256_HASH=$(shasum -a 256 SideStore.ipa | awk '{ print $1 }')
|
||||||
|
echo "SHA-256 Hash: $SHA256_HASH"
|
||||||
|
echo "SHA256_HASH=$SHA256_HASH" >> $GITHUB_ENV
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Set Release Info variables
|
||||||
|
run: |
|
||||||
|
# Format localized description
|
||||||
|
LOCALIZED_DESCRIPTION=$(cat <<EOF
|
||||||
|
This is release for:
|
||||||
|
- version: "${{ inputs.version }}"
|
||||||
|
- revision: "${{ inputs.short_commit }}"
|
||||||
|
- timestamp: "${{ steps.date.outputs.date }}"
|
||||||
|
|
||||||
|
Release Notes:
|
||||||
|
${{ steps.release_notes.outputs.content }}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "IS_BETA=${{ inputs.is_beta }}" >> $GITHUB_ENV
|
||||||
|
echo "BUNDLE_IDENTIFIER=${{ inputs.bundle_id }}" >> $GITHUB_ENV
|
||||||
|
echo "VERSION_IPA=${{ inputs.marketing_version }}" >> $GITHUB_ENV
|
||||||
|
echo "VERSION_DATE=$FORMATTED_DATE" >> $GITHUB_ENV
|
||||||
|
echo "RELEASE_CHANNEL=${{ inputs.release_channel }}" >> $GITHUB_ENV
|
||||||
|
echo "SIZE=$IPA_SIZE" >> $GITHUB_ENV
|
||||||
|
echo "SHA256=$SHA256_HASH" >> $GITHUB_ENV
|
||||||
|
echo "DOWNLOAD_URL=https://github.com/SideStore/SideStore/releases/download/${{ inputs.release_tag }}/SideStore.ipa" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
# multiline strings
|
||||||
|
echo "LOCALIZED_DESCRIPTION<<EOF" >> $GITHUB_ENV
|
||||||
|
echo "$LOCALIZED_DESCRIPTION" >> $GITHUB_ENV
|
||||||
|
echo "EOF" >> $GITHUB_ENV
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Check if Publish updates is set
|
||||||
|
id: check_publish
|
||||||
|
run: |
|
||||||
|
echo "Publish updates to source.json = ${{ inputs.publish }}"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Checkout SideStore/apps-v2.json
|
||||||
|
if: ${{ inputs.is_beta && inputs.publish }}
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: 'SideStore/apps-v2.json'
|
||||||
|
ref: 'main' # this branch is shared by all beta builds, so beta build workflows are serialized
|
||||||
|
token: ${{ secrets.CROSS_REPO_PUSH_KEY }}
|
||||||
|
path: 'SideStore/apps-v2.json'
|
||||||
|
|
||||||
|
# for stable builds, let the user manually edit the source.json
|
||||||
|
- name: Publish to SideStore/apps-v2.json
|
||||||
|
if: ${{ inputs.is_beta && inputs.publish }}
|
||||||
|
id: publish-release
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Copy and execute the update script
|
||||||
|
pushd SideStore/apps-v2.json/
|
||||||
|
|
||||||
|
# Configure Git user (committer details)
|
||||||
|
git config user.name "GitHub Actions"
|
||||||
|
git config user.email "github-actions@github.com"
|
||||||
|
|
||||||
|
# update the source.json
|
||||||
|
python3 ../../update_apps.py "./_includes/source.json"
|
||||||
|
|
||||||
|
# Commit changes and push using SSH
|
||||||
|
git add --verbose ./_includes/source.json
|
||||||
|
git commit -m " - updated for ${{ inputs.short_commit }} deployment" || echo "No changes to commit"
|
||||||
|
|
||||||
|
git push --verbose
|
||||||
|
popd
|
||||||
24
.github/workflows/sidestore-shared.yml
vendored
Normal file
24
.github/workflows/sidestore-shared.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: SideStore Shared
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
outputs:
|
||||||
|
short-commit:
|
||||||
|
value: ${{ jobs.shared.outputs.short-commit }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
shared:
|
||||||
|
name: Shared Steps
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
runs-on: 'macos-15'
|
||||||
|
steps:
|
||||||
|
- name: Set short commit hash
|
||||||
|
id: commit-id
|
||||||
|
run: |
|
||||||
|
# SHORT_COMMIT="${{ github.sha }}"
|
||||||
|
SHORT_COMMIT=${GITHUB_SHA:0:7}
|
||||||
|
echo "Short commit hash: $SHORT_COMMIT"
|
||||||
|
echo "SHORT_COMMIT=$SHORT_COMMIT" >> $GITHUB_OUTPUT
|
||||||
|
outputs:
|
||||||
|
short-commit: ${{ steps.commit-id.outputs.SHORT_COMMIT }}
|
||||||
204
.github/workflows/sidestore-tests-build.yml
vendored
Normal file
204
.github/workflows/sidestore-tests-build.yml
vendored
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
name: SideStore Tests Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
type: string
|
||||||
|
short_commit:
|
||||||
|
type: string
|
||||||
|
secrets:
|
||||||
|
BUILD_LOG_ZIP_PASSWORD:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tests-build:
|
||||||
|
name: Tests-Build SideStore - ${{ inputs.release_tag }}
|
||||||
|
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_BUILD == '1' }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: 'macos-15'
|
||||||
|
version: '16.2'
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Install dependencies - xcbeautify
|
||||||
|
run: |
|
||||||
|
brew install xcbeautify
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@v1.6.0
|
||||||
|
with:
|
||||||
|
xcode-version: '16.2'
|
||||||
|
|
||||||
|
# - name: (Tests-Build) Cache Build
|
||||||
|
# uses: irgaly/xcode-cache@v1.8.1
|
||||||
|
# with:
|
||||||
|
# key: xcode-cache-deriveddata-test-${{ github.ref_name }}-${{ github.sha }}
|
||||||
|
# # tests shouldn't restore cache unless it is same build
|
||||||
|
# # restore-keys: xcode-cache-deriveddata-test-${{ github.ref_name }}-
|
||||||
|
# swiftpm-cache-key: xcode-cache-sourcedata-test-${{ github.ref_name }}-${{ github.sha }}
|
||||||
|
# swiftpm-cache-restore-keys: |
|
||||||
|
# xcode-cache-sourcedata-test-${{ github.ref_name }}-
|
||||||
|
# delete-used-deriveddata-cache: true
|
||||||
|
|
||||||
|
- name: (Tests-Build) Restore Xcode & SwiftPM Cache (Exact match)
|
||||||
|
id: xcode-cache-restore
|
||||||
|
uses: actions/cache/restore@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/Library/Developer/Xcode/DerivedData
|
||||||
|
~/Library/Caches/org.swift.swiftpm
|
||||||
|
key: xcode-cache-tests-${{ github.ref_name }}-${{ github.sha }}
|
||||||
|
|
||||||
|
- name: (Tests-Build) Restore Xcode & SwiftPM Cache (Last Available)
|
||||||
|
id: xcode-cache-restore-recent
|
||||||
|
uses: actions/cache/restore@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/Library/Developer/Xcode/DerivedData
|
||||||
|
~/Library/Caches/org.swift.swiftpm
|
||||||
|
key: xcode-cache-tests-${{ github.ref_name }}-
|
||||||
|
|
||||||
|
- name: (Tests-Build) Restore Pods from Cache (Exact match)
|
||||||
|
id: pods-restore
|
||||||
|
uses: actions/cache/restore@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
./Podfile.lock
|
||||||
|
./Pods/
|
||||||
|
./AltStore.xcworkspace/
|
||||||
|
key: pods-cache-test-${{ github.ref_name }}-${{ hashFiles('Podfile') }}
|
||||||
|
|
||||||
|
- name: (Tests-Build) Restore Pods from Cache (Last Available)
|
||||||
|
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
|
||||||
|
id: pods-restore-recent
|
||||||
|
uses: actions/cache/restore@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
./Podfile.lock
|
||||||
|
./Pods/
|
||||||
|
./AltStore.xcworkspace/
|
||||||
|
key: pods-cache-test-${{ github.ref_name }}-
|
||||||
|
|
||||||
|
- name: (Tests-Build) Install CocoaPods
|
||||||
|
run: pod install
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: (Tests-Build) Save Pods to Cache
|
||||||
|
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
|
||||||
|
uses: actions/cache/save@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
./Podfile.lock
|
||||||
|
./Pods/
|
||||||
|
./AltStore.xcworkspace/
|
||||||
|
key: pods-cache-test-${{ github.ref_name }}-${{ hashFiles('Podfile') }}
|
||||||
|
|
||||||
|
- name: Clean Derived Data (if required)
|
||||||
|
if: ${{ vars.PERFORM_CLEAN_TESTS_BUILD == '1' }}
|
||||||
|
run: |
|
||||||
|
rm -rf ~/Library/Developer/Xcode/DerivedData/
|
||||||
|
make clean
|
||||||
|
xcodebuild clean
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: (Tests-Build) Clean previous build artifacts
|
||||||
|
run: |
|
||||||
|
make clean
|
||||||
|
mkdir -p build/logs
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: (Tests-Build) List Files and derived data
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
||||||
|
ls -la .
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Pods <<<<<<<<<<"
|
||||||
|
find Pods -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> SideStore <<<<<<<<<<"
|
||||||
|
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
|
||||||
|
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
|
||||||
|
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
- name: Build SideStore Tests
|
||||||
|
# using 'tee' to intercept stdout and log for detailed build-log
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
NSUnbufferedIO=YES make -B build-tests 2>&1 | tee -a build/logs/tests-build.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
|
||||||
|
|
||||||
|
- name: (Tests-Build) Save Xcode & SwiftPM Cache
|
||||||
|
id: cache-save
|
||||||
|
if: ${{ steps.xcode-cache-restore.outputs.cache-hit != 'true' }}
|
||||||
|
uses: actions/cache/save@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/Library/Developer/Xcode/DerivedData
|
||||||
|
~/Library/Caches/org.swift.swiftpm
|
||||||
|
key: xcode-cache-tests-${{ github.ref_name }}-${{ github.sha }}
|
||||||
|
|
||||||
|
- name: (Tests-Build) List Files and Build artifacts
|
||||||
|
if: always()
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
||||||
|
ls -la .
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Build <<<<<<<<<<"
|
||||||
|
find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
|
||||||
|
find ~/Library/Developer/Xcode/DerivedData -maxdepth 8 -exec ls -ld {} + | grep "Build/Products" >> tests-build-deriveddata.txt || true
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: tests-build-deriveddata-${{ inputs.short_commit }}.txt
|
||||||
|
path: tests-build-deriveddata.txt
|
||||||
|
|
||||||
|
- name: Encrypt tests-build-logs for upload
|
||||||
|
id: encrypt-test-log
|
||||||
|
if: always()
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
DEFAULT_BUILD_LOG_PASSWORD=12345
|
||||||
|
|
||||||
|
BUILD_LOG_ZIP_PASSWORD=${{ secrets.BUILD_LOG_ZIP_PASSWORD }}
|
||||||
|
BUILD_LOG_ZIP_PASSWORD=${BUILD_LOG_ZIP_PASSWORD:-$DEFAULT_BUILD_LOG_PASSWORD}
|
||||||
|
|
||||||
|
if [ "$BUILD_LOG_ZIP_PASSWORD" == "$DEFAULT_BUILD_LOG_PASSWORD" ]; then
|
||||||
|
echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'."
|
||||||
|
fi
|
||||||
|
|
||||||
|
pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-tests-build-logs.zip * || popd
|
||||||
|
echo "::set-output name=encrypted::true"
|
||||||
|
|
||||||
|
- name: Upload encrypted-tests-build-logs.zip
|
||||||
|
id: attach-encrypted-test-log
|
||||||
|
if: always() && steps.encrypt-test-log.outputs.encrypted == 'true'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: encrypted-tests-build-logs-${{ inputs.short_commit }}.zip
|
||||||
|
path: encrypted-tests-build-logs.zip
|
||||||
235
.github/workflows/sidestore-tests-run.yml
vendored
Normal file
235
.github/workflows/sidestore-tests-run.yml
vendored
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
name: SideStore Tests Run
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
type: string
|
||||||
|
short_commit:
|
||||||
|
type: string
|
||||||
|
secrets:
|
||||||
|
BUILD_LOG_ZIP_PASSWORD:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tests-run:
|
||||||
|
name: Tests-Run SideStore - ${{ inputs.release_tag }}
|
||||||
|
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: 'macos-15'
|
||||||
|
version: '16.2'
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Boot Simulator async(nohup) for testing
|
||||||
|
run: |
|
||||||
|
mkdir -p build/logs
|
||||||
|
nohup make -B boot-sim-async </dev/null >> build/logs/tests-run.log 2>&1 &
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@v1.6.0
|
||||||
|
with:
|
||||||
|
xcode-version: '16.2'
|
||||||
|
|
||||||
|
# - name: (Tests-Run) Cache Build
|
||||||
|
# uses: irgaly/xcode-cache@v1.8.1
|
||||||
|
# with:
|
||||||
|
# # This comes from
|
||||||
|
# key: xcode-cache-deriveddata-test-${{ github.ref_name }}-${{ github.sha }}
|
||||||
|
# swiftpm-cache-key: xcode-cache-sourcedata-test-${{ github.ref_name }}-${{ github.sha }}
|
||||||
|
|
||||||
|
- name: (Tests-Build) Restore Xcode & SwiftPM Cache (Exact match) [from tests-build job]
|
||||||
|
id: xcode-cache-restore
|
||||||
|
uses: actions/cache/restore@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/Library/Developer/Xcode/DerivedData
|
||||||
|
~/Library/Caches/org.swift.swiftpm
|
||||||
|
key: xcode-cache-tests-${{ github.ref_name }}-${{ github.sha }}
|
||||||
|
|
||||||
|
- name: (Tests-Run) Restore Pods from Cache (Exact match)
|
||||||
|
id: pods-restore
|
||||||
|
uses: actions/cache/restore@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
./Podfile.lock
|
||||||
|
./Pods/
|
||||||
|
./AltStore.xcworkspace/
|
||||||
|
key: pods-cache-test-${{ github.ref_name }}-${{ hashFiles('Podfile') }}
|
||||||
|
|
||||||
|
- name: (Tests-Run) Restore Pods from Cache (Last Available)
|
||||||
|
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
|
||||||
|
id: pods-restore-recent
|
||||||
|
uses: actions/cache/restore@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
./Podfile.lock
|
||||||
|
./Pods/
|
||||||
|
./AltStore.xcworkspace/
|
||||||
|
key: pods-cache-test-${{ github.ref_name }}-
|
||||||
|
|
||||||
|
- name: (Tests-Run) Install CocoaPods
|
||||||
|
run: pod install
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: (Tests-Run) Save Pods to Cache
|
||||||
|
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
|
||||||
|
uses: actions/cache/save@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
./Podfile.lock
|
||||||
|
./Pods/
|
||||||
|
./AltStore.xcworkspace/
|
||||||
|
key: pods-cache-test-${{ github.ref_name }}-${{ hashFiles('Podfile') }}
|
||||||
|
|
||||||
|
- name: (Tests-Run) Clean previous build artifacts
|
||||||
|
run: |
|
||||||
|
make clean
|
||||||
|
mkdir -p build/logs
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: (Tests-Run) List Files and derived data
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
||||||
|
ls -la .
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Pods <<<<<<<<<<"
|
||||||
|
find Pods -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> SideStore <<<<<<<<<<"
|
||||||
|
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
|
||||||
|
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
|
||||||
|
find ~/Library/Developer/Xcode/DerivedData -maxdepth 8 -exec ls -ld {} + | grep "Build/Products" >> tests-run-deriveddata.txt || true
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: tests-run-deriveddata-${{ inputs.short_commit }}.txt
|
||||||
|
path: tests-run-deriveddata.txt
|
||||||
|
|
||||||
|
# we expect simulator to have been booted by now, so exit otherwise
|
||||||
|
- name: Simulator Boot Check
|
||||||
|
run: |
|
||||||
|
mkdir -p build/logs
|
||||||
|
make -B sim-boot-check | tee -a build/logs/tests-run.log
|
||||||
|
exit ${PIPESTATUS[0]}
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Start Recording UI tests (if DEBUG_RECORD_TESTS is set to 1)
|
||||||
|
if: ${{ vars.DEBUG_RECORD_TESTS == '1' }}
|
||||||
|
run: |
|
||||||
|
nohup xcrun simctl io booted recordVideo -f tests-recording.mp4 --codec h264 </dev/null > tests-recording.log 2>&1 &
|
||||||
|
RECORD_PID=$!
|
||||||
|
echo "RECORD_PID=$RECORD_PID" >> $GITHUB_ENV
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Run SideStore Tests
|
||||||
|
# using 'tee' to intercept stdout and log for detailed build-log
|
||||||
|
run: |
|
||||||
|
make run-tests 2>&1 | tee -a build/logs/tests-run.log && exit ${PIPESTATUS[0]}
|
||||||
|
# NSUnbufferedIO=YES make -B run-tests 2>&1 | tee build/logs/tests-run.log | xcpretty -r junit --output ./build/tests/test-results.xml && exit ${PIPESTATUS[0]}
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Stop Recording tests
|
||||||
|
if: ${{ always() && env.RECORD_PID != '' }}
|
||||||
|
run: |
|
||||||
|
kill -INT ${{ env.RECORD_PID }}
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: (Tests-Run) List Files and Build artifacts
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
||||||
|
ls -la .
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Build <<<<<<<<<<"
|
||||||
|
find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Encrypt tests-run-logs for upload
|
||||||
|
id: encrypt-test-log
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
DEFAULT_BUILD_LOG_PASSWORD=12345
|
||||||
|
|
||||||
|
BUILD_LOG_ZIP_PASSWORD=${{ secrets.BUILD_LOG_ZIP_PASSWORD }}
|
||||||
|
BUILD_LOG_ZIP_PASSWORD=${BUILD_LOG_ZIP_PASSWORD:-$DEFAULT_BUILD_LOG_PASSWORD}
|
||||||
|
|
||||||
|
if [ "$BUILD_LOG_ZIP_PASSWORD" == "$DEFAULT_BUILD_LOG_PASSWORD" ]; then
|
||||||
|
echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'."
|
||||||
|
fi
|
||||||
|
|
||||||
|
pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-tests-run-logs.zip * || popd
|
||||||
|
echo "::set-output name=encrypted::true"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Upload encrypted-tests-run-logs.zip
|
||||||
|
id: attach-encrypted-test-log
|
||||||
|
if: always() && steps.encrypt-test-log.outputs.encrypted == 'true'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: encrypted-tests-run-logs-${{ inputs.short_commit }}.zip
|
||||||
|
path: encrypted-tests-run-logs.zip
|
||||||
|
|
||||||
|
- name: Print tests-recording.log contents (if exists)
|
||||||
|
if: ${{ always() && env.RECORD_PID != '' }}
|
||||||
|
run: |
|
||||||
|
if [ -f tests-recording.log ]; then
|
||||||
|
echo "tests-recording.log found. Its contents:"
|
||||||
|
cat tests-recording.log
|
||||||
|
else
|
||||||
|
echo "tests-recording.log not found."
|
||||||
|
fi
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Check for tests-recording.mp4 presence
|
||||||
|
id: check-recording
|
||||||
|
if: ${{ always() && env.RECORD_PID != '' }}
|
||||||
|
run: |
|
||||||
|
if [ -f tests-recording.mp4 ]; then
|
||||||
|
echo "::set-output name=found::true"
|
||||||
|
echo "tests-recording.mp4 found."
|
||||||
|
else
|
||||||
|
echo "tests-recording.mp4 not found, skipping upload."
|
||||||
|
echo "::set-output name=found::false"
|
||||||
|
fi
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Upload tests-recording.mp4
|
||||||
|
id: upload-recording
|
||||||
|
if: ${{ always() && steps.check-recording.outputs.found == 'true' }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: tests-recording-${{ inputs.short_commit }}.mp4
|
||||||
|
path: tests-recording.mp4
|
||||||
|
|
||||||
|
- name: Zip test-results
|
||||||
|
run: zip -r -9 ./test-results.zip ./build/tests
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Upload Test Artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: test-results-${{ inputs.short_commit }}.zip
|
||||||
|
path: test-results.zip
|
||||||
283
.github/workflows/stable.yml
vendored
Normal file
283
.github/workflows/stable.yml
vendored
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
name: Stable SideStore build
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '[0-9]+.[0-9]+.[0-9]+' # example: 1.0.0
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build SideStore - stable (on tag push)
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: 'macos-15'
|
||||||
|
version: '16.2'
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Echo Build.xcconfig
|
||||||
|
run: |
|
||||||
|
echo "cat Build.xcconfig"
|
||||||
|
cat Build.xcconfig
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
# - name: Change MARKETING_VERSION to the pushed tag that triggered this build
|
||||||
|
# run: sed -e '/MARKETING_VERSION = .*/s/= .*/= ${{ github.ref_name }}/' -i '' Build.xcconfig
|
||||||
|
|
||||||
|
- name: Echo Updated Build.xcconfig
|
||||||
|
run: |
|
||||||
|
cat Build.xcconfig
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Extract MARKETING_VERSION from Build.xcconfig
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
version=$(grep MARKETING_VERSION Build.xcconfig | sed -e 's/MARKETING_VERSION = //g')
|
||||||
|
echo "version=$version" >> $GITHUB_OUTPUT
|
||||||
|
echo "version=$version"
|
||||||
|
|
||||||
|
echo "MARKETING_VERSION=$version" >> $GITHUB_ENV
|
||||||
|
echo "MARKETING_VERSION=$version" >> $GITHUB_OUTPUT
|
||||||
|
echo "MARKETING_VERSION=$version"
|
||||||
|
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Fail the build if pushed tag and embedded MARKETING_VERSION in Build.xcconfig are mismatching
|
||||||
|
run: |
|
||||||
|
if [ "$MARKETING_VERSION" != "${{ github.ref_name }}" ]; then
|
||||||
|
echo 'Version mismatch: $tag != $marketing_version ... '
|
||||||
|
echo " expected-tag : $MARKETING_VERSION"
|
||||||
|
echo " pushed-tag : ${{ github.ref_name }}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo 'Version matches: $tag == $marketing_version ... '
|
||||||
|
echo " expected-tag : $MARKETING_VERSION"
|
||||||
|
echo " pushed-tag : ${{ github.ref_name }}"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Install dependencies - ldid & xcbeautify
|
||||||
|
run: |
|
||||||
|
brew install ldid xcbeautify
|
||||||
|
|
||||||
|
- name: Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@v1.6.0
|
||||||
|
with:
|
||||||
|
xcode-version: ${{ matrix.version }}
|
||||||
|
|
||||||
|
- name: (Build) Restore Xcode & SwiftPM Cache (Exact match)
|
||||||
|
id: xcode-cache-restore
|
||||||
|
uses: actions/cache/restore@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/Library/Developer/Xcode/DerivedData
|
||||||
|
~/Library/Caches/org.swift.swiftpm
|
||||||
|
key: xcode-cache-build-stable-${{ github.sha }}
|
||||||
|
|
||||||
|
- name: (Build) Restore Xcode & SwiftPM Cache (Last Available)
|
||||||
|
id: xcode-cache-restore-recent
|
||||||
|
uses: actions/cache/restore@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/Library/Developer/Xcode/DerivedData
|
||||||
|
~/Library/Caches/org.swift.swiftpm
|
||||||
|
key: xcode-cache-build-stable-
|
||||||
|
|
||||||
|
- name: (Build) Restore Pods from Cache (Exact match)
|
||||||
|
id: pods-restore
|
||||||
|
uses: actions/cache/restore@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
./Podfile.lock
|
||||||
|
./Pods/
|
||||||
|
./AltStore.xcworkspace/
|
||||||
|
key: pods-cache-build-stable-${{ hashFiles('Podfile') }}
|
||||||
|
|
||||||
|
- name: (Build) Restore Pods from Cache (Last Available)
|
||||||
|
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
|
||||||
|
id: pods-restore-recent
|
||||||
|
uses: actions/cache/restore@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
./Podfile.lock
|
||||||
|
./Pods/
|
||||||
|
./AltStore.xcworkspace/
|
||||||
|
key: pods-cache-build-stable-
|
||||||
|
|
||||||
|
|
||||||
|
- name: (Build) Install CocoaPods
|
||||||
|
run: pod install
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: (Build) Save Pods to Cache
|
||||||
|
id: save-pods
|
||||||
|
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
|
||||||
|
uses: actions/cache/save@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
./Podfile.lock
|
||||||
|
./Pods/
|
||||||
|
./AltStore.xcworkspace/
|
||||||
|
key: pods-cache-build-stable-${{ hashFiles('Podfile') }}
|
||||||
|
|
||||||
|
- name: (Build) Clean previous build artifacts
|
||||||
|
run: |
|
||||||
|
make clean
|
||||||
|
mkdir -p build/logs
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: (Build) List Files and derived data
|
||||||
|
if: always()
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
||||||
|
ls -la .
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Pods <<<<<<<<<<"
|
||||||
|
find Pods -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> SideStore <<<<<<<<<<"
|
||||||
|
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
|
||||||
|
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
|
||||||
|
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
- name: Build SideStore.xcarchive
|
||||||
|
# using 'tee' to intercept stdout and log for detailed build-log
|
||||||
|
run: |
|
||||||
|
NSUnbufferedIO=YES make -B build 2>&1 | tee -a build/logs/build.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Fakesign app
|
||||||
|
run: make fakesign | tee -a build/logs/build.log
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Convert to IPA
|
||||||
|
run: make ipa | tee -a build/logs/build.log
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: (Build) Save Xcode & SwiftPM Cache
|
||||||
|
id: cache-save
|
||||||
|
if: ${{ steps.xcode-cache-restore.outputs.cache-hit != 'true' }}
|
||||||
|
uses: actions/cache/save@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/Library/Developer/Xcode/DerivedData
|
||||||
|
~/Library/Caches/org.swift.swiftpm
|
||||||
|
key: xcode-cache-build-stable-${{ github.sha }}
|
||||||
|
|
||||||
|
- name: (Build) List Files and Build artifacts
|
||||||
|
run: |
|
||||||
|
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
||||||
|
ls -la .
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Build <<<<<<<<<<"
|
||||||
|
find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> SideStore <<<<<<<<<<"
|
||||||
|
find SideStore -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> SideStore.xcarchive <<<<<<<<<<"
|
||||||
|
find SideStore.xcarchive -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
|
||||||
|
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
|
||||||
|
echo ""
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Encrypt build-logs for upload
|
||||||
|
id: encrypt-build-log
|
||||||
|
run: |
|
||||||
|
DEFAULT_BUILD_LOG_PASSWORD=12345
|
||||||
|
|
||||||
|
BUILD_LOG_ZIP_PASSWORD=${{ secrets.BUILD_LOG_ZIP_PASSWORD }}
|
||||||
|
BUILD_LOG_ZIP_PASSWORD=${BUILD_LOG_ZIP_PASSWORD:-$DEFAULT_BUILD_LOG_PASSWORD}
|
||||||
|
|
||||||
|
if [ "$BUILD_LOG_ZIP_PASSWORD" == "$DEFAULT_BUILD_LOG_PASSWORD" ]; then
|
||||||
|
echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'."
|
||||||
|
fi
|
||||||
|
|
||||||
|
pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-build-logs.zip * || popd
|
||||||
|
echo "::set-output name=encrypted::true"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Upload encrypted-build-logs.zip
|
||||||
|
id: attach-encrypted-build-log
|
||||||
|
if: ${{ always() && steps.encrypt-build-log.outputs.encrypted == 'true' }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: encrypted-build-logs-${{ steps.version.outputs.version }}.zip
|
||||||
|
path: encrypted-build-logs.zip
|
||||||
|
|
||||||
|
- name: Upload SideStore.ipa Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: SideStore-${{ steps.version.outputs.version }}.ipa
|
||||||
|
path: SideStore.ipa
|
||||||
|
|
||||||
|
- name: Zip dSYMs
|
||||||
|
run: zip -r -9 ./SideStore.dSYMs.zip ./SideStore.xcarchive/dSYMs
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Upload *.dSYM Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: SideStore-${{ steps.version.outputs.version }}-dSYMs.zip
|
||||||
|
path: SideStore.dSYMs.zip
|
||||||
|
|
||||||
|
- name: Get current date
|
||||||
|
id: date
|
||||||
|
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Get current date in AltStore date form
|
||||||
|
id: date_altstore
|
||||||
|
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Upload to releases
|
||||||
|
uses: IsaacShelton/update-existing-release@v1.3.1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
draft: true
|
||||||
|
release: ${{ github.ref_name }} # name
|
||||||
|
tag: ${{ github.ref_name }}
|
||||||
|
# stick with what the user pushed, do not use latest commit or anything,
|
||||||
|
# ex: if we want to go back to previous release due to hot issue, dev can create a new tag pointing to that older working tag/commit so as to keep it as an update (to revert major issue)
|
||||||
|
# in this case we do not want the tag to be auto-updated to latest
|
||||||
|
updateTag: false
|
||||||
|
prerelease: false
|
||||||
|
files: >
|
||||||
|
SideStore.ipa
|
||||||
|
SideStore.dSYMs.zip
|
||||||
|
encrypted-build-logs.zip
|
||||||
|
body: |
|
||||||
|
<!-- NOTE: to reset SideSource cache, go to `https://apps.sidestore.io/reset-cache/nightly/<sidesource key>`. This is not included in the GitHub Action since it makes draft releases so they can be edited and have a changelog. -->
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
- TODO
|
||||||
|
|
||||||
|
## Build Info
|
||||||
|
|
||||||
|
Built at (UTC): `${{ steps.date.outputs.date }}`
|
||||||
|
Built at (UTC date): `${{ steps.date_altstore.outputs.date }}`
|
||||||
|
Commit SHA: `${{ github.sha }}`
|
||||||
|
Version: `${{ steps.version.outputs.version }}`
|
||||||
46
.gitignore
vendored
46
.gitignore
vendored
@@ -1,14 +1,18 @@
|
|||||||
# macOS
|
# macOS
|
||||||
#
|
#
|
||||||
*.DS_Store
|
**/*.DS_Store
|
||||||
|
|
||||||
# Xcode
|
# Xcode
|
||||||
#
|
#
|
||||||
|
|
||||||
|
## CocoaPods
|
||||||
|
Pods/
|
||||||
|
|
||||||
## Build generated
|
## Build generated
|
||||||
build/
|
build/
|
||||||
DerivedData
|
DerivedData
|
||||||
|
|
||||||
|
SideStore.xcarchive
|
||||||
## Various settings
|
## Various settings
|
||||||
*.pbxuser
|
*.pbxuser
|
||||||
!default.pbxuser
|
!default.pbxuser
|
||||||
@@ -27,4 +31,42 @@ xcuserdata
|
|||||||
*.xcscmblueprint
|
*.xcscmblueprint
|
||||||
|
|
||||||
## Obj-C/Swift specific
|
## Obj-C/Swift specific
|
||||||
*.hmap
|
*.hmap
|
||||||
|
/newrelic_agent.log
|
||||||
|
/CodeSigning.xcconfig
|
||||||
|
/.vscode
|
||||||
|
|
||||||
|
## AppCode specific
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
Payload/
|
||||||
|
**/SideStore.ipa
|
||||||
|
**/AltBackup.ipa
|
||||||
|
**/*.dSYM
|
||||||
|
|
||||||
|
Dependencies/.*-prebuilt-fetch-*
|
||||||
|
SideStore/minimuxer/*
|
||||||
|
SideStore/em_proxy/*
|
||||||
|
!Dependencies/**/.gitkeep
|
||||||
|
.nightly-build-num
|
||||||
|
|
||||||
|
## em_proxy and minimuxer biaries
|
||||||
|
**/.last-prebuilt-fetch-em_proxy
|
||||||
|
**/.last-prebuilt-fetch-minimuxer
|
||||||
|
|
||||||
|
# misc
|
||||||
|
**/output.txt
|
||||||
|
SideStore/.skip-prebuilt-fetch-minimuxer
|
||||||
|
SideStore/.skip-prebuilt-fetch-em_proxy
|
||||||
|
|
||||||
|
.git.bkp/
|
||||||
|
# Never check-in this package.resolved file
|
||||||
|
# coz SPM then resolves packages using the stale entries in this file
|
||||||
|
*.xcodeproj/**/Package.resolved
|
||||||
|
*.xcworkspace/**/Package.resolved
|
||||||
|
|
||||||
|
# some more commandline build artifacts
|
||||||
|
test-recording.mp4
|
||||||
|
test-recording.log
|
||||||
|
altstore-sources.md
|
||||||
|
local-build.sh
|
||||||
71
.gitmodules
vendored
71
.gitmodules
vendored
@@ -1,15 +1,64 @@
|
|||||||
|
#-------------------------------
|
||||||
|
# When changing url/branch in this .gitmodules file,
|
||||||
|
# Always ensure you run:
|
||||||
|
# 1. `git rm --cached <submodule_relative_path>` # this removes the submodule entry from general git tracking
|
||||||
|
# 2. `rm -rf .git/modules/<submodule_relative_path>` # this removes the stale name entries in submodule tracker
|
||||||
|
# 3. `rm -rf <submodule_relative_path>` # removes the submodule completely
|
||||||
|
# 4. `git submodule --deinit <submodule_relative_path>` # make sure that the submodule is de-inited too (ignore errors at this point)
|
||||||
|
# 5. `git submodule add [-b <branch_name>] <repo_url> <submodule_relative_path>` # This adds the submodule back into general git tracking and also adds to the submodule tracker
|
||||||
|
# 6. Step 5 creates an entry in the .gitmodules when a submodule is added,
|
||||||
|
# So if you already had one entry, try to remove duplicates at this point
|
||||||
|
# 7. `git submodule sync --recursive` # this now sets/updates the submodule repo url tracker into git config
|
||||||
|
# 8. `git submodule update --init --recursive` # this now clones the updated repo set by .gitmodules
|
||||||
|
# But this will always fetch the latest commit sepecified by the custom(if set)/default branch
|
||||||
|
# 9. If you do want to have a specific commit in that submodule branch and not latest, you need to perform normal detached head checkout and check-in as follows:
|
||||||
|
# `pushd <submodule_relative_path>` # switch to the submodule repo
|
||||||
|
# `git checkout <commit-id>` # this creates a detached head state
|
||||||
|
# `popd` # get back to parent repo
|
||||||
|
# `git add <submodule_relative_path>` # check-in the changes in parent for this submodule link (tracker)
|
||||||
|
# `git commit -m <commit-message>` # commit it to parent repo
|
||||||
|
# `git push` # push to parent repo to preserve this entire change in the submodule repo/link file
|
||||||
|
#
|
||||||
|
# NOTES:
|
||||||
|
# 1. updating just this .gitmodules file is NOT ENOUGH when changing repo url and performing a simple `git submodule update --init --recursive`, need to do all the above listed steps for proper tracking
|
||||||
|
# 2. updating the branch in this .gitmodules for same repo is okay as long as `git submodule update --init --recursive` is also performed followed by it
|
||||||
|
# 3. Ensure there is no stale entries or duplicate entries in this .gitmodules file coz, `git submodule add ...` creates an entry here.
|
||||||
|
#-------------------------------
|
||||||
|
|
||||||
[submodule "Dependencies/Roxas"]
|
[submodule "Dependencies/Roxas"]
|
||||||
path = Dependencies/Roxas
|
path = Dependencies/Roxas
|
||||||
url = https://github.com/rileytestut/Roxas.git
|
url = https://github.com/rileytestut/Roxas.git
|
||||||
[submodule "Dependencies/AltSign"]
|
|
||||||
path = Dependencies/AltSign
|
|
||||||
url = https://github.com/rileytestut/AltSign.git
|
|
||||||
[submodule "Dependencies/libimobiledevice"]
|
[submodule "Dependencies/libimobiledevice"]
|
||||||
path = Dependencies/libimobiledevice
|
path = Dependencies/libimobiledevice
|
||||||
url = https://github.com/rileytestut/libimobiledevice.git
|
url = https://github.com/SideStore/libimobiledevice
|
||||||
[submodule "Dependencies/libusbmuxd"]
|
[submodule "Dependencies/libusbmuxd"]
|
||||||
path = Dependencies/libusbmuxd
|
path = Dependencies/libusbmuxd
|
||||||
url = https://github.com/libimobiledevice/libusbmuxd.git
|
url = https://github.com/libimobiledevice/libusbmuxd.git
|
||||||
[submodule "Dependencies/libplist"]
|
[submodule "Dependencies/libplist"]
|
||||||
path = Dependencies/libplist
|
path = Dependencies/libplist
|
||||||
url = https://github.com/libimobiledevice/libplist.git
|
url = https://github.com/SideStore/libplist.git
|
||||||
|
[submodule "Dependencies/MarkdownAttributedString"]
|
||||||
|
path = Dependencies/MarkdownAttributedString
|
||||||
|
url = https://github.com/chockenberry/MarkdownAttributedString.git
|
||||||
|
[submodule "Dependencies/libimobiledevice-glue"]
|
||||||
|
path = Dependencies/libimobiledevice-glue
|
||||||
|
url = https://github.com/libimobiledevice/libimobiledevice-glue
|
||||||
|
|
||||||
|
|
||||||
|
#sidestore dependencies
|
||||||
|
[submodule "SideStore/minimuxer"]
|
||||||
|
path = SideStore/minimuxer
|
||||||
|
url = https://github.com/SideStore/minimuxer
|
||||||
|
branch = master
|
||||||
|
[submodule "SideStore/libfragmentzip"]
|
||||||
|
path = SideStore/libfragmentzip
|
||||||
|
url = https://github.com/SideStore/libfragmentzip
|
||||||
|
branch = master
|
||||||
|
[submodule "SideStore/apps-v2.json"]
|
||||||
|
path = SideStore/apps-v2.json
|
||||||
|
url = https://github.com/SideStore/apps-v2.json
|
||||||
|
branch = main
|
||||||
|
[submodule "SideStore/AltSign"]
|
||||||
|
path = SideStore/AltSign
|
||||||
|
url = https://github.com/SideStore/AltSign
|
||||||
|
branch = master
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>IDEDidComputeMac32BitWarning</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<true/>
|
<array>
|
||||||
|
<string>group.$(APP_GROUP_IDENTIFIER)</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
133
AltBackup/AppDelegate.swift
Normal file
133
AltBackup/AppDelegate.swift
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
//
|
||||||
|
// AppDelegate.swift
|
||||||
|
// AltBackup
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 5/11/20.
|
||||||
|
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension AppDelegate
|
||||||
|
{
|
||||||
|
static let startBackupNotification = Notification.Name("io.sidestore.StartBackup")
|
||||||
|
static let startRestoreNotification = Notification.Name("io.sidestore.StartRestore")
|
||||||
|
|
||||||
|
static let operationDidFinishNotification = Notification.Name("io.sidestore.BackupOperationFinished")
|
||||||
|
|
||||||
|
static let operationResultKey = "result"
|
||||||
|
}
|
||||||
|
|
||||||
|
@UIApplicationMain
|
||||||
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
|
var window: UIWindow?
|
||||||
|
|
||||||
|
private var currentBackupReturnURL: URL?
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool
|
||||||
|
{
|
||||||
|
// Override point for customization after application launch.
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(AppDelegate.operationDidFinish(_:)), name: AppDelegate.operationDidFinishNotification, object: nil)
|
||||||
|
|
||||||
|
let viewController = ViewController()
|
||||||
|
|
||||||
|
self.window = UIWindow(frame: UIScreen.main.bounds)
|
||||||
|
self.window?.rootViewController = viewController
|
||||||
|
self.window?.makeKeyAndVisible()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationWillResignActive(_ application: UIApplication) {
|
||||||
|
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||||
|
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||||
|
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationWillTerminate(_ application: UIApplication) {
|
||||||
|
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) -> Bool
|
||||||
|
{
|
||||||
|
return self.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension AppDelegate
|
||||||
|
{
|
||||||
|
func open(_ url: URL) -> Bool
|
||||||
|
{
|
||||||
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false }
|
||||||
|
guard let command = components.host?.lowercased() else { return false }
|
||||||
|
|
||||||
|
switch command
|
||||||
|
{
|
||||||
|
case "backup":
|
||||||
|
guard let returnString = components.queryItems?.first(where: { $0.name == "returnURL" })?.value, let returnURL = URL(string: returnString) else { return false }
|
||||||
|
self.currentBackupReturnURL = returnURL
|
||||||
|
NotificationCenter.default.post(name: AppDelegate.startBackupNotification, object: nil)
|
||||||
|
|
||||||
|
return true
|
||||||
|
|
||||||
|
case "restore":
|
||||||
|
guard let returnString = components.queryItems?.first(where: { $0.name == "returnURL" })?.value, let returnURL = URL(string: returnString) else { return false }
|
||||||
|
self.currentBackupReturnURL = returnURL
|
||||||
|
NotificationCenter.default.post(name: AppDelegate.startRestoreNotification, object: nil)
|
||||||
|
|
||||||
|
return true
|
||||||
|
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func operationDidFinish(_ notification: Notification)
|
||||||
|
{
|
||||||
|
defer {
|
||||||
|
self.currentBackupReturnURL = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: @mahee96: This doesn't account cases where backup is too long and user switched to other apps
|
||||||
|
// The check for self.currentBackupReturnURL when backup/restore was still in progress but app switched
|
||||||
|
// between FG/BG is improper, since it will ignore(eat up) the response(success/failure) to parent
|
||||||
|
//
|
||||||
|
// This leaves the backup/restore to show dummy animation forever
|
||||||
|
guard
|
||||||
|
let returnURL = self.currentBackupReturnURL,
|
||||||
|
let result = notification.userInfo?[AppDelegate.operationResultKey] as? Result<Void, Error>
|
||||||
|
else {
|
||||||
|
return // This is bad (Needs fixing - never eat up response like this unless there is no context to post response to!)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard var components = URLComponents(url: returnURL, resolvingAgainstBaseURL: false) else {
|
||||||
|
return // This is ASSERTION Failure, ie RETURN URL needs to be valid. So ignoring (eating up) response is not the solution
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result
|
||||||
|
{
|
||||||
|
case .success:
|
||||||
|
components.path = "/success"
|
||||||
|
|
||||||
|
case .failure(let error as NSError):
|
||||||
|
components.path = "/failure"
|
||||||
|
components.queryItems = ["errorDomain": error.domain,
|
||||||
|
"errorCode": String(error.code),
|
||||||
|
"errorDescription": error.localizedDescription].map { URLQueryItem(name: $0, value: $1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let responseURL = components.url else { return }
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
// Response to the caller/parent app is posted here (url is provided by caller in incoming query params)
|
||||||
|
UIApplication.shared.open(responseURL, options: [:]) { (success) in
|
||||||
|
print("Sent response to app with success:", success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
38
AltBackup/Assets.xcassets/Background.colorset/Contents.json
Normal file
38
AltBackup/Assets.xcassets/Background.colorset/Contents.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.518",
|
||||||
|
"green" : "0.502",
|
||||||
|
"red" : "0.004"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.404",
|
||||||
|
"green" : "0.322",
|
||||||
|
"red" : "0.008"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
AltBackup/Assets.xcassets/Contents.json
Normal file
6
AltBackup/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
20
AltBackup/Assets.xcassets/Text.colorset/Contents.json
Normal file
20
AltBackup/Assets.xcassets/Text.colorset/Contents.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "0.750",
|
||||||
|
"blue" : "0xFF",
|
||||||
|
"green" : "0xFF",
|
||||||
|
"red" : "0xFF"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
342
AltBackup/BackupController.swift
Executable file
342
AltBackup/BackupController.swift
Executable file
@@ -0,0 +1,342 @@
|
|||||||
|
//
|
||||||
|
// BackupController.swift
|
||||||
|
// AltBackup
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 5/12/20.
|
||||||
|
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension ErrorUserInfoKey
|
||||||
|
{
|
||||||
|
static let sourceFile: String = "alt_sourceFile"
|
||||||
|
static let sourceFileLine: String = "alt_sourceFileLine"
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Error
|
||||||
|
{
|
||||||
|
var sourceDescription: String? {
|
||||||
|
guard let sourceFile = (self as NSError).userInfo[ErrorUserInfoKey.sourceFile] as? String, let sourceFileLine = (self as NSError).userInfo[ErrorUserInfoKey.sourceFileLine] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return "(\((sourceFile as NSString).lastPathComponent), Line \(sourceFileLine))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BackupError: ALTLocalizedError
|
||||||
|
{
|
||||||
|
enum Code: ALTErrorEnum, RawRepresentable
|
||||||
|
{
|
||||||
|
case invalidBundleID
|
||||||
|
case appGroupNotFound(String?)
|
||||||
|
case randomError // Used for debugging.
|
||||||
|
|
||||||
|
// Provide failure reason for each error code
|
||||||
|
var errorFailureReason: String {
|
||||||
|
switch self {
|
||||||
|
case .invalidBundleID:
|
||||||
|
return NSLocalizedString("The bundle identifier is invalid.", comment: "")
|
||||||
|
case .appGroupNotFound(let appGroup):
|
||||||
|
if let appGroup = appGroup {
|
||||||
|
return String(format: NSLocalizedString("The app group “%@” could not be found.", comment: ""), appGroup)
|
||||||
|
} else {
|
||||||
|
return NSLocalizedString("The AltStore app group could not be found.", comment: "")
|
||||||
|
}
|
||||||
|
case .randomError:
|
||||||
|
return NSLocalizedString("A random error occurred.", comment: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static var errorDomain: String {
|
||||||
|
return "com.sidestore.BackupError"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a raw value for RawRepresentable conformance
|
||||||
|
var rawValue: Int {
|
||||||
|
switch self {
|
||||||
|
case .invalidBundleID: return 0
|
||||||
|
case .appGroupNotFound: return 1
|
||||||
|
case .randomError: return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initializer for RawRepresentable
|
||||||
|
init?(rawValue: Int) {
|
||||||
|
switch rawValue {
|
||||||
|
case 0: self = .invalidBundleID
|
||||||
|
case 1: self = .appGroupNotFound(nil)
|
||||||
|
case 2: self = .randomError
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let code: Code
|
||||||
|
let sourceFile: String
|
||||||
|
let sourceFileLine: Int
|
||||||
|
var failure: String?
|
||||||
|
|
||||||
|
var errorTitle: String?
|
||||||
|
var errorFailure: String?
|
||||||
|
|
||||||
|
var failureReason: String? {
|
||||||
|
switch self.code
|
||||||
|
{
|
||||||
|
case .invalidBundleID: return NSLocalizedString("The bundle identifier is invalid.", comment: "")
|
||||||
|
case .appGroupNotFound(let appGroup):
|
||||||
|
if let appGroup = appGroup
|
||||||
|
{
|
||||||
|
return String(format: NSLocalizedString("The app group “%@” could not be found.", comment: ""), appGroup)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return NSLocalizedString("The AltStore app group could not be found.", comment: "")
|
||||||
|
}
|
||||||
|
case .randomError: return NSLocalizedString("A random error occured.", comment: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorUserInfo: [String : Any] {
|
||||||
|
let userInfo: [String: Any?] = [NSLocalizedDescriptionKey: self.errorDescription,
|
||||||
|
NSLocalizedFailureReasonErrorKey: self.failureReason,
|
||||||
|
NSLocalizedFailureErrorKey: self.failure,
|
||||||
|
ErrorUserInfoKey.sourceFile: self.sourceFile,
|
||||||
|
ErrorUserInfoKey.sourceFileLine: self.sourceFileLine]
|
||||||
|
return userInfo.compactMapValues { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implement description for CustomStringConvertible
|
||||||
|
var description: String {
|
||||||
|
return "\(errorTitle ?? "Unknown Error"): \(failureReason ?? "No reason available")"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(_ code: Code, description: String? = nil, file: String = #file, line: Int = #line)
|
||||||
|
{
|
||||||
|
self.code = code
|
||||||
|
self.failure = description
|
||||||
|
self.sourceFile = file
|
||||||
|
self.sourceFileLine = line
|
||||||
|
self.errorTitle = NSLocalizedString("Backup Error", comment: "")
|
||||||
|
self.errorFailure = description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackupController: NSObject
|
||||||
|
{
|
||||||
|
private let fileCoordinator = NSFileCoordinator(filePresenter: nil)
|
||||||
|
private let operationQueue = OperationQueue()
|
||||||
|
|
||||||
|
override init()
|
||||||
|
{
|
||||||
|
self.operationQueue.name = "AltBackup-BackupQueue"
|
||||||
|
}
|
||||||
|
|
||||||
|
func performBackup(completionHandler: @escaping (Result<Void, Error>) -> Void)
|
||||||
|
{
|
||||||
|
do
|
||||||
|
{
|
||||||
|
guard let bundleIdentifier = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.altBundleID) as? String else {
|
||||||
|
throw BackupError(.invalidBundleID, description: NSLocalizedString("Unable to create backup directory.", comment: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard
|
||||||
|
let altstoreAppGroup = Bundle.main.altstoreAppGroup,
|
||||||
|
let sharedDirectoryURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: altstoreAppGroup)
|
||||||
|
else {
|
||||||
|
throw BackupError(.appGroupNotFound(nil), description: NSLocalizedString("Unable to create backup directory.", comment: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
let backupsDirectory = sharedDirectoryURL.appendingPathComponent("Backups")
|
||||||
|
|
||||||
|
// Use temporary directory to prevent messing up successful backup with incomplete one.
|
||||||
|
let temporaryAppBackupDirectory = backupsDirectory.appendingPathComponent("Temp", isDirectory: true).appendingPathComponent(UUID().uuidString)
|
||||||
|
let appBackupDirectory = backupsDirectory.appendingPathComponent(bundleIdentifier)
|
||||||
|
|
||||||
|
let writingIntent = NSFileAccessIntent.writingIntent(with: temporaryAppBackupDirectory, options: [])
|
||||||
|
let replacementIntent = NSFileAccessIntent.writingIntent(with: appBackupDirectory, options: [.forReplacing])
|
||||||
|
self.fileCoordinator.coordinate(with: [writingIntent, replacementIntent], queue: self.operationQueue) { (error) in
|
||||||
|
do
|
||||||
|
{
|
||||||
|
if let error = error
|
||||||
|
{
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
let mainGroupBackupDirectory = temporaryAppBackupDirectory.appendingPathComponent("App")
|
||||||
|
try FileManager.default.createDirectory(at: mainGroupBackupDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
|
||||||
|
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
let backupDocumentsDirectory = mainGroupBackupDirectory.appendingPathComponent(documentsDirectory.lastPathComponent)
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: backupDocumentsDirectory.path)
|
||||||
|
{
|
||||||
|
try FileManager.default.removeItem(at: backupDocumentsDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: documentsDirectory.path)
|
||||||
|
{
|
||||||
|
try FileManager.default.copyItem(at: documentsDirectory, to: backupDocumentsDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Copied Documents directory from \(documentsDirectory) to \(backupDocumentsDirectory)")
|
||||||
|
|
||||||
|
let libraryDirectory = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0]
|
||||||
|
let backupLibraryDirectory = mainGroupBackupDirectory.appendingPathComponent(libraryDirectory.lastPathComponent)
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: backupLibraryDirectory.path)
|
||||||
|
{
|
||||||
|
try FileManager.default.removeItem(at: backupLibraryDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: libraryDirectory.path)
|
||||||
|
{
|
||||||
|
try FileManager.default.copyItem(at: libraryDirectory, to: backupLibraryDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Copied Library directory from \(libraryDirectory) to \(backupLibraryDirectory)")
|
||||||
|
}
|
||||||
|
|
||||||
|
for appGroup in Bundle.main.appGroups where appGroup != altstoreAppGroup
|
||||||
|
{
|
||||||
|
guard let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
|
||||||
|
throw BackupError(.appGroupNotFound(appGroup), description: NSLocalizedString("Unable to create app group backup directory.", comment: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
let backupAppGroupURL = temporaryAppBackupDirectory.appendingPathComponent(appGroup)
|
||||||
|
|
||||||
|
// There are several system hidden files that we don't have permission to read, so we just skip all hidden files in app group directories.
|
||||||
|
try self.copyDirectoryContents(at: appGroupURL, to: backupAppGroupURL, options: [.skipsHiddenFiles])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace previous backup with new backup.
|
||||||
|
_ = try FileManager.default.replaceItemAt(appBackupDirectory, withItemAt: temporaryAppBackupDirectory)
|
||||||
|
|
||||||
|
print("Replaced previous backup with new backup:", temporaryAppBackupDirectory)
|
||||||
|
|
||||||
|
completionHandler(.success(()))
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
do { try FileManager.default.removeItem(at: temporaryAppBackupDirectory) }
|
||||||
|
catch { print("Failed to remove temporary directory.", error) }
|
||||||
|
|
||||||
|
completionHandler(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
completionHandler(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreBackup(completionHandler: @escaping (Result<Void, Error>) -> Void)
|
||||||
|
{
|
||||||
|
do
|
||||||
|
{
|
||||||
|
guard let bundleIdentifier = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.altBundleID) as? String else {
|
||||||
|
throw BackupError(.invalidBundleID, description: NSLocalizedString("Unable to access backup.", comment: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard
|
||||||
|
let altstoreAppGroup = Bundle.main.altstoreAppGroup,
|
||||||
|
let sharedDirectoryURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: altstoreAppGroup)
|
||||||
|
else { throw BackupError(.appGroupNotFound(nil), description: NSLocalizedString("Unable to access backup.", comment: "")) }
|
||||||
|
|
||||||
|
let backupsDirectory = sharedDirectoryURL.appendingPathComponent("Backups")
|
||||||
|
let appBackupDirectory = backupsDirectory.appendingPathComponent(bundleIdentifier)
|
||||||
|
|
||||||
|
let readingIntent = NSFileAccessIntent.readingIntent(with: appBackupDirectory, options: [])
|
||||||
|
self.fileCoordinator.coordinate(with: [readingIntent], queue: self.operationQueue) { (error) in
|
||||||
|
do
|
||||||
|
{
|
||||||
|
if let error = error
|
||||||
|
{
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
let mainGroupBackupDirectory = appBackupDirectory.appendingPathComponent("App")
|
||||||
|
|
||||||
|
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
let backupDocumentsDirectory = mainGroupBackupDirectory.appendingPathComponent(documentsDirectory.lastPathComponent)
|
||||||
|
|
||||||
|
let libraryDirectory = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0]
|
||||||
|
let backupLibraryDirectory = mainGroupBackupDirectory.appendingPathComponent(libraryDirectory.lastPathComponent)
|
||||||
|
|
||||||
|
try self.copyDirectoryContents(at: backupDocumentsDirectory, to: documentsDirectory)
|
||||||
|
try self.copyDirectoryContents(at: backupLibraryDirectory, to: libraryDirectory)
|
||||||
|
|
||||||
|
for appGroup in Bundle.main.appGroups where appGroup != altstoreAppGroup
|
||||||
|
{
|
||||||
|
guard let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
|
||||||
|
throw BackupError(.appGroupNotFound(appGroup), description: NSLocalizedString("Unable to read app group backup.", comment: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
let backupAppGroupURL = appBackupDirectory.appendingPathComponent(appGroup)
|
||||||
|
try self.copyDirectoryContents(at: backupAppGroupURL, to: appGroupURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
completionHandler(.success(()))
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
completionHandler(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
completionHandler(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension BackupController
|
||||||
|
{
|
||||||
|
func copyDirectoryContents(at sourceDirectoryURL: URL, to destinationDirectoryURL: URL, options: FileManager.DirectoryEnumerationOptions = []) throws
|
||||||
|
{
|
||||||
|
guard FileManager.default.fileExists(atPath: sourceDirectoryURL.path) else { return }
|
||||||
|
|
||||||
|
if !FileManager.default.fileExists(atPath: destinationDirectoryURL.path)
|
||||||
|
{
|
||||||
|
try FileManager.default.createDirectory(at: destinationDirectoryURL, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
for fileURL in try FileManager.default.contentsOfDirectory(at: sourceDirectoryURL, includingPropertiesForKeys: [.isDirectoryKey], options: options)
|
||||||
|
{
|
||||||
|
let isDirectory = try fileURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false
|
||||||
|
let destinationURL = destinationDirectoryURL.appendingPathComponent(fileURL.lastPathComponent)
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: destinationURL.path)
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
try FileManager.default.removeItem(at: destinationURL)
|
||||||
|
}
|
||||||
|
catch CocoaError.fileWriteNoPermission where isDirectory {
|
||||||
|
try self.copyDirectoryContents(at: fileURL, to: destinationURL, options: options)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
print(error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try FileManager.default.copyItem(at: fileURL, to: destinationURL)
|
||||||
|
print("Copied item from \(fileURL) to \(destinationURL)")
|
||||||
|
}
|
||||||
|
catch let error where fileURL.lastPathComponent == "Inbox" && fileURL.deletingLastPathComponent().lastPathComponent == "Documents" {
|
||||||
|
// Ignore errors for /Documents/Inbox
|
||||||
|
print("Failed to copy Inbox directory:", error)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
print(error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
AltBackup/Base.lproj/LaunchScreen.storyboard
Normal file
32
AltBackup/Base.lproj/LaunchScreen.storyboard
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="16096" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||||
|
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||||
|
<dependencies>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/>
|
||||||
|
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||||
|
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||||
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--View Controller-->
|
||||||
|
<scene sceneID="EHf-IW-A2E">
|
||||||
|
<objects>
|
||||||
|
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||||
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<color key="backgroundColor" name="Background"/>
|
||||||
|
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="53" y="375"/>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
<resources>
|
||||||
|
<namedColor name="Background">
|
||||||
|
<color red="0.0040000001899898052" green="0.50199997425079346" blue="0.51800000667572021" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
</namedColor>
|
||||||
|
</resources>
|
||||||
|
</document>
|
||||||
66
AltBackup/Info.plist
Normal file
66
AltBackup/Info.plist
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>ALTAppGroups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.$(APP_GROUP_IDENTIFIER)</string>
|
||||||
|
</array>
|
||||||
|
<key>ALTBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Editor</string>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>SideBackup General</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>sidebackup</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>armv7</string>
|
||||||
|
</array>
|
||||||
|
<key>UIStatusBarStyle</key>
|
||||||
|
<string>UIStatusBarStyleLightContent</string>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportsDocumentBrowser</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
18
AltBackup/Resources/ReleaseEntitlements.plist
Executable file
18
AltBackup/Resources/ReleaseEntitlements.plist
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>application-identifier</key>
|
||||||
|
<string>XYZ0123456.com.SideStore.SideStore.AltBackup</string>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.team-identifier</key>
|
||||||
|
<string>XYZ0123456</string>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.SideStore.SideStore</string>
|
||||||
|
</array>
|
||||||
|
<key>get-task-allow</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
15
AltBackup/UIColor+AltBackup.swift
Normal file
15
AltBackup/UIColor+AltBackup.swift
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// UIColor+AltBackup.swift
|
||||||
|
// AltBackup
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 5/11/20.
|
||||||
|
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIColor
|
||||||
|
{
|
||||||
|
static let altstoreBackground = UIColor(named: "Background")!
|
||||||
|
static let altstoreText = UIColor(named: "Text")!
|
||||||
|
}
|
||||||
212
AltBackup/ViewController.swift
Normal file
212
AltBackup/ViewController.swift
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
//
|
||||||
|
// ViewController.swift
|
||||||
|
// AltBackup
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 5/11/20.
|
||||||
|
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension Bundle
|
||||||
|
{
|
||||||
|
var appName: String? {
|
||||||
|
let appName =
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ??
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: kCFBundleNameKey as String) as? String
|
||||||
|
return appName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ViewController
|
||||||
|
{
|
||||||
|
enum BackupOperation
|
||||||
|
{
|
||||||
|
case backup
|
||||||
|
case restore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewController: UIViewController
|
||||||
|
{
|
||||||
|
private let backupController = BackupController()
|
||||||
|
|
||||||
|
private var currentOperation: BackupOperation? {
|
||||||
|
didSet {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.update()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var textLabel: UILabel!
|
||||||
|
private var detailTextLabel: UILabel!
|
||||||
|
private var activityIndicatorView: UIActivityIndicatorView!
|
||||||
|
|
||||||
|
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||||
|
return .lightContent
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?)
|
||||||
|
{
|
||||||
|
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.backup), name: AppDelegate.startBackupNotification, object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.restore), name: AppDelegate.startRestoreNotification, object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.didEnterBackground(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad()
|
||||||
|
{
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
self.view.backgroundColor = .altstoreBackground
|
||||||
|
|
||||||
|
self.textLabel = UILabel(frame: .zero)
|
||||||
|
self.textLabel.font = UIFont.preferredFont(forTextStyle: .title2)
|
||||||
|
self.textLabel.textColor = .altstoreText
|
||||||
|
self.textLabel.textAlignment = .center
|
||||||
|
self.textLabel.numberOfLines = 0
|
||||||
|
|
||||||
|
self.detailTextLabel = UILabel(frame: .zero)
|
||||||
|
self.detailTextLabel.font = UIFont.preferredFont(forTextStyle: .body)
|
||||||
|
self.detailTextLabel.textColor = .altstoreText
|
||||||
|
self.detailTextLabel.textAlignment = .center
|
||||||
|
self.detailTextLabel.numberOfLines = 0
|
||||||
|
|
||||||
|
self.activityIndicatorView = UIActivityIndicatorView(style: .whiteLarge)
|
||||||
|
self.activityIndicatorView.color = .altstoreText
|
||||||
|
self.activityIndicatorView.startAnimating()
|
||||||
|
|
||||||
|
// TODO: @mahee96: Disabled these backup/restore buttons in altbackup.app screen which were present for debugging purpose.
|
||||||
|
// Can find something useful for these later, but these are not required by this backup/restore app
|
||||||
|
// #if DEBUG
|
||||||
|
// let button1 = UIButton(type: .system)
|
||||||
|
// button1.setTitle("Backup", for: .normal)
|
||||||
|
// button1.setTitleColor(.white, for: .normal)
|
||||||
|
// button1.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
|
||||||
|
// button1.addTarget(self, action: #selector(ViewController.backup), for: .primaryActionTriggered)
|
||||||
|
//
|
||||||
|
// let button2 = UIButton(type: .system)
|
||||||
|
// button2.setTitle("Restore", for: .normal)
|
||||||
|
// button2.setTitleColor(.white, for: .normal)
|
||||||
|
// button2.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
|
||||||
|
// button2.addTarget(self, action: #selector(ViewController.restore), for: .primaryActionTriggered)
|
||||||
|
//
|
||||||
|
// let arrangedSubviews = [self.textLabel!, self.detailTextLabel!, self.activityIndicatorView!, button1, button2]
|
||||||
|
// #else
|
||||||
|
let arrangedSubviews = [self.textLabel!, self.detailTextLabel!, self.activityIndicatorView!]
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
let stackView = UIStackView(arrangedSubviews: arrangedSubviews)
|
||||||
|
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
stackView.spacing = 22
|
||||||
|
stackView.axis = .vertical
|
||||||
|
stackView.alignment = .center
|
||||||
|
self.view.addSubview(stackView)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([stackView.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
|
||||||
|
stackView.centerYAnchor.constraint(equalTo: self.view.centerYAnchor),
|
||||||
|
stackView.leadingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: self.view.safeAreaLayoutGuide.leadingAnchor, multiplier: 1.0),
|
||||||
|
self.view.safeAreaLayoutGuide.trailingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: stackView.trailingAnchor, multiplier: 1.0)])
|
||||||
|
|
||||||
|
self.update()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ViewController
|
||||||
|
{
|
||||||
|
@objc func backup()
|
||||||
|
{
|
||||||
|
self.currentOperation = .backup
|
||||||
|
|
||||||
|
self.backupController.performBackup { (result) in
|
||||||
|
let appName = Bundle.main.appName ?? NSLocalizedString("App", comment: "")
|
||||||
|
|
||||||
|
let title = String(format: NSLocalizedString("%@ could not be backed up.", comment: ""), appName)
|
||||||
|
self.process(result, errorTitle: title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func restore()
|
||||||
|
{
|
||||||
|
self.currentOperation = .restore
|
||||||
|
|
||||||
|
self.backupController.restoreBackup { (result) in
|
||||||
|
let appName = Bundle.main.appName ?? NSLocalizedString("App", comment: "")
|
||||||
|
|
||||||
|
let title = String(format: NSLocalizedString("%@ could not be restored.", comment: ""), appName)
|
||||||
|
self.process(result, errorTitle: title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func update()
|
||||||
|
{
|
||||||
|
switch self.currentOperation
|
||||||
|
{
|
||||||
|
case .backup:
|
||||||
|
self.textLabel.text = NSLocalizedString("Backing up app data…", comment: "")
|
||||||
|
self.detailTextLabel.isHidden = true
|
||||||
|
self.activityIndicatorView.startAnimating()
|
||||||
|
|
||||||
|
case .restore:
|
||||||
|
self.textLabel.text = NSLocalizedString("Restoring app data…", comment: "")
|
||||||
|
self.detailTextLabel.isHidden = true
|
||||||
|
self.activityIndicatorView.startAnimating()
|
||||||
|
|
||||||
|
// TODO: @mahee96: This is pointless since, app going in bg/fg should still report its last operation properly
|
||||||
|
case .none:
|
||||||
|
self.textLabel.text = String(format: NSLocalizedString("%@ is inactive.", comment: ""),
|
||||||
|
Bundle.main.appName ?? NSLocalizedString("App", comment: ""))
|
||||||
|
|
||||||
|
self.detailTextLabel.text = String(format: NSLocalizedString("Refresh %@ in SideStore to continue using it.", comment: ""),
|
||||||
|
Bundle.main.appName ?? NSLocalizedString("this app", comment: ""))
|
||||||
|
|
||||||
|
self.detailTextLabel.isHidden = false
|
||||||
|
self.activityIndicatorView.stopAnimating()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ViewController
|
||||||
|
{
|
||||||
|
func process(_ result: Result<Void, Error>, errorTitle: String)
|
||||||
|
{
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
switch result
|
||||||
|
{
|
||||||
|
case .success: break
|
||||||
|
case .failure(let error as NSError):
|
||||||
|
let message: String
|
||||||
|
|
||||||
|
if let sourceDescription = error.sourceDescription
|
||||||
|
{
|
||||||
|
message = error.localizedDescription + "\n\n" + sourceDescription
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
message = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
let alertController = UIAlertController(title: errorTitle, message: message, preferredStyle: .alert)
|
||||||
|
alertController.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default, handler: nil))
|
||||||
|
self.present(alertController, animated: true, completion: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.post(name: AppDelegate.operationDidFinishNotification, object: nil, userInfo: [AppDelegate.operationResultKey: result])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: @mahee96: This doesn't account cases where backup is too long and user switched to other apps
|
||||||
|
// Now the user has lost his progress since current operation was cancelled due to switch between FG and BG
|
||||||
|
// if this just the reset for enum such that UI stops showing progress circle, then this is fine!
|
||||||
|
@objc func didEnterBackground(_ notification: Notification)
|
||||||
|
{
|
||||||
|
// Reset UI once we've left app (but not before).
|
||||||
|
self.currentOperation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
//
|
|
||||||
// Bundle+AltStore.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 5/30/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public extension Bundle
|
|
||||||
{
|
|
||||||
struct Info
|
|
||||||
{
|
|
||||||
public static let deviceID = "ALTDeviceID"
|
|
||||||
public static let serverID = "ALTServerID"
|
|
||||||
public static let appGroups = "ALTAppGroups"
|
|
||||||
|
|
||||||
public static let urlTypes = "CFBundleURLTypes"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension Bundle
|
|
||||||
{
|
|
||||||
var infoPlistURL: URL {
|
|
||||||
let infoPlistURL = self.bundleURL.appendingPathComponent("Info.plist")
|
|
||||||
return infoPlistURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
//
|
|
||||||
// NSError+ALTServerError.h
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 5/30/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
|
|
||||||
extern NSErrorDomain const AltServerErrorDomain;
|
|
||||||
extern NSErrorDomain const AltServerInstallationErrorDomain;
|
|
||||||
|
|
||||||
typedef NS_ERROR_ENUM(AltServerErrorDomain, ALTServerError)
|
|
||||||
{
|
|
||||||
ALTServerErrorUnknown = 0,
|
|
||||||
ALTServerErrorConnectionFailed = 1,
|
|
||||||
ALTServerErrorLostConnection = 2,
|
|
||||||
|
|
||||||
ALTServerErrorDeviceNotFound = 3,
|
|
||||||
ALTServerErrorDeviceWriteFailed = 4,
|
|
||||||
|
|
||||||
ALTServerErrorInvalidRequest = 5,
|
|
||||||
ALTServerErrorInvalidResponse = 6,
|
|
||||||
|
|
||||||
ALTServerErrorInvalidApp = 7,
|
|
||||||
ALTServerErrorInstallationFailed = 8,
|
|
||||||
ALTServerErrorMaximumFreeAppLimitReached = 9,
|
|
||||||
ALTServerErrorUnsupportediOSVersion = 10,
|
|
||||||
};
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
@interface NSError (ALTServerError)
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
//
|
|
||||||
// NSError+ALTServerError.m
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 5/30/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "NSError+ALTServerError.h"
|
|
||||||
|
|
||||||
NSErrorDomain const AltServerErrorDomain = @"com.rileytestut.AltServer";
|
|
||||||
NSErrorDomain const AltServerInstallationErrorDomain = @"com.rileytestut.AltServer.Installation";
|
|
||||||
|
|
||||||
@implementation NSError (ALTServerError)
|
|
||||||
|
|
||||||
+ (void)load
|
|
||||||
{
|
|
||||||
[NSError setUserInfoValueProviderForDomain:AltServerErrorDomain provider:^id _Nullable(NSError * _Nonnull error, NSErrorUserInfoKey _Nonnull userInfoKey) {
|
|
||||||
if ([userInfoKey isEqualToString:NSLocalizedDescriptionKey])
|
|
||||||
{
|
|
||||||
return [error alt_localizedDescription];
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil;
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (nullable NSString *)alt_localizedDescription
|
|
||||||
{
|
|
||||||
switch ((ALTServerError)self.code)
|
|
||||||
{
|
|
||||||
case ALTServerErrorUnknown:
|
|
||||||
return NSLocalizedString(@"An unknown error occured.", @"");
|
|
||||||
|
|
||||||
case ALTServerErrorConnectionFailed:
|
|
||||||
return NSLocalizedString(@"Could not connect to AltServer.", @"");
|
|
||||||
|
|
||||||
case ALTServerErrorLostConnection:
|
|
||||||
return NSLocalizedString(@"Lost connection to AltServer.", @"");
|
|
||||||
|
|
||||||
case ALTServerErrorDeviceNotFound:
|
|
||||||
return NSLocalizedString(@"AltServer could not find this device.", @"");
|
|
||||||
|
|
||||||
case ALTServerErrorDeviceWriteFailed:
|
|
||||||
return NSLocalizedString(@"Failed to write app data to device.", @"");
|
|
||||||
|
|
||||||
case ALTServerErrorInvalidRequest:
|
|
||||||
return NSLocalizedString(@"AltServer received an invalid request.", @"");
|
|
||||||
|
|
||||||
case ALTServerErrorInvalidResponse:
|
|
||||||
return NSLocalizedString(@"AltServer sent an invalid response.", @"");
|
|
||||||
|
|
||||||
case ALTServerErrorInvalidApp:
|
|
||||||
return NSLocalizedString(@"The app is invalid.", @"");
|
|
||||||
|
|
||||||
case ALTServerErrorInstallationFailed:
|
|
||||||
return NSLocalizedString(@"An error occured while installing the app.", @"");
|
|
||||||
|
|
||||||
case ALTServerErrorMaximumFreeAppLimitReached:
|
|
||||||
return NSLocalizedString(@"You have reached the limit of 3 apps per device.", @"");
|
|
||||||
|
|
||||||
case ALTServerErrorUnsupportediOSVersion:
|
|
||||||
return NSLocalizedString(@"Your device must be running iOS 12.2 or later to install AltStore.", @"");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
//
|
|
||||||
// ServerProtocol.swift
|
|
||||||
// AltServer
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 5/24/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public let ALTServerServiceType = "_altserver._tcp"
|
|
||||||
|
|
||||||
// Can only automatically conform ALTServerError.Code to Codable, not ALTServerError itself
|
|
||||||
extension ALTServerError.Code: Codable {}
|
|
||||||
|
|
||||||
protocol ServerMessage: Codable
|
|
||||||
{
|
|
||||||
var version: Int { get }
|
|
||||||
var identifier: String { get }
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct PrepareAppRequest: ServerMessage
|
|
||||||
{
|
|
||||||
public var version = 1
|
|
||||||
public var identifier = "PrepareApp"
|
|
||||||
|
|
||||||
public var udid: String
|
|
||||||
public var contentSize: Int
|
|
||||||
|
|
||||||
public init(udid: String, contentSize: Int)
|
|
||||||
{
|
|
||||||
self.udid = udid
|
|
||||||
self.contentSize = contentSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct BeginInstallationRequest: ServerMessage
|
|
||||||
{
|
|
||||||
public var version = 1
|
|
||||||
public var identifier = "BeginInstallation"
|
|
||||||
|
|
||||||
public init()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ServerResponse: ServerMessage
|
|
||||||
{
|
|
||||||
public var version = 1
|
|
||||||
public var identifier = "ServerResponse"
|
|
||||||
|
|
||||||
public var progress: Double
|
|
||||||
|
|
||||||
public var error: ALTServerError? {
|
|
||||||
get {
|
|
||||||
guard let code = self.errorCode else { return nil }
|
|
||||||
return ALTServerError(code)
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
self.errorCode = newValue?.code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private var errorCode: ALTServerError.Code?
|
|
||||||
|
|
||||||
public init(progress: Double, error: ALTServerError?)
|
|
||||||
{
|
|
||||||
self.progress = progress
|
|
||||||
self.error = error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
//
|
|
||||||
// Use this file to import your target's public headers that you would like to expose to Swift.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "ALTDeviceManager.h"
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict/>
|
|
||||||
</plist>
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
//
|
|
||||||
// AppDelegate.swift
|
|
||||||
// AltServer
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 5/24/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Cocoa
|
|
||||||
import UserNotifications
|
|
||||||
|
|
||||||
import AltSign
|
|
||||||
|
|
||||||
import LaunchAtLogin
|
|
||||||
|
|
||||||
@NSApplicationMain
|
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
|
||||||
|
|
||||||
private var statusItem: NSStatusItem?
|
|
||||||
|
|
||||||
private var connectedDevices = [ALTDevice]()
|
|
||||||
|
|
||||||
private weak var authenticationAlert: NSAlert?
|
|
||||||
|
|
||||||
@IBOutlet private var appMenu: NSMenu!
|
|
||||||
@IBOutlet private var connectedDevicesMenu: NSMenu!
|
|
||||||
@IBOutlet private var launchAtLoginMenuItem: NSMenuItem!
|
|
||||||
|
|
||||||
private weak var authenticationAppleIDTextField: NSTextField?
|
|
||||||
private weak var authenticationPasswordTextField: NSSecureTextField?
|
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ aNotification: Notification)
|
|
||||||
{
|
|
||||||
UserDefaults.standard.registerDefaults()
|
|
||||||
|
|
||||||
UNUserNotificationCenter.current().delegate = self
|
|
||||||
ConnectionManager.shared.start()
|
|
||||||
|
|
||||||
let item = NSStatusBar.system.statusItem(withLength: -1)
|
|
||||||
guard let button = item.button else { return }
|
|
||||||
|
|
||||||
button.image = NSImage(named: "MenuBarIcon")
|
|
||||||
button.target = self
|
|
||||||
button.action = #selector(AppDelegate.presentMenu)
|
|
||||||
|
|
||||||
self.statusItem = item
|
|
||||||
|
|
||||||
self.connectedDevicesMenu.delegate = self
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationWillTerminate(_ aNotification: Notification)
|
|
||||||
{
|
|
||||||
// Insert code here to tear down your application
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension AppDelegate
|
|
||||||
{
|
|
||||||
@objc func presentMenu()
|
|
||||||
{
|
|
||||||
guard let button = self.statusItem?.button, let superview = button.superview, let window = button.window else { return }
|
|
||||||
|
|
||||||
self.connectedDevices = ALTDeviceManager.shared.connectedDevices
|
|
||||||
|
|
||||||
self.launchAtLoginMenuItem.state = LaunchAtLogin.isEnabled ? .on : .off
|
|
||||||
self.launchAtLoginMenuItem.action = #selector(AppDelegate.toggleLaunchAtLogin(_:))
|
|
||||||
|
|
||||||
let x = button.frame.origin.x
|
|
||||||
let y = button.frame.origin.y - 5
|
|
||||||
|
|
||||||
let location = superview.convert(NSMakePoint(x, y), to: nil)
|
|
||||||
|
|
||||||
guard let event = NSEvent.mouseEvent(with: .leftMouseUp, location: location,
|
|
||||||
modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil,
|
|
||||||
eventNumber: 0, clickCount: 1, pressure: 0)
|
|
||||||
else { return }
|
|
||||||
|
|
||||||
NSMenu.popUpContextMenu(self.appMenu, with: event, for: button)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func installAltStore(_ item: NSMenuItem)
|
|
||||||
{
|
|
||||||
guard case let index = self.connectedDevicesMenu.index(of: item), index != -1 else { return }
|
|
||||||
|
|
||||||
let alert = NSAlert()
|
|
||||||
alert.messageText = NSLocalizedString("Please enter your Apple ID and password.", comment: "")
|
|
||||||
alert.informativeText = NSLocalizedString("Your Apple ID and password are not saved and are only sent to Apple for authentication.", comment: "")
|
|
||||||
|
|
||||||
let textFieldSize = NSSize(width: 300, height: 22)
|
|
||||||
|
|
||||||
let appleIDTextField = NSTextField(frame: NSRect(x: 0, y: 0, width: textFieldSize.width, height: textFieldSize.height))
|
|
||||||
appleIDTextField.delegate = self
|
|
||||||
appleIDTextField.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
appleIDTextField.placeholderString = NSLocalizedString("Apple ID", comment: "")
|
|
||||||
alert.window.initialFirstResponder = appleIDTextField
|
|
||||||
self.authenticationAppleIDTextField = appleIDTextField
|
|
||||||
|
|
||||||
let passwordTextField = NSSecureTextField(frame: NSRect(x: 0, y: 0, width: textFieldSize.width, height: textFieldSize.height))
|
|
||||||
passwordTextField.delegate = self
|
|
||||||
passwordTextField.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
passwordTextField.placeholderString = NSLocalizedString("Password", comment: "")
|
|
||||||
self.authenticationPasswordTextField = passwordTextField
|
|
||||||
|
|
||||||
appleIDTextField.nextKeyView = passwordTextField
|
|
||||||
|
|
||||||
let stackView = NSStackView(frame: NSRect(x: 0, y: 0, width: textFieldSize.width, height: textFieldSize.height * 2))
|
|
||||||
stackView.orientation = .vertical
|
|
||||||
stackView.distribution = .equalSpacing
|
|
||||||
stackView.spacing = 0
|
|
||||||
stackView.addArrangedSubview(appleIDTextField)
|
|
||||||
stackView.addArrangedSubview(passwordTextField)
|
|
||||||
alert.accessoryView = stackView
|
|
||||||
|
|
||||||
alert.addButton(withTitle: NSLocalizedString("Install", comment: ""))
|
|
||||||
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: ""))
|
|
||||||
|
|
||||||
self.authenticationAlert = alert
|
|
||||||
self.validate()
|
|
||||||
|
|
||||||
NSRunningApplication.current.activate(options: .activateIgnoringOtherApps)
|
|
||||||
|
|
||||||
let response = alert.runModal()
|
|
||||||
guard response == .alertFirstButtonReturn else { return }
|
|
||||||
|
|
||||||
let username = appleIDTextField.stringValue
|
|
||||||
let password = passwordTextField.stringValue
|
|
||||||
|
|
||||||
let device = self.connectedDevices[index]
|
|
||||||
ALTDeviceManager.shared.installAltStore(to: device, appleID: username, password: password) { (result) in
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .success:
|
|
||||||
let content = UNMutableNotificationContent()
|
|
||||||
content.title = NSLocalizedString("Installation Succeeded", comment: "")
|
|
||||||
content.body = String(format: NSLocalizedString("AltStore was successfully installed on %@.", comment: ""), device.name)
|
|
||||||
|
|
||||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
|
||||||
UNUserNotificationCenter.current().add(request)
|
|
||||||
|
|
||||||
case .failure(InstallError.cancelled):
|
|
||||||
// Ignore
|
|
||||||
break
|
|
||||||
|
|
||||||
case .failure(let error as NSError):
|
|
||||||
|
|
||||||
let alert = NSAlert()
|
|
||||||
alert.alertStyle = .critical
|
|
||||||
alert.messageText = NSLocalizedString("Installation Failed", comment: "")
|
|
||||||
|
|
||||||
if let underlyingError = error.userInfo[NSUnderlyingErrorKey] as? Error
|
|
||||||
{
|
|
||||||
alert.informativeText = underlyingError.localizedDescription
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
alert.informativeText = error.localizedDescription
|
|
||||||
}
|
|
||||||
|
|
||||||
NSRunningApplication.current.activate(options: .activateIgnoringOtherApps)
|
|
||||||
|
|
||||||
alert.runModal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func toggleLaunchAtLogin(_ item: NSMenuItem)
|
|
||||||
{
|
|
||||||
if item.state == .on
|
|
||||||
{
|
|
||||||
item.state = .off
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
item.state = .on
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchAtLogin.isEnabled.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppDelegate: NSMenuDelegate
|
|
||||||
{
|
|
||||||
func numberOfItems(in menu: NSMenu) -> Int
|
|
||||||
{
|
|
||||||
return self.connectedDevices.isEmpty ? 1 : self.connectedDevices.count
|
|
||||||
}
|
|
||||||
|
|
||||||
func menu(_ menu: NSMenu, update item: NSMenuItem, at index: Int, shouldCancel: Bool) -> Bool
|
|
||||||
{
|
|
||||||
if self.connectedDevices.isEmpty
|
|
||||||
{
|
|
||||||
item.title = NSLocalizedString("No Connected Devices", comment: "")
|
|
||||||
item.isEnabled = false
|
|
||||||
item.target = nil
|
|
||||||
item.action = nil
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
let device = self.connectedDevices[index]
|
|
||||||
item.title = device.name
|
|
||||||
item.isEnabled = true
|
|
||||||
item.target = self
|
|
||||||
item.action = #selector(AppDelegate.installAltStore)
|
|
||||||
item.tag = index
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppDelegate: NSTextFieldDelegate
|
|
||||||
{
|
|
||||||
func controlTextDidChange(_ obj: Notification)
|
|
||||||
{
|
|
||||||
self.validate()
|
|
||||||
}
|
|
||||||
|
|
||||||
func controlTextDidEndEditing(_ obj: Notification)
|
|
||||||
{
|
|
||||||
self.validate()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func validate()
|
|
||||||
{
|
|
||||||
guard
|
|
||||||
let appleID = self.authenticationAppleIDTextField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
let password = self.authenticationPasswordTextField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
else { return }
|
|
||||||
|
|
||||||
if appleID.isEmpty || password.isEmpty
|
|
||||||
{
|
|
||||||
self.authenticationAlert?.buttons.first?.isEnabled = false
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.authenticationAlert?.buttons.first?.isEnabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
self.authenticationAlert?.layout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppDelegate: UNUserNotificationCenterDelegate
|
|
||||||
{
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
|
|
||||||
{
|
|
||||||
completionHandler([.alert, .sound, .badge])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"size" : "16x16",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"size" : "16x16",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"size" : "32x32",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"size" : "32x32",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"size" : "128x128",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"size" : "128x128",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"size" : "256x256",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"size" : "256x256",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"size" : "512x512",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"size" : "512x512",
|
|
||||||
"scale" : "2x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"version" : 1,
|
|
||||||
"author" : "xcode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"info" : {
|
|
||||||
"version" : 1,
|
|
||||||
"author" : "xcode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.7 KiB |
@@ -1,351 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
|
|
||||||
<dependencies>
|
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14490.70"/>
|
|
||||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
|
||||||
</dependencies>
|
|
||||||
<scenes>
|
|
||||||
<!--Application-->
|
|
||||||
<scene sceneID="JPo-4y-FX3">
|
|
||||||
<objects>
|
|
||||||
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
|
|
||||||
<stackView distribution="fill" orientation="vertical" alignment="leading" spacing="4" horizontalStackHuggingPriority="249.99998474121094" verticalStackHuggingPriority="249.99998474121094" detachesHiddenViews="YES" id="urc-xw-Dhc">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="300" height="48"/>
|
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
|
|
||||||
<subviews>
|
|
||||||
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="zLd-d8-ghZ">
|
|
||||||
<rect key="frame" x="0.0" y="26" width="300" height="22"/>
|
|
||||||
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Apple ID" drawsBackground="YES" id="BXa-Re-rs3">
|
|
||||||
<font key="font" metaFont="system"/>
|
|
||||||
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
|
|
||||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
|
||||||
</textFieldCell>
|
|
||||||
<connections>
|
|
||||||
<outlet property="delegate" destination="Voe-Tx-rLC" id="QtW-r2-Vuh"/>
|
|
||||||
<outlet property="nextKeyView" destination="9rp-Vx-rvB" id="bQY-qj-Sej"/>
|
|
||||||
</connections>
|
|
||||||
</textField>
|
|
||||||
<secureTextField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="9rp-Vx-rvB">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="300" height="22"/>
|
|
||||||
<secureTextFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Password" drawsBackground="YES" usesSingleLineMode="YES" id="xqJ-wt-DlP">
|
|
||||||
<font key="font" metaFont="system"/>
|
|
||||||
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
|
|
||||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
|
||||||
<allowedInputSourceLocales>
|
|
||||||
<string>NSAllRomanInputSourcesLocaleIdentifier</string>
|
|
||||||
</allowedInputSourceLocales>
|
|
||||||
</secureTextFieldCell>
|
|
||||||
<connections>
|
|
||||||
<outlet property="delegate" destination="Voe-Tx-rLC" id="qav-xj-izy"/>
|
|
||||||
</connections>
|
|
||||||
</secureTextField>
|
|
||||||
</subviews>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstItem="9rp-Vx-rvB" firstAttribute="width" secondItem="urc-xw-Dhc" secondAttribute="width" id="Eht-pU-Gyh"/>
|
|
||||||
<constraint firstItem="zLd-d8-ghZ" firstAttribute="width" secondItem="urc-xw-Dhc" secondAttribute="width" id="mg7-Kq-abL"/>
|
|
||||||
<constraint firstAttribute="width" constant="300" id="zqf-x6-BET"/>
|
|
||||||
</constraints>
|
|
||||||
<visibilityPriorities>
|
|
||||||
<integer value="1000"/>
|
|
||||||
<integer value="1000"/>
|
|
||||||
</visibilityPriorities>
|
|
||||||
<customSpacing>
|
|
||||||
<real value="3.4028234663852886e+38"/>
|
|
||||||
<real value="3.4028234663852886e+38"/>
|
|
||||||
</customSpacing>
|
|
||||||
</stackView>
|
|
||||||
<customObject id="Ady-hI-5gd" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
|
|
||||||
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="AltServer" customModuleProvider="target">
|
|
||||||
<connections>
|
|
||||||
<outlet property="appMenu" destination="uQy-DD-JDr" id="7cY-Ov-AOW"/>
|
|
||||||
<outlet property="authenticationAppleIDTextField" destination="zLd-d8-ghZ" id="wW5-0J-zdq"/>
|
|
||||||
<outlet property="authenticationPasswordTextField" destination="9rp-Vx-rvB" id="ZoC-DI-jzQ"/>
|
|
||||||
<outlet property="connectedDevicesMenu" destination="KJ9-WY-pW1" id="Mcv-64-iFU"/>
|
|
||||||
<outlet property="launchAtLoginMenuItem" destination="IyR-FQ-upe" id="Fxn-EP-hwH"/>
|
|
||||||
</connections>
|
|
||||||
</customObject>
|
|
||||||
<application id="hnw-xV-0zn" sceneMemberID="viewController">
|
|
||||||
<menu key="mainMenu" title="Main Menu" systemMenu="main" id="AYu-sK-qS6">
|
|
||||||
<items>
|
|
||||||
<menuItem title="AltServer" id="1Xt-HY-uBw">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<menu key="submenu" title="AltServer" systemMenu="apple" id="uQy-DD-JDr">
|
|
||||||
<items>
|
|
||||||
<menuItem title="About AltServer" id="5kV-Vb-QxS">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="orderFrontStandardAboutPanel:" target="Ady-hI-5gd" id="Exp-CZ-Vem"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem isSeparatorItem="YES" id="wFC-TO-SCJ"/>
|
|
||||||
<menuItem title="Install AltStore" id="MJ8-Lt-SSV">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<menu key="submenu" title="Install AltStore" systemMenu="recentDocuments" id="KJ9-WY-pW1">
|
|
||||||
<items>
|
|
||||||
<menuItem title="No Connected Devices" id="N5N-3K-XuR">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="clearRecentDocuments:" target="Ady-hI-5gd" id="DKG-yI-Ujv"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
</items>
|
|
||||||
<connections>
|
|
||||||
<outlet property="delegate" destination="Voe-Tx-rLC" id="VYb-BL-Zri"/>
|
|
||||||
</connections>
|
|
||||||
</menu>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem isSeparatorItem="YES" id="1ZZ-BB-xHy"/>
|
|
||||||
<menuItem title="Launch at Login" id="IyR-FQ-upe" userLabel="Launch At Login">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem isSeparatorItem="YES" id="mVM-Nm-Zi9"/>
|
|
||||||
<menuItem title="Quit AltServer" keyEquivalent="q" id="4sb-4s-VLi">
|
|
||||||
<connections>
|
|
||||||
<action selector="terminate:" target="Ady-hI-5gd" id="Te7-pn-YzF"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
</items>
|
|
||||||
</menu>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Edit" id="5QF-Oa-p0T">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<menu key="submenu" title="Edit" id="W48-6f-4Dl">
|
|
||||||
<items>
|
|
||||||
<menuItem title="Undo" keyEquivalent="z" id="dRJ-4n-Yzg">
|
|
||||||
<connections>
|
|
||||||
<action selector="undo:" target="Ady-hI-5gd" id="M6e-cu-g7V"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Redo" keyEquivalent="Z" id="6dh-zS-Vam">
|
|
||||||
<connections>
|
|
||||||
<action selector="redo:" target="Ady-hI-5gd" id="oIA-Rs-6OD"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem isSeparatorItem="YES" id="WRV-NI-Exz"/>
|
|
||||||
<menuItem title="Cut" keyEquivalent="x" id="uRl-iY-unG">
|
|
||||||
<connections>
|
|
||||||
<action selector="cut:" target="Ady-hI-5gd" id="YJe-68-I9s"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Copy" keyEquivalent="c" id="x3v-GG-iWU">
|
|
||||||
<connections>
|
|
||||||
<action selector="copy:" target="Ady-hI-5gd" id="G1f-GL-Joy"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Paste" keyEquivalent="v" id="gVA-U4-sdL">
|
|
||||||
<connections>
|
|
||||||
<action selector="paste:" target="Ady-hI-5gd" id="UvS-8e-Qdg"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Paste and Match Style" keyEquivalent="V" id="WeT-3V-zwk">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="pasteAsPlainText:" target="Ady-hI-5gd" id="cEh-KX-wJQ"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Delete" id="pa3-QI-u2k">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="delete:" target="Ady-hI-5gd" id="0Mk-Ml-PaM"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Select All" keyEquivalent="a" id="Ruw-6m-B2m">
|
|
||||||
<connections>
|
|
||||||
<action selector="selectAll:" target="Ady-hI-5gd" id="VNm-Mi-diN"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem isSeparatorItem="YES" id="uyl-h8-XO2"/>
|
|
||||||
<menuItem title="Find" id="4EN-yA-p0u">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<menu key="submenu" title="Find" id="1b7-l0-nxx">
|
|
||||||
<items>
|
|
||||||
<menuItem title="Find…" tag="1" keyEquivalent="f" id="Xz5-n4-O0W">
|
|
||||||
<connections>
|
|
||||||
<action selector="performFindPanelAction:" target="Ady-hI-5gd" id="cD7-Qs-BN4"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Find and Replace…" tag="12" keyEquivalent="f" id="YEy-JH-Tfz">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="performFindPanelAction:" target="Ady-hI-5gd" id="WD3-Gg-5AJ"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Find Next" tag="2" keyEquivalent="g" id="q09-fT-Sye">
|
|
||||||
<connections>
|
|
||||||
<action selector="performFindPanelAction:" target="Ady-hI-5gd" id="NDo-RZ-v9R"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Find Previous" tag="3" keyEquivalent="G" id="OwM-mh-QMV">
|
|
||||||
<connections>
|
|
||||||
<action selector="performFindPanelAction:" target="Ady-hI-5gd" id="HOh-sY-3ay"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Use Selection for Find" tag="7" keyEquivalent="e" id="buJ-ug-pKt">
|
|
||||||
<connections>
|
|
||||||
<action selector="performFindPanelAction:" target="Ady-hI-5gd" id="U76-nv-p5D"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Jump to Selection" keyEquivalent="j" id="S0p-oC-mLd">
|
|
||||||
<connections>
|
|
||||||
<action selector="centerSelectionInVisibleArea:" target="Ady-hI-5gd" id="IOG-6D-g5B"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
</items>
|
|
||||||
</menu>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Spelling and Grammar" id="Dv1-io-Yv7">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<menu key="submenu" title="Spelling" id="3IN-sU-3Bg">
|
|
||||||
<items>
|
|
||||||
<menuItem title="Show Spelling and Grammar" keyEquivalent=":" id="HFo-cy-zxI">
|
|
||||||
<connections>
|
|
||||||
<action selector="showGuessPanel:" target="Ady-hI-5gd" id="vFj-Ks-hy3"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Check Document Now" keyEquivalent=";" id="hz2-CU-CR7">
|
|
||||||
<connections>
|
|
||||||
<action selector="checkSpelling:" target="Ady-hI-5gd" id="fz7-VC-reM"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem isSeparatorItem="YES" id="bNw-od-mp5"/>
|
|
||||||
<menuItem title="Check Spelling While Typing" id="rbD-Rh-wIN">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="toggleContinuousSpellChecking:" target="Ady-hI-5gd" id="7w6-Qz-0kB"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Check Grammar With Spelling" id="mK6-2p-4JG">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="toggleGrammarChecking:" target="Ady-hI-5gd" id="muD-Qn-j4w"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Correct Spelling Automatically" id="78Y-hA-62v">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="toggleAutomaticSpellingCorrection:" target="Ady-hI-5gd" id="2lM-Qi-WAP"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
</items>
|
|
||||||
</menu>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Substitutions" id="9ic-FL-obx">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<menu key="submenu" title="Substitutions" id="FeM-D8-WVr">
|
|
||||||
<items>
|
|
||||||
<menuItem title="Show Substitutions" id="z6F-FW-3nz">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="orderFrontSubstitutionsPanel:" target="Ady-hI-5gd" id="oku-mr-iSq"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem isSeparatorItem="YES" id="gPx-C9-uUO"/>
|
|
||||||
<menuItem title="Smart Copy/Paste" id="9yt-4B-nSM">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="toggleSmartInsertDelete:" target="Ady-hI-5gd" id="3IJ-Se-DZD"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Smart Quotes" id="hQb-2v-fYv">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="toggleAutomaticQuoteSubstitution:" target="Ady-hI-5gd" id="ptq-xd-QOA"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Smart Dashes" id="rgM-f4-ycn">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="toggleAutomaticDashSubstitution:" target="Ady-hI-5gd" id="oCt-pO-9gS"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Smart Links" id="cwL-P1-jid">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="toggleAutomaticLinkDetection:" target="Ady-hI-5gd" id="Gip-E3-Fov"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Data Detectors" id="tRr-pd-1PS">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="toggleAutomaticDataDetection:" target="Ady-hI-5gd" id="R1I-Nq-Kbl"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Text Replacement" id="HFQ-gK-NFA">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="toggleAutomaticTextReplacement:" target="Ady-hI-5gd" id="DvP-Fe-Py6"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
</items>
|
|
||||||
</menu>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Transformations" id="2oI-Rn-ZJC">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<menu key="submenu" title="Transformations" id="c8a-y6-VQd">
|
|
||||||
<items>
|
|
||||||
<menuItem title="Make Upper Case" id="vmV-6d-7jI">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="uppercaseWord:" target="Ady-hI-5gd" id="sPh-Tk-edu"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Make Lower Case" id="d9M-CD-aMd">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="lowercaseWord:" target="Ady-hI-5gd" id="iUZ-b5-hil"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Capitalize" id="UEZ-Bs-lqG">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="capitalizeWord:" target="Ady-hI-5gd" id="26H-TL-nsh"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
</items>
|
|
||||||
</menu>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Speech" id="xrE-MZ-jX0">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<menu key="submenu" title="Speech" id="3rS-ZA-NoH">
|
|
||||||
<items>
|
|
||||||
<menuItem title="Start Speaking" id="Ynk-f8-cLZ">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="startSpeaking:" target="Ady-hI-5gd" id="654-Ng-kyl"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Stop Speaking" id="Oyz-dy-DGm">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="stopSpeaking:" target="Ady-hI-5gd" id="dX8-6p-jy9"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
</items>
|
|
||||||
</menu>
|
|
||||||
</menuItem>
|
|
||||||
</items>
|
|
||||||
</menu>
|
|
||||||
</menuItem>
|
|
||||||
<menuItem title="Help" id="wpr-3q-Mcd">
|
|
||||||
<modifierMask key="keyEquivalentModifierMask"/>
|
|
||||||
<menu key="submenu" title="Help" systemMenu="help" id="F2S-fz-NVQ">
|
|
||||||
<items>
|
|
||||||
<menuItem title="AltServer Help" keyEquivalent="?" id="FKE-Sm-Kum">
|
|
||||||
<connections>
|
|
||||||
<action selector="showHelp:" target="Ady-hI-5gd" id="y7X-2Q-9no"/>
|
|
||||||
</connections>
|
|
||||||
</menuItem>
|
|
||||||
</items>
|
|
||||||
</menu>
|
|
||||||
</menuItem>
|
|
||||||
</items>
|
|
||||||
</menu>
|
|
||||||
<connections>
|
|
||||||
<outlet property="delegate" destination="Voe-Tx-rLC" id="PrD-fu-P6m"/>
|
|
||||||
</connections>
|
|
||||||
</application>
|
|
||||||
</objects>
|
|
||||||
<point key="canvasLocation" x="75" y="0.0"/>
|
|
||||||
</scene>
|
|
||||||
</scenes>
|
|
||||||
</document>
|
|
||||||
@@ -1,444 +0,0 @@
|
|||||||
//
|
|
||||||
// ConnectionManager.swift
|
|
||||||
// AltServer
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 5/23/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Network
|
|
||||||
|
|
||||||
import AltKit
|
|
||||||
|
|
||||||
extension ALTServerError
|
|
||||||
{
|
|
||||||
init<E: Error>(_ error: E)
|
|
||||||
{
|
|
||||||
switch error
|
|
||||||
{
|
|
||||||
case let error as ALTServerError: self = error
|
|
||||||
case is DecodingError: self = ALTServerError(.invalidRequest)
|
|
||||||
case is EncodingError: self = ALTServerError(.invalidResponse)
|
|
||||||
default:
|
|
||||||
assertionFailure("Caught unknown error type")
|
|
||||||
self = ALTServerError(.unknown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ConnectionManager
|
|
||||||
{
|
|
||||||
enum State
|
|
||||||
{
|
|
||||||
case notRunning
|
|
||||||
case connecting
|
|
||||||
case running(NWListener.Service)
|
|
||||||
case failed(Swift.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ConnectionManager
|
|
||||||
{
|
|
||||||
static let shared = ConnectionManager()
|
|
||||||
|
|
||||||
var stateUpdateHandler: ((State) -> Void)?
|
|
||||||
|
|
||||||
private(set) var state: State = .notRunning {
|
|
||||||
didSet {
|
|
||||||
self.stateUpdateHandler?(self.state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private lazy var listener = self.makeListener()
|
|
||||||
private let dispatchQueue = DispatchQueue(label: "com.rileytestut.AltServer.connections", qos: .utility)
|
|
||||||
|
|
||||||
private var connections = [NWConnection]()
|
|
||||||
|
|
||||||
private init()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
func start()
|
|
||||||
{
|
|
||||||
switch self.state
|
|
||||||
{
|
|
||||||
case .notRunning, .failed: self.listener.start(queue: self.dispatchQueue)
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop()
|
|
||||||
{
|
|
||||||
switch self.state
|
|
||||||
{
|
|
||||||
case .running: self.listener.cancel()
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension ConnectionManager
|
|
||||||
{
|
|
||||||
func makeListener() -> NWListener
|
|
||||||
{
|
|
||||||
let listener = try! NWListener(using: .tcp)
|
|
||||||
|
|
||||||
let service: NWListener.Service
|
|
||||||
|
|
||||||
if let serverID = UserDefaults.standard.serverID?.data(using: .utf8)
|
|
||||||
{
|
|
||||||
let txtDictionary = ["serverID": serverID]
|
|
||||||
let txtData = NetService.data(fromTXTRecord: txtDictionary)
|
|
||||||
|
|
||||||
service = NWListener.Service(name: nil, type: ALTServerServiceType, domain: nil, txtRecord: txtData)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
service = NWListener.Service(type: ALTServerServiceType)
|
|
||||||
}
|
|
||||||
|
|
||||||
listener.service = service
|
|
||||||
|
|
||||||
listener.serviceRegistrationUpdateHandler = { (serviceChange) in
|
|
||||||
switch serviceChange
|
|
||||||
{
|
|
||||||
case .add(.service(let name, let type, let domain, _)):
|
|
||||||
let service = NWListener.Service(name: name, type: type, domain: domain, txtRecord: nil)
|
|
||||||
self.state = .running(service)
|
|
||||||
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
listener.stateUpdateHandler = { (state) in
|
|
||||||
switch state
|
|
||||||
{
|
|
||||||
case .ready: break
|
|
||||||
case .waiting, .setup: self.state = .connecting
|
|
||||||
case .cancelled: self.state = .notRunning
|
|
||||||
case .failed(let error):
|
|
||||||
self.state = .failed(error)
|
|
||||||
self.start()
|
|
||||||
|
|
||||||
@unknown default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
listener.newConnectionHandler = { [weak self] (connection) in
|
|
||||||
self?.awaitRequest(from: connection)
|
|
||||||
}
|
|
||||||
|
|
||||||
return listener
|
|
||||||
}
|
|
||||||
|
|
||||||
func disconnect(_ connection: NWConnection)
|
|
||||||
{
|
|
||||||
switch connection.state
|
|
||||||
{
|
|
||||||
case .cancelled, .failed:
|
|
||||||
print("Disconnecting from \(connection.endpoint)...")
|
|
||||||
|
|
||||||
if let index = self.connections.firstIndex(where: { $0 === connection })
|
|
||||||
{
|
|
||||||
self.connections.remove(at: index)
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
// State update handler will call this method again.
|
|
||||||
connection.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func process(data: Data?, error: NWError?, from connection: NWConnection) throws -> Data
|
|
||||||
{
|
|
||||||
do
|
|
||||||
{
|
|
||||||
do
|
|
||||||
{
|
|
||||||
guard let data = data else { throw error ?? ALTServerError(.unknown) }
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
catch let error as NWError
|
|
||||||
{
|
|
||||||
print("Error receiving data from connection \(connection)", error)
|
|
||||||
|
|
||||||
throw ALTServerError(.lostConnection)
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch let error as ALTServerError
|
|
||||||
{
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
preconditionFailure("A non-ALTServerError should never be thrown from this method.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension ConnectionManager
|
|
||||||
{
|
|
||||||
func awaitRequest(from connection: NWConnection)
|
|
||||||
{
|
|
||||||
guard !self.connections.contains(where: { $0 === connection }) else { return }
|
|
||||||
self.connections.append(connection)
|
|
||||||
|
|
||||||
|
|
||||||
connection.stateUpdateHandler = { [weak self] (state) in
|
|
||||||
switch state
|
|
||||||
{
|
|
||||||
case .setup, .preparing: break
|
|
||||||
|
|
||||||
case .ready:
|
|
||||||
print("Connected to client:", connection.endpoint)
|
|
||||||
|
|
||||||
self?.receiveApp(from: connection) { (result) in
|
|
||||||
self?.finish(connection: connection, error: result.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
case .waiting:
|
|
||||||
print("Waiting for connection...")
|
|
||||||
|
|
||||||
case .failed(let error):
|
|
||||||
print("Failed to connect to service \(connection.endpoint).", error)
|
|
||||||
self?.disconnect(connection)
|
|
||||||
|
|
||||||
case .cancelled:
|
|
||||||
self?.disconnect(connection)
|
|
||||||
|
|
||||||
@unknown default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
connection.start(queue: self.dispatchQueue)
|
|
||||||
}
|
|
||||||
|
|
||||||
func receiveApp(from connection: NWConnection, completionHandler: @escaping (Result<Void, ALTServerError>) -> Void)
|
|
||||||
{
|
|
||||||
var temporaryURL: URL?
|
|
||||||
|
|
||||||
func finish(_ result: Result<Void, ALTServerError>)
|
|
||||||
{
|
|
||||||
if let temporaryURL = temporaryURL
|
|
||||||
{
|
|
||||||
do { try FileManager.default.removeItem(at: temporaryURL) }
|
|
||||||
catch { print("Failed to remove .ipa.", error) }
|
|
||||||
}
|
|
||||||
|
|
||||||
completionHandler(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.receive(PrepareAppRequest.self, from: connection) { (result) in
|
|
||||||
print("Received request with result:", result)
|
|
||||||
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(let error): finish(.failure(error))
|
|
||||||
case .success(let request):
|
|
||||||
self.receiveApp(for: request, from: connection) { (result) in
|
|
||||||
print("Received app with result:", result)
|
|
||||||
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(let error): finish(.failure(error))
|
|
||||||
case .success(let request, let fileURL):
|
|
||||||
temporaryURL = fileURL
|
|
||||||
|
|
||||||
print("Awaiting begin installation request...")
|
|
||||||
|
|
||||||
self.receive(BeginInstallationRequest.self, from: connection) { (result) in
|
|
||||||
print("Received begin installation request with result:", result)
|
|
||||||
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(let error): finish(.failure(error))
|
|
||||||
case .success:
|
|
||||||
print("Installing to device \(request.udid)...")
|
|
||||||
|
|
||||||
self.installApp(at: fileURL, toDeviceWithUDID: request.udid, connection: connection) { (result) in
|
|
||||||
print("Installed to device with result:", result)
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(let error): finish(.failure(error))
|
|
||||||
case .success: finish(.success(()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func finish(connection: NWConnection, error: ALTServerError?)
|
|
||||||
{
|
|
||||||
if let error = error
|
|
||||||
{
|
|
||||||
print("Failed to process request from \(connection.endpoint).", error)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
print("Processed request from \(connection.endpoint).")
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = ServerResponse(progress: 1.0, error: error)
|
|
||||||
|
|
||||||
self.send(response, to: connection) { (result) in
|
|
||||||
print("Sent response to \(connection.endpoint) with result:", result)
|
|
||||||
|
|
||||||
self.disconnect(connection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func receiveApp(for request: PrepareAppRequest, from connection: NWConnection, completionHandler: @escaping (Result<(PrepareAppRequest, URL), ALTServerError>) -> Void)
|
|
||||||
{
|
|
||||||
connection.receive(minimumIncompleteLength: request.contentSize, maximumLength: request.contentSize) { (data, _, _, error) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
print("Received app data!")
|
|
||||||
|
|
||||||
let data = try self.process(data: data, error: error, from: connection)
|
|
||||||
|
|
||||||
print("Processed app data!")
|
|
||||||
|
|
||||||
guard ALTDeviceManager.shared.availableDevices.contains(where: { $0.identifier == request.udid }) else { throw ALTServerError(.deviceNotFound) }
|
|
||||||
|
|
||||||
print("Writing app data...")
|
|
||||||
|
|
||||||
let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".ipa")
|
|
||||||
try data.write(to: temporaryURL, options: .atomic)
|
|
||||||
|
|
||||||
print("Wrote app to URL:", temporaryURL)
|
|
||||||
|
|
||||||
completionHandler(.success((request, temporaryURL)))
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
print("Error processing app data:", error)
|
|
||||||
|
|
||||||
completionHandler(.failure(ALTServerError(error)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func installApp(at fileURL: URL, toDeviceWithUDID udid: String, connection: NWConnection, completionHandler: @escaping (Result<Void, ALTServerError>) -> Void)
|
|
||||||
{
|
|
||||||
let serialQueue = DispatchQueue(label: "com.altstore.ConnectionManager.installQueue", qos: .default)
|
|
||||||
var isSending = false
|
|
||||||
|
|
||||||
var observation: NSKeyValueObservation?
|
|
||||||
|
|
||||||
let progress = ALTDeviceManager.shared.installApp(at: fileURL, toDeviceWithUDID: udid) { (success, error) in
|
|
||||||
print("Installed app with result:", error == nil ? "Success" : error!.localizedDescription)
|
|
||||||
|
|
||||||
if let error = error.map({ $0 as? ALTServerError ?? ALTServerError(.unknown) })
|
|
||||||
{
|
|
||||||
completionHandler(.failure(error))
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
completionHandler(.success(()))
|
|
||||||
}
|
|
||||||
|
|
||||||
observation?.invalidate()
|
|
||||||
observation = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
observation = progress.observe(\.fractionCompleted, changeHandler: { (progress, change) in
|
|
||||||
serialQueue.async {
|
|
||||||
guard !isSending else { return }
|
|
||||||
isSending = true
|
|
||||||
|
|
||||||
print("Progress:", progress.fractionCompleted)
|
|
||||||
let response = ServerResponse(progress: progress.fractionCompleted, error: nil)
|
|
||||||
|
|
||||||
self.send(response, to: connection) { (result) in
|
|
||||||
serialQueue.async {
|
|
||||||
isSending = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func send<T: Encodable>(_ response: T, to connection: NWConnection, completionHandler: @escaping (Result<Void, ALTServerError>) -> Void)
|
|
||||||
{
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let data = try JSONEncoder().encode(response)
|
|
||||||
let responseSize = withUnsafeBytes(of: Int32(data.count)) { Data($0) }
|
|
||||||
|
|
||||||
connection.send(content: responseSize, completion: .contentProcessed { (error) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
if let error = error
|
|
||||||
{
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
connection.send(content: data, completion: .contentProcessed { (error) in
|
|
||||||
if error != nil
|
|
||||||
{
|
|
||||||
completionHandler(.failure(.init(.lostConnection)))
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
completionHandler(.success(()))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
completionHandler(.failure(.init(.lostConnection)))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
completionHandler(.failure(.init(.invalidResponse)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func receive<T: Decodable>(_ responseType: T.Type, from connection: NWConnection, completionHandler: @escaping (Result<T, ALTServerError>) -> Void)
|
|
||||||
{
|
|
||||||
let size = MemoryLayout<Int32>.size
|
|
||||||
|
|
||||||
print("Receiving request size")
|
|
||||||
connection.receive(minimumIncompleteLength: size, maximumLength: size) { (data, _, _, error) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let data = try self.process(data: data, error: error, from: connection)
|
|
||||||
|
|
||||||
print("Receiving request...")
|
|
||||||
|
|
||||||
let expectedBytes = Int(data.withUnsafeBytes { $0.load(as: Int32.self) })
|
|
||||||
connection.receive(minimumIncompleteLength: expectedBytes, maximumLength: expectedBytes) { (data, _, _, error) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let data = try self.process(data: data, error: error, from: connection)
|
|
||||||
|
|
||||||
let request = try JSONDecoder().decode(T.self, from: data)
|
|
||||||
|
|
||||||
print("Received installation request:", request)
|
|
||||||
|
|
||||||
completionHandler(.success(request))
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
completionHandler(.failure(ALTServerError(error)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
completionHandler(.failure(ALTServerError(error)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
//
|
|
||||||
// UserDefaults+AltServer.swift
|
|
||||||
// AltServer
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/31/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension UserDefaults
|
|
||||||
{
|
|
||||||
var serverID: String? {
|
|
||||||
get {
|
|
||||||
return self.string(forKey: "serverID")
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
self.set(newValue, forKey: "serverID")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerDefaults()
|
|
||||||
{
|
|
||||||
if self.serverID == nil
|
|
||||||
{
|
|
||||||
self.serverID = UUID().uuidString
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,434 +0,0 @@
|
|||||||
//
|
|
||||||
// ALTDeviceManager+Installation.swift
|
|
||||||
// AltServer
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/1/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Cocoa
|
|
||||||
import UserNotifications
|
|
||||||
|
|
||||||
enum InstallError: LocalizedError
|
|
||||||
{
|
|
||||||
case cancelled
|
|
||||||
case noTeam
|
|
||||||
case missingPrivateKey
|
|
||||||
case missingCertificate
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self
|
|
||||||
{
|
|
||||||
case .cancelled: return NSLocalizedString("The operation was cancelled.", comment: "")
|
|
||||||
case .noTeam: return "You are not a member of any developer teams."
|
|
||||||
case .missingPrivateKey: return "The developer certificate's private key could not be found."
|
|
||||||
case .missingCertificate: return "The developer certificate could not be found."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ALTDeviceManager
|
|
||||||
{
|
|
||||||
func installAltStore(to device: ALTDevice, appleID: String, password: String, completion: @escaping (Result<Void, Error>) -> Void)
|
|
||||||
{
|
|
||||||
let destinationDirectoryURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
||||||
|
|
||||||
func finish(_ error: Error?, title: String = "")
|
|
||||||
{
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let error = error
|
|
||||||
{
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
completion(.success(()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try? FileManager.default.removeItem(at: destinationDirectoryURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.authenticate(appleID: appleID, password: password) { (result) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let account = try result.get()
|
|
||||||
|
|
||||||
self.fetchTeam(for: account) { (result) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let team = try result.get()
|
|
||||||
|
|
||||||
self.register(device, team: team) { (result) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let device = try result.get()
|
|
||||||
|
|
||||||
self.fetchCertificate(for: team) { (result) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let certificate = try result.get()
|
|
||||||
|
|
||||||
let content = UNMutableNotificationContent()
|
|
||||||
content.title = String(format: NSLocalizedString("Installing AltStore to %@...", comment: ""), device.name)
|
|
||||||
content.body = NSLocalizedString("This may take a few seconds.", comment: "")
|
|
||||||
|
|
||||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
|
||||||
UNUserNotificationCenter.current().add(request)
|
|
||||||
|
|
||||||
self.downloadApp { (result) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let fileURL = try result.get()
|
|
||||||
|
|
||||||
try FileManager.default.createDirectory(at: destinationDirectoryURL, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
|
|
||||||
let appBundleURL = try FileManager.default.unzipAppBundle(at: fileURL, toDirectory: destinationDirectoryURL)
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
try FileManager.default.removeItem(at: fileURL)
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
print("Failed to remove downloaded .ipa.", error)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let application = ALTApplication(fileURL: appBundleURL) else { throw ALTError(.invalidApp) }
|
|
||||||
|
|
||||||
self.registerAppID(name: "AltStore", identifier: "com.rileytestut.AltStore", team: team) { (result) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let appID = try result.get()
|
|
||||||
|
|
||||||
self.updateFeatures(for: appID, app: application, team: team) { (result) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let appID = try result.get()
|
|
||||||
|
|
||||||
self.fetchProvisioningProfile(for: appID, team: team) { (result) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let provisioningProfile = try result.get()
|
|
||||||
|
|
||||||
self.install(application, to: device, team: team, appID: appID, certificate: certificate, profile: provisioningProfile) { (result) in
|
|
||||||
finish(result.error, title: "Failed to Install AltStore")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
finish(error, title: "Failed to Fetch Provisioning Profile")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
finish(error, title: "Failed to Update App ID")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
finish(error, title: "Failed to Register App")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
finish(error, title: "Failed to Download AltStore")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
finish(error, title: "Failed to Fetch Certificate")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
finish(error, title: "Failed to Register Device")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
finish(error, title: "Failed to Fetch Team")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
finish(error, title: "Failed to Authenticate")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadApp(completionHandler: @escaping (Result<URL, Error>) -> Void)
|
|
||||||
{
|
|
||||||
let appURL = URL(string: "https://www.dropbox.com/s/w1gn9iztlqvltyp/AltStore.ipa?dl=1")!
|
|
||||||
|
|
||||||
let downloadTask = URLSession.shared.downloadTask(with: appURL) { (fileURL, response, error) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let (fileURL, _) = try Result((fileURL, response), error).get()
|
|
||||||
completionHandler(.success(fileURL))
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
completionHandler(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadTask.resume()
|
|
||||||
}
|
|
||||||
|
|
||||||
func authenticate(appleID: String, password: String, completionHandler: @escaping (Result<ALTAccount, Error>) -> Void)
|
|
||||||
{
|
|
||||||
ALTAppleAPI.shared.authenticate(appleID: appleID, password: password) { (account, error) in
|
|
||||||
let result = Result(account, error)
|
|
||||||
completionHandler(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchTeam(for account: ALTAccount, completionHandler: @escaping (Result<ALTTeam, Error>) -> Void)
|
|
||||||
{
|
|
||||||
ALTAppleAPI.shared.fetchTeams(for: account) { (teams, error) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let teams = try Result(teams, error).get()
|
|
||||||
|
|
||||||
if let team = teams.first(where: { $0.type == .free })
|
|
||||||
{
|
|
||||||
return completionHandler(.success(team))
|
|
||||||
}
|
|
||||||
else if let team = teams.first(where: { $0.type == .individual })
|
|
||||||
{
|
|
||||||
return completionHandler(.success(team))
|
|
||||||
}
|
|
||||||
else if let team = teams.first
|
|
||||||
{
|
|
||||||
return completionHandler(.success(team))
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw InstallError.noTeam
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
completionHandler(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchCertificate(for team: ALTTeam, completionHandler: @escaping (Result<ALTCertificate, Error>) -> Void)
|
|
||||||
{
|
|
||||||
ALTAppleAPI.shared.fetchCertificates(for: team) { (certificates, error) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let certificates = try Result(certificates, error).get()
|
|
||||||
|
|
||||||
// Check if there is another AltStore certificate, which means AltStore has been installed with this Apple ID before.
|
|
||||||
if certificates.contains(where: { $0.machineName?.starts(with: "AltStore") == true })
|
|
||||||
{
|
|
||||||
var isCancelled = false
|
|
||||||
|
|
||||||
DispatchQueue.main.sync {
|
|
||||||
let alert = NSAlert()
|
|
||||||
alert.messageText = NSLocalizedString("AltStore already installed on another device.", comment: "")
|
|
||||||
alert.informativeText = NSLocalizedString("Apps installed with AltStore on your other devices will stop working. Are you sure you want to continue?", comment: "")
|
|
||||||
|
|
||||||
alert.addButton(withTitle: NSLocalizedString("Continue", comment: ""))
|
|
||||||
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: ""))
|
|
||||||
|
|
||||||
NSRunningApplication.current.activate(options: .activateIgnoringOtherApps)
|
|
||||||
|
|
||||||
let buttonIndex = alert.runModal()
|
|
||||||
if buttonIndex == NSApplication.ModalResponse.alertSecondButtonReturn
|
|
||||||
{
|
|
||||||
isCancelled = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if isCancelled
|
|
||||||
{
|
|
||||||
return completionHandler(.failure(InstallError.cancelled))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let certificate = certificates.first
|
|
||||||
{
|
|
||||||
ALTAppleAPI.shared.revoke(certificate, for: team) { (success, error) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
try Result(success, error).get()
|
|
||||||
self.fetchCertificate(for: team, completionHandler: completionHandler)
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
completionHandler(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ALTAppleAPI.shared.addCertificate(machineName: "AltStore", to: team) { (certificate, error) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let certificate = try Result(certificate, error).get()
|
|
||||||
guard let privateKey = certificate.privateKey else { throw InstallError.missingPrivateKey }
|
|
||||||
|
|
||||||
ALTAppleAPI.shared.fetchCertificates(for: team) { (certificates, error) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let certificates = try Result(certificates, error).get()
|
|
||||||
|
|
||||||
guard let certificate = certificates.first(where: { $0.serialNumber == certificate.serialNumber }) else {
|
|
||||||
throw InstallError.missingCertificate
|
|
||||||
}
|
|
||||||
|
|
||||||
certificate.privateKey = privateKey
|
|
||||||
|
|
||||||
completionHandler(.success(certificate))
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
completionHandler(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
completionHandler(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
completionHandler(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerAppID(name appName: String, identifier: String, team: ALTTeam, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void)
|
|
||||||
{
|
|
||||||
let bundleID = "com.\(team.identifier).\(identifier)"
|
|
||||||
|
|
||||||
ALTAppleAPI.shared.fetchAppIDs(for: team) { (appIDs, error) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let appIDs = try Result(appIDs, error).get()
|
|
||||||
|
|
||||||
if let appID = appIDs.first(where: { $0.bundleIdentifier == bundleID })
|
|
||||||
{
|
|
||||||
completionHandler(.success(appID))
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ALTAppleAPI.shared.addAppID(withName: appName, bundleIdentifier: bundleID, team: team) { (appID, error) in
|
|
||||||
completionHandler(Result(appID, error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
completionHandler(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateFeatures(for appID: ALTAppID, app: ALTApplication, team: ALTTeam, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void)
|
|
||||||
{
|
|
||||||
let requiredFeatures = app.entitlements.compactMap { (entitlement, value) -> (ALTFeature, Any)? in
|
|
||||||
guard let feature = ALTFeature(entitlement) else { return nil }
|
|
||||||
return (feature, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
var features = requiredFeatures.reduce(into: [ALTFeature: Any]()) { $0[$1.0] = $1.1 }
|
|
||||||
|
|
||||||
if let applicationGroups = app.entitlements[.appGroups] as? [String], !applicationGroups.isEmpty
|
|
||||||
{
|
|
||||||
features[.appGroups] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
let appID = appID.copy() as! ALTAppID
|
|
||||||
appID.features = features
|
|
||||||
|
|
||||||
ALTAppleAPI.shared.update(appID, team: team) { (appID, error) in
|
|
||||||
completionHandler(Result(appID, error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func register(_ device: ALTDevice, team: ALTTeam, completionHandler: @escaping (Result<ALTDevice, Error>) -> Void)
|
|
||||||
{
|
|
||||||
ALTAppleAPI.shared.fetchDevices(for: team) { (devices, error) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let devices = try Result(devices, error).get()
|
|
||||||
|
|
||||||
if let device = devices.first(where: { $0.identifier == device.identifier })
|
|
||||||
{
|
|
||||||
completionHandler(.success(device))
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ALTAppleAPI.shared.registerDevice(name: device.name, identifier: device.identifier, team: team) { (device, error) in
|
|
||||||
completionHandler(Result(device, error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
completionHandler(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchProvisioningProfile(for appID: ALTAppID, team: ALTTeam, completionHandler: @escaping (Result<ALTProvisioningProfile, Error>) -> Void)
|
|
||||||
{
|
|
||||||
ALTAppleAPI.shared.fetchProvisioningProfile(for: appID, team: team) { (profile, error) in
|
|
||||||
completionHandler(Result(profile, error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func install(_ application: ALTApplication, to device: ALTDevice, team: ALTTeam, appID: ALTAppID, certificate: ALTCertificate, profile: ALTProvisioningProfile, completionHandler: @escaping (Result<Void, Error>) -> Void)
|
|
||||||
{
|
|
||||||
DispatchQueue.global().async {
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let infoPlistURL = application.fileURL.appendingPathComponent("Info.plist")
|
|
||||||
|
|
||||||
guard var infoDictionary = NSDictionary(contentsOf: infoPlistURL) as? [String: Any] else { throw ALTError(.missingInfoPlist) }
|
|
||||||
infoDictionary[kCFBundleIdentifierKey as String] = profile.bundleIdentifier
|
|
||||||
infoDictionary[Bundle.Info.deviceID] = device.identifier
|
|
||||||
infoDictionary[Bundle.Info.serverID] = UserDefaults.standard.serverID
|
|
||||||
try (infoDictionary as NSDictionary).write(to: infoPlistURL)
|
|
||||||
|
|
||||||
let resigner = ALTSigner(team: team, certificate: certificate)
|
|
||||||
resigner.signApp(at: application.fileURL, provisioningProfiles: [profile]) { (success, error) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
try Result(success, error).get()
|
|
||||||
|
|
||||||
ALTDeviceManager.shared.installApp(at: application.fileURL, toDeviceWithUDID: device.identifier) { (success, error) in
|
|
||||||
completionHandler(Result(success, error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
print("Failed to install app", error)
|
|
||||||
completionHandler(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
print("Failed to install AltStore", error)
|
|
||||||
completionHandler(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
//
|
|
||||||
// ALTDeviceManager.h
|
|
||||||
// AltServer
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 5/24/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
#import <AltSign/AltSign.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
@interface ALTDeviceManager : NSObject
|
|
||||||
|
|
||||||
@property (class, nonatomic, readonly) ALTDeviceManager *sharedManager;
|
|
||||||
|
|
||||||
@property (nonatomic, readonly) NSArray<ALTDevice *> *connectedDevices;
|
|
||||||
@property (nonatomic, readonly) NSArray<ALTDevice *> *availableDevices;
|
|
||||||
|
|
||||||
- (NSProgress *)installAppAtURL:(NSURL *)fileURL toDeviceWithUDID:(NSString *)udid completionHandler:(void (^)(BOOL success, NSError *_Nullable error))completionHandler;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
||||||
@@ -1,672 +0,0 @@
|
|||||||
//
|
|
||||||
// ALTDeviceManager.m
|
|
||||||
// AltServer
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 5/24/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "ALTDeviceManager.h"
|
|
||||||
#import "NSError+ALTServerError.h"
|
|
||||||
|
|
||||||
#include <libimobiledevice/libimobiledevice.h>
|
|
||||||
#include <libimobiledevice/lockdown.h>
|
|
||||||
#include <libimobiledevice/installation_proxy.h>
|
|
||||||
#include <libimobiledevice/notification_proxy.h>
|
|
||||||
#include <libimobiledevice/afc.h>
|
|
||||||
#include <libimobiledevice/misagent.h>
|
|
||||||
|
|
||||||
void ALTDeviceManagerUpdateStatus(plist_t command, plist_t status, void *udid);
|
|
||||||
|
|
||||||
NSErrorDomain const ALTDeviceErrorDomain = @"com.rileytestut.ALTDeviceError";
|
|
||||||
|
|
||||||
@interface ALTDeviceManager ()
|
|
||||||
|
|
||||||
@property (nonatomic, readonly) NSMutableDictionary<NSUUID *, void (^)(NSError *)> *installationCompletionHandlers;
|
|
||||||
@property (nonatomic, readonly) NSMutableDictionary<NSUUID *, NSProgress *> *installationProgress;
|
|
||||||
@property (nonatomic, readonly) dispatch_queue_t installationQueue;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation ALTDeviceManager
|
|
||||||
|
|
||||||
+ (ALTDeviceManager *)sharedManager
|
|
||||||
{
|
|
||||||
static ALTDeviceManager *_manager = nil;
|
|
||||||
static dispatch_once_t onceToken;
|
|
||||||
dispatch_once(&onceToken, ^{
|
|
||||||
_manager = [[self alloc] init];
|
|
||||||
});
|
|
||||||
|
|
||||||
return _manager;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (instancetype)init
|
|
||||||
{
|
|
||||||
self = [super init];
|
|
||||||
if (self)
|
|
||||||
{
|
|
||||||
_installationCompletionHandlers = [NSMutableDictionary dictionary];
|
|
||||||
_installationProgress = [NSMutableDictionary dictionary];
|
|
||||||
|
|
||||||
_installationQueue = dispatch_queue_create("com.rileytestut.AltServer.InstallationQueue", DISPATCH_QUEUE_SERIAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSProgress *)installAppAtURL:(NSURL *)fileURL toDeviceWithUDID:(NSString *)udid completionHandler:(void (^)(BOOL, NSError * _Nullable))completionHandler
|
|
||||||
{
|
|
||||||
NSProgress *progress = [NSProgress discreteProgressWithTotalUnitCount:4];
|
|
||||||
|
|
||||||
dispatch_async(self.installationQueue, ^{
|
|
||||||
NSUUID *UUID = [NSUUID UUID];
|
|
||||||
__block char *uuidString = (char *)malloc(UUID.UUIDString.length + 1);
|
|
||||||
strncpy(uuidString, (const char *)UUID.UUIDString.UTF8String, UUID.UUIDString.length);
|
|
||||||
uuidString[UUID.UUIDString.length] = '\0';
|
|
||||||
|
|
||||||
__block idevice_t device = NULL;
|
|
||||||
__block lockdownd_client_t client = NULL;
|
|
||||||
__block instproxy_client_t ipc = NULL;
|
|
||||||
__block afc_client_t afc = NULL;
|
|
||||||
__block misagent_client_t mis = NULL;
|
|
||||||
__block lockdownd_service_descriptor_t service = NULL;
|
|
||||||
|
|
||||||
NSURL *removedProfilesDirectoryURL = [[[NSFileManager defaultManager] temporaryDirectory] URLByAppendingPathComponent:[[NSUUID UUID] UUIDString]];
|
|
||||||
NSMutableDictionary<NSString *, ALTProvisioningProfile *> *preferredProfiles = [NSMutableDictionary dictionary];
|
|
||||||
|
|
||||||
void (^finish)(NSError *error) = ^(NSError *error) {
|
|
||||||
|
|
||||||
if ([[NSFileManager defaultManager] fileExistsAtPath:removedProfilesDirectoryURL.path isDirectory:nil])
|
|
||||||
{
|
|
||||||
// Reinstall all provisioning profiles we removed before installation.
|
|
||||||
|
|
||||||
NSArray *contents = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:removedProfilesDirectoryURL.path error:nil];
|
|
||||||
for (NSString *filename in contents)
|
|
||||||
{
|
|
||||||
NSURL *fileURL = [removedProfilesDirectoryURL URLByAppendingPathComponent:filename];
|
|
||||||
|
|
||||||
ALTProvisioningProfile *provisioningProfile = [[ALTProvisioningProfile alloc] initWithURL:fileURL];
|
|
||||||
if (provisioningProfile == nil)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
ALTProvisioningProfile *preferredProfile = preferredProfiles[provisioningProfile.bundleIdentifier];
|
|
||||||
if (![preferredProfile isEqual:provisioningProfile])
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
plist_t pdata = plist_new_data((const char *)provisioningProfile.data.bytes, provisioningProfile.data.length);
|
|
||||||
|
|
||||||
if (misagent_install(mis, pdata) == MISAGENT_E_SUCCESS)
|
|
||||||
{
|
|
||||||
NSLog(@"Reinstalled profile: %@", provisioningProfile.UUID);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
int code = misagent_get_status_code(mis);
|
|
||||||
NSLog(@"Failed to reinstall provisioning profile %@. (%@)", provisioningProfile.UUID, @(code));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[[NSFileManager defaultManager] removeItemAtURL:removedProfilesDirectoryURL error:nil];
|
|
||||||
}
|
|
||||||
|
|
||||||
instproxy_client_free(ipc);
|
|
||||||
afc_client_free(afc);
|
|
||||||
lockdownd_client_free(client);
|
|
||||||
misagent_client_free(mis);
|
|
||||||
idevice_free(device);
|
|
||||||
lockdownd_service_descriptor_free(service);
|
|
||||||
|
|
||||||
free(uuidString);
|
|
||||||
uuidString = NULL;
|
|
||||||
|
|
||||||
if (error != nil)
|
|
||||||
{
|
|
||||||
completionHandler(NO, error);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
completionHandler(YES, nil);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
NSURL *appBundleURL = nil;
|
|
||||||
NSURL *temporaryDirectoryURL = nil;
|
|
||||||
|
|
||||||
if ([fileURL.pathExtension.lowercaseString isEqualToString:@"app"])
|
|
||||||
{
|
|
||||||
appBundleURL = fileURL;
|
|
||||||
temporaryDirectoryURL = nil;
|
|
||||||
}
|
|
||||||
else if ([fileURL.pathExtension.lowercaseString isEqualToString:@"ipa"])
|
|
||||||
{
|
|
||||||
NSLog(@"Unzipping .ipa...");
|
|
||||||
|
|
||||||
temporaryDirectoryURL = [NSFileManager.defaultManager.temporaryDirectory URLByAppendingPathComponent:[[NSUUID UUID] UUIDString] isDirectory:YES];
|
|
||||||
|
|
||||||
NSError *error = nil;
|
|
||||||
if (![[NSFileManager defaultManager] createDirectoryAtURL:temporaryDirectoryURL withIntermediateDirectories:YES attributes:nil error:&error])
|
|
||||||
{
|
|
||||||
return finish(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
appBundleURL = [[NSFileManager defaultManager] unzipAppBundleAtURL:fileURL toDirectory:temporaryDirectoryURL error:&error];
|
|
||||||
if (appBundleURL == nil)
|
|
||||||
{
|
|
||||||
return finish(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return finish([NSError errorWithDomain:NSCocoaErrorDomain code:NSFileReadCorruptFileError userInfo:@{NSURLErrorKey: fileURL}]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Find Device */
|
|
||||||
if (idevice_new(&device, udid.UTF8String) != IDEVICE_E_SUCCESS)
|
|
||||||
{
|
|
||||||
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorDeviceNotFound userInfo:nil]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Connect to Device */
|
|
||||||
if (lockdownd_client_new_with_handshake(device, &client, "altserver") != LOCKDOWN_E_SUCCESS)
|
|
||||||
{
|
|
||||||
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Connect to Installation Proxy */
|
|
||||||
if ((lockdownd_start_service(client, "com.apple.mobile.installation_proxy", &service) != LOCKDOWN_E_SUCCESS) || service == NULL)
|
|
||||||
{
|
|
||||||
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (instproxy_client_new(device, service, &ipc) != INSTPROXY_E_SUCCESS)
|
|
||||||
{
|
|
||||||
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (service)
|
|
||||||
{
|
|
||||||
lockdownd_service_descriptor_free(service);
|
|
||||||
service = NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Connect to Misagent */
|
|
||||||
// Must connect now, since if we take too long writing files to device, connecting may fail later when managing profiles.
|
|
||||||
if (lockdownd_start_service(client, "com.apple.misagent", &service) != LOCKDOWN_E_SUCCESS || service == NULL)
|
|
||||||
{
|
|
||||||
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (misagent_client_new(device, service, &mis) != MISAGENT_E_SUCCESS)
|
|
||||||
{
|
|
||||||
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Connect to AFC service */
|
|
||||||
if ((lockdownd_start_service(client, "com.apple.afc", &service) != LOCKDOWN_E_SUCCESS) || service == NULL)
|
|
||||||
{
|
|
||||||
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (afc_client_new(device, service, &afc) != AFC_E_SUCCESS)
|
|
||||||
{
|
|
||||||
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]);
|
|
||||||
}
|
|
||||||
|
|
||||||
NSURL *stagingURL = [NSURL fileURLWithPath:@"PublicStaging" isDirectory:YES];
|
|
||||||
|
|
||||||
/* Prepare for installation */
|
|
||||||
char **files = NULL;
|
|
||||||
if (afc_get_file_info(afc, stagingURL.relativePath.fileSystemRepresentation, &files) != AFC_E_SUCCESS)
|
|
||||||
{
|
|
||||||
if (afc_make_directory(afc, stagingURL.relativePath.fileSystemRepresentation) != AFC_E_SUCCESS)
|
|
||||||
{
|
|
||||||
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorDeviceWriteFailed userInfo:nil]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (files)
|
|
||||||
{
|
|
||||||
int i = 0;
|
|
||||||
|
|
||||||
while (files[i])
|
|
||||||
{
|
|
||||||
free(files[i]);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
free(files);
|
|
||||||
}
|
|
||||||
|
|
||||||
NSLog(@"Writing to device...");
|
|
||||||
|
|
||||||
plist_t options = instproxy_client_options_new();
|
|
||||||
instproxy_client_options_add(options, "PackageType", "Developer", NULL);
|
|
||||||
|
|
||||||
NSURL *destinationURL = [stagingURL URLByAppendingPathComponent:appBundleURL.lastPathComponent];
|
|
||||||
|
|
||||||
// Writing files to device should be worth 3/4 of total work.
|
|
||||||
[progress becomeCurrentWithPendingUnitCount:3];
|
|
||||||
|
|
||||||
NSError *writeError = nil;
|
|
||||||
if (![self writeDirectory:appBundleURL toDestinationURL:destinationURL client:afc progress:nil error:&writeError])
|
|
||||||
{
|
|
||||||
return finish(writeError);
|
|
||||||
}
|
|
||||||
|
|
||||||
NSLog(@"Finished writing to device.");
|
|
||||||
|
|
||||||
if (service)
|
|
||||||
{
|
|
||||||
lockdownd_service_descriptor_free(service);
|
|
||||||
service = NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Provisioning Profiles */
|
|
||||||
NSURL *provisioningProfileURL = [appBundleURL URLByAppendingPathComponent:@"embedded.mobileprovision"];
|
|
||||||
ALTProvisioningProfile *installationProvisioningProfile = [[ALTProvisioningProfile alloc] initWithURL:provisioningProfileURL];
|
|
||||||
if (installationProvisioningProfile != nil)
|
|
||||||
{
|
|
||||||
NSError *error = nil;
|
|
||||||
if (![[NSFileManager defaultManager] createDirectoryAtURL:removedProfilesDirectoryURL withIntermediateDirectories:YES attributes:nil error:&error])
|
|
||||||
{
|
|
||||||
return finish(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
plist_t profiles = NULL;
|
|
||||||
|
|
||||||
if (misagent_copy_all(mis, &profiles) != MISAGENT_E_SUCCESS)
|
|
||||||
{
|
|
||||||
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]);
|
|
||||||
}
|
|
||||||
|
|
||||||
uint32_t profileCount = plist_array_get_size(profiles);
|
|
||||||
for (int i = 0; i < profileCount; i++)
|
|
||||||
{
|
|
||||||
plist_t profile = plist_array_get_item(profiles, i);
|
|
||||||
if (plist_get_node_type(profile) != PLIST_DATA)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
char *bytes = NULL;
|
|
||||||
uint64_t length = 0;
|
|
||||||
|
|
||||||
plist_get_data_val(profile, &bytes, &length);
|
|
||||||
if (bytes == NULL)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSData *data = [NSData dataWithBytes:(const void *)bytes length:length];
|
|
||||||
ALTProvisioningProfile *provisioningProfile = [[ALTProvisioningProfile alloc] initWithData:data];
|
|
||||||
|
|
||||||
if (![provisioningProfile.teamIdentifier isEqualToString:installationProvisioningProfile.teamIdentifier])
|
|
||||||
{
|
|
||||||
NSLog(@"Ignoring: %@ (Team: %@)", provisioningProfile.bundleIdentifier, provisioningProfile.teamIdentifier);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
ALTProvisioningProfile *preferredProfile = preferredProfiles[provisioningProfile.bundleIdentifier];
|
|
||||||
if (preferredProfile != nil)
|
|
||||||
{
|
|
||||||
if ([provisioningProfile.expirationDate compare:preferredProfile.expirationDate] == NSOrderedDescending)
|
|
||||||
{
|
|
||||||
preferredProfiles[provisioningProfile.bundleIdentifier] = provisioningProfile;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
preferredProfiles[provisioningProfile.bundleIdentifier] = provisioningProfile;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSString *filename = [NSString stringWithFormat:@"%@.mobileprovision", [[NSUUID UUID] UUIDString]];
|
|
||||||
NSURL *fileURL = [removedProfilesDirectoryURL URLByAppendingPathComponent:filename];
|
|
||||||
|
|
||||||
NSError *copyError = nil;
|
|
||||||
if (![provisioningProfile.data writeToURL:fileURL options:NSDataWritingAtomic error:©Error])
|
|
||||||
{
|
|
||||||
NSLog(@"Failed to copy profile to temporary URL. %@", copyError);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (misagent_remove(mis, provisioningProfile.UUID.UUIDString.lowercaseString.UTF8String) == MISAGENT_E_SUCCESS)
|
|
||||||
{
|
|
||||||
NSLog(@"Removed provisioning profile: %@", provisioningProfile.UUID);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
int code = misagent_get_status_code(mis);
|
|
||||||
NSLog(@"Failed to remove provisioning profile %@. Error Code: %@", provisioningProfile.UUID, @(code));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lockdownd_client_free(client);
|
|
||||||
client = NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
|
|
||||||
|
|
||||||
NSProgress *installationProgress = [NSProgress progressWithTotalUnitCount:100 parent:progress pendingUnitCount:1];
|
|
||||||
|
|
||||||
self.installationProgress[UUID] = installationProgress;
|
|
||||||
self.installationCompletionHandlers[UUID] = ^(NSError *error) {
|
|
||||||
finish(error);
|
|
||||||
|
|
||||||
if (temporaryDirectoryURL != nil)
|
|
||||||
{
|
|
||||||
NSError *error = nil;
|
|
||||||
if (![[NSFileManager defaultManager] removeItemAtURL:temporaryDirectoryURL error:&error])
|
|
||||||
{
|
|
||||||
NSLog(@"Error removing temporary directory. %@", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch_semaphore_signal(semaphore);
|
|
||||||
};
|
|
||||||
|
|
||||||
NSLog(@"Installing to device %@...", udid);
|
|
||||||
|
|
||||||
instproxy_install(ipc, destinationURL.relativePath.fileSystemRepresentation, options, ALTDeviceManagerUpdateStatus, uuidString);
|
|
||||||
instproxy_client_options_free(options);
|
|
||||||
|
|
||||||
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
|
|
||||||
});
|
|
||||||
|
|
||||||
return progress;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (BOOL)writeDirectory:(NSURL *)directoryURL toDestinationURL:(NSURL *)destinationURL client:(afc_client_t)afc progress:(NSProgress *)progress error:(NSError **)error
|
|
||||||
{
|
|
||||||
afc_make_directory(afc, destinationURL.relativePath.fileSystemRepresentation);
|
|
||||||
|
|
||||||
if (progress == nil)
|
|
||||||
{
|
|
||||||
NSDirectoryEnumerator *countEnumerator = [[NSFileManager defaultManager] enumeratorAtURL:directoryURL
|
|
||||||
includingPropertiesForKeys:@[]
|
|
||||||
options:0
|
|
||||||
errorHandler:^BOOL(NSURL * _Nonnull url, NSError * _Nonnull error) {
|
|
||||||
if (error) {
|
|
||||||
NSLog(@"[Error] %@ (%@)", error, url);
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
return YES;
|
|
||||||
}];
|
|
||||||
|
|
||||||
NSInteger totalCount = 0;
|
|
||||||
for (NSURL *__unused fileURL in countEnumerator)
|
|
||||||
{
|
|
||||||
totalCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
progress = [NSProgress progressWithTotalUnitCount:totalCount];
|
|
||||||
}
|
|
||||||
|
|
||||||
NSDirectoryEnumerator *enumerator = [[NSFileManager defaultManager] enumeratorAtURL:directoryURL
|
|
||||||
includingPropertiesForKeys:@[NSURLIsDirectoryKey]
|
|
||||||
options:NSDirectoryEnumerationSkipsSubdirectoryDescendants
|
|
||||||
errorHandler:^BOOL(NSURL * _Nonnull url, NSError * _Nonnull error) {
|
|
||||||
if (error) {
|
|
||||||
NSLog(@"[Error] %@ (%@)", error, url);
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
return YES;
|
|
||||||
}];
|
|
||||||
|
|
||||||
for (NSURL *fileURL in enumerator)
|
|
||||||
{
|
|
||||||
NSNumber *isDirectory = nil;
|
|
||||||
if (![fileURL getResourceValue:&isDirectory forKey:NSURLIsDirectoryKey error:error])
|
|
||||||
{
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([isDirectory boolValue])
|
|
||||||
{
|
|
||||||
NSURL *destinationDirectoryURL = [destinationURL URLByAppendingPathComponent:fileURL.lastPathComponent isDirectory:YES];
|
|
||||||
if (![self writeDirectory:fileURL toDestinationURL:destinationDirectoryURL client:afc progress:progress error:error])
|
|
||||||
{
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
NSURL *destinationFileURL = [destinationURL URLByAppendingPathComponent:fileURL.lastPathComponent isDirectory:NO];
|
|
||||||
if (![self writeFile:fileURL toDestinationURL:destinationFileURL client:afc error:error])
|
|
||||||
{
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.completedUnitCount += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return YES;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (BOOL)writeFile:(NSURL *)fileURL toDestinationURL:(NSURL *)destinationURL client:(afc_client_t)afc error:(NSError **)error
|
|
||||||
{
|
|
||||||
NSFileHandle *fileHandle = [NSFileHandle fileHandleForReadingAtPath:fileURL.path];
|
|
||||||
if (fileHandle == nil)
|
|
||||||
{
|
|
||||||
if (error)
|
|
||||||
{
|
|
||||||
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileNoSuchFileError userInfo:@{NSURLErrorKey: fileURL}];
|
|
||||||
}
|
|
||||||
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSData *data = [fileHandle readDataToEndOfFile];
|
|
||||||
|
|
||||||
uint64_t af = 0;
|
|
||||||
if ((afc_file_open(afc, destinationURL.relativePath.fileSystemRepresentation, AFC_FOPEN_WRONLY, &af) != AFC_E_SUCCESS) || af == 0)
|
|
||||||
{
|
|
||||||
if (error)
|
|
||||||
{
|
|
||||||
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileWriteUnknownError userInfo:@{NSURLErrorKey: destinationURL}];
|
|
||||||
}
|
|
||||||
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
BOOL success = YES;
|
|
||||||
uint32_t bytesWritten = 0;
|
|
||||||
|
|
||||||
while (bytesWritten < data.length)
|
|
||||||
{
|
|
||||||
uint32_t count = 0;
|
|
||||||
|
|
||||||
if (afc_file_write(afc, af, (const char *)data.bytes + bytesWritten, (uint32_t)data.length - bytesWritten, &count) != AFC_E_SUCCESS)
|
|
||||||
{
|
|
||||||
if (error)
|
|
||||||
{
|
|
||||||
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileWriteUnknownError userInfo:@{NSURLErrorKey: destinationURL}];
|
|
||||||
}
|
|
||||||
|
|
||||||
success = NO;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
bytesWritten += count;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bytesWritten != data.length)
|
|
||||||
{
|
|
||||||
if (error)
|
|
||||||
{
|
|
||||||
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileWriteUnknownError userInfo:@{NSURLErrorKey: destinationURL}];
|
|
||||||
}
|
|
||||||
|
|
||||||
success = NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
afc_file_close(afc, af);
|
|
||||||
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Getters -
|
|
||||||
|
|
||||||
- (NSArray<ALTDevice *> *)connectedDevices
|
|
||||||
{
|
|
||||||
return [self availableDevicesIncludingNetworkDevices:NO];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSArray<ALTDevice *> *)availableDevices
|
|
||||||
{
|
|
||||||
return [self availableDevicesIncludingNetworkDevices:YES];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSArray<ALTDevice *> *)availableDevicesIncludingNetworkDevices:(BOOL)includingNetworkDevices
|
|
||||||
{
|
|
||||||
NSMutableSet *connectedDevices = [NSMutableSet set];
|
|
||||||
|
|
||||||
int count = 0;
|
|
||||||
char **udids = NULL;
|
|
||||||
if (idevice_get_device_list(&udids, &count) < 0)
|
|
||||||
{
|
|
||||||
fprintf(stderr, "ERROR: Unable to retrieve device list!\n");
|
|
||||||
return @[];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int i = 0; i < count; i++)
|
|
||||||
{
|
|
||||||
char *udid = udids[i];
|
|
||||||
|
|
||||||
idevice_t device = NULL;
|
|
||||||
|
|
||||||
if (includingNetworkDevices)
|
|
||||||
{
|
|
||||||
idevice_new(&device, udid);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
idevice_new_ignore_network(&device, udid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!device)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
lockdownd_client_t client = NULL;
|
|
||||||
int result = lockdownd_client_new(device, &client, "altserver");
|
|
||||||
if (result != LOCKDOWN_E_SUCCESS)
|
|
||||||
{
|
|
||||||
fprintf(stderr, "ERROR: Connecting to device %s failed! (%d)\n", udid, result);
|
|
||||||
|
|
||||||
idevice_free(device);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
char *device_name = NULL;
|
|
||||||
if (lockdownd_get_device_name(client, &device_name) != LOCKDOWN_E_SUCCESS || device_name == NULL)
|
|
||||||
{
|
|
||||||
fprintf(stderr, "ERROR: Could not get device name!\n");
|
|
||||||
|
|
||||||
lockdownd_client_free(client);
|
|
||||||
idevice_free(device);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
lockdownd_client_free(client);
|
|
||||||
idevice_free(device);
|
|
||||||
|
|
||||||
NSString *name = [NSString stringWithCString:device_name encoding:NSUTF8StringEncoding];
|
|
||||||
NSString *identifier = [NSString stringWithCString:udid encoding:NSUTF8StringEncoding];
|
|
||||||
|
|
||||||
ALTDevice *altDevice = [[ALTDevice alloc] initWithName:name identifier:identifier];
|
|
||||||
[connectedDevices addObject:altDevice];
|
|
||||||
|
|
||||||
if (device_name != NULL)
|
|
||||||
{
|
|
||||||
free(device_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
idevice_device_list_free(udids);
|
|
||||||
|
|
||||||
return connectedDevices.allObjects;
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
#pragma mark - Callbacks -
|
|
||||||
|
|
||||||
void ALTDeviceManagerUpdateStatus(plist_t command, plist_t status, void *uuid)
|
|
||||||
{
|
|
||||||
NSUUID *UUID = [[NSUUID alloc] initWithUUIDString:[NSString stringWithUTF8String:(const char *)uuid]];
|
|
||||||
|
|
||||||
NSProgress *progress = ALTDeviceManager.sharedManager.installationProgress[UUID];
|
|
||||||
if (progress == nil)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int percent = -1;
|
|
||||||
instproxy_status_get_percent_complete(status, &percent);
|
|
||||||
|
|
||||||
char *name = NULL;
|
|
||||||
char *description = NULL;
|
|
||||||
uint64_t code = 0;
|
|
||||||
instproxy_status_get_error(status, &name, &description, &code);
|
|
||||||
|
|
||||||
if ((percent == -1 && progress.completedUnitCount > 0) || code != 0 || name != NULL)
|
|
||||||
{
|
|
||||||
void (^completionHandler)(NSError *) = ALTDeviceManager.sharedManager.installationCompletionHandlers[UUID];
|
|
||||||
if (completionHandler != nil)
|
|
||||||
{
|
|
||||||
if (code != 0 || name != NULL)
|
|
||||||
{
|
|
||||||
NSLog(@"Error installing app. %@ (%@). %@", @(code), @(name), @(description));
|
|
||||||
|
|
||||||
NSError *error = nil;
|
|
||||||
|
|
||||||
if (code == 3892346913)
|
|
||||||
{
|
|
||||||
error = [NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorMaximumFreeAppLimitReached userInfo:nil];
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
NSString *errorName = [NSString stringWithCString:name encoding:NSUTF8StringEncoding];
|
|
||||||
if ([errorName isEqualToString:@"DeviceOSVersionTooLow"])
|
|
||||||
{
|
|
||||||
error = [NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorUnsupportediOSVersion userInfo:nil];
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
NSError *underlyingError = [NSError errorWithDomain:AltServerInstallationErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey: @(description)}];
|
|
||||||
error = [NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorInstallationFailed userInfo:@{NSUnderlyingErrorKey: underlyingError}];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
completionHandler(error);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
NSLog(@"Finished installing app!");
|
|
||||||
completionHandler(nil);
|
|
||||||
}
|
|
||||||
|
|
||||||
ALTDeviceManager.sharedManager.installationCompletionHandlers[UUID] = nil;
|
|
||||||
ALTDeviceManager.sharedManager.installationProgress[UUID] = nil;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (progress.completedUnitCount < percent)
|
|
||||||
{
|
|
||||||
progress.completedUnitCount = percent;
|
|
||||||
|
|
||||||
NSLog(@"Installation Progress: %@", @(percent));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,6 @@
|
|||||||
<Workspace
|
<Workspace
|
||||||
version = "1.0">
|
version = "1.0">
|
||||||
<FileRef
|
<FileRef
|
||||||
location = "self:AltStore.xcodeproj">
|
location = "self:">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
</Workspace>
|
</Workspace>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1020"
|
LastUpgradeVersion = "1610"
|
||||||
version = "1.3">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
@@ -14,9 +15,9 @@
|
|||||||
buildForAnalyzing = "YES">
|
buildForAnalyzing = "YES">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "BF45868C229872EA00BD7491"
|
BlueprintIdentifier = "BF58047A246A28F7008AE704"
|
||||||
BuildableName = "AltServer.app"
|
BuildableName = "AltBackup.app"
|
||||||
BlueprintName = "AltServer"
|
BlueprintName = "AltBackup"
|
||||||
ReferencedContainer = "container:AltStore.xcodeproj">
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
@@ -26,20 +27,8 @@
|
|||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
<Testables>
|
shouldAutocreateTestPlan = "YES">
|
||||||
</Testables>
|
|
||||||
<MacroExpansion>
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "BF45868C229872EA00BD7491"
|
|
||||||
BuildableName = "AltServer.app"
|
|
||||||
BlueprintName = "AltServer"
|
|
||||||
ReferencedContainer = "container:AltStore.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</MacroExpansion>
|
|
||||||
<AdditionalOptions>
|
|
||||||
</AdditionalOptions>
|
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
@@ -55,14 +44,42 @@
|
|||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "BF45868C229872EA00BD7491"
|
BlueprintIdentifier = "BF58047A246A28F7008AE704"
|
||||||
BuildableName = "AltServer.app"
|
BuildableName = "AltBackup.app"
|
||||||
BlueprintName = "AltServer"
|
BlueprintName = "AltBackup"
|
||||||
ReferencedContainer = "container:AltStore.xcodeproj">
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
<AdditionalOptions>
|
<CommandLineArguments>
|
||||||
</AdditionalOptions>
|
<CommandLineArgument
|
||||||
|
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
|
||||||
|
isEnabled = "NO">
|
||||||
|
</CommandLineArgument>
|
||||||
|
<CommandLineArgument
|
||||||
|
argument = "-com.apple.CoreData.MigrationDebug 1"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</CommandLineArgument>
|
||||||
|
<CommandLineArgument
|
||||||
|
argument = "-com.apple.CoreData.SQLiteIntegrityCheck 1"
|
||||||
|
isEnabled = "NO">
|
||||||
|
</CommandLineArgument>
|
||||||
|
<CommandLineArgument
|
||||||
|
argument = "-com.apple.CoreData.SQLDebug 1"
|
||||||
|
isEnabled = "NO">
|
||||||
|
</CommandLineArgument>
|
||||||
|
</CommandLineArguments>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "OS_ACTIVITY_MODE"
|
||||||
|
value = "$(DEBUG_ACTIVITY_MODE)"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "OBJC_DEBUG_DUPLICATE_CLASSES"
|
||||||
|
value = "$(DEBUG_DUPLICATE_CLASSES)"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
@@ -74,9 +91,9 @@
|
|||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "BF45868C229872EA00BD7491"
|
BlueprintIdentifier = "BF58047A246A28F7008AE704"
|
||||||
BuildableName = "AltServer.app"
|
BuildableName = "AltBackup.app"
|
||||||
BlueprintName = "AltServer"
|
BlueprintName = "AltBackup"
|
||||||
ReferencedContainer = "container:AltStore.xcodeproj">
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1620"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<TestPlans>
|
||||||
|
<TestPlanReference
|
||||||
|
reference = "container:SideStore/Tests/DataStructureTests.xctestplan"
|
||||||
|
default = "YES">
|
||||||
|
</TestPlanReference>
|
||||||
|
</TestPlans>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A81A8CC42D68BA610086C96F"
|
||||||
|
BuildableName = "DataStructureTests.xctest"
|
||||||
|
BlueprintName = "DataStructureTests"
|
||||||
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1020"
|
LastUpgradeVersion = "1020"
|
||||||
version = "1.3">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES">
|
||||||
@@ -15,34 +15,22 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "BFD247692284B9A500981D42"
|
BlueprintIdentifier = "BFD247692284B9A500981D42"
|
||||||
BuildableName = "AltStore.app"
|
BuildableName = "SideStore.app"
|
||||||
BlueprintName = "AltStore"
|
BlueprintName = "SideStore"
|
||||||
ReferencedContainer = "container:AltStore.xcodeproj">
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
</BuildActionEntries>
|
</BuildActionEntries>
|
||||||
</BuildAction>
|
</BuildAction>
|
||||||
<TestAction
|
<TestAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Release"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
<Testables>
|
shouldAutocreateTestPlan = "YES">
|
||||||
</Testables>
|
|
||||||
<MacroExpansion>
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "BFD247692284B9A500981D42"
|
|
||||||
BuildableName = "AltStore.app"
|
|
||||||
BlueprintName = "AltStore"
|
|
||||||
ReferencedContainer = "container:AltStore.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</MacroExpansion>
|
|
||||||
<AdditionalOptions>
|
|
||||||
</AdditionalOptions>
|
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Release"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
launchStyle = "0"
|
launchStyle = "0"
|
||||||
@@ -56,19 +44,41 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "BFD247692284B9A500981D42"
|
BlueprintIdentifier = "BFD247692284B9A500981D42"
|
||||||
BuildableName = "AltStore.app"
|
BuildableName = "SideStore.app"
|
||||||
BlueprintName = "AltStore"
|
BlueprintName = "SideStore"
|
||||||
ReferencedContainer = "container:AltStore.xcodeproj">
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
<CommandLineArguments>
|
<CommandLineArguments>
|
||||||
<CommandLineArgument
|
<CommandLineArgument
|
||||||
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
|
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
|
||||||
|
isEnabled = "NO">
|
||||||
|
</CommandLineArgument>
|
||||||
|
<CommandLineArgument
|
||||||
|
argument = "-com.apple.CoreData.MigrationDebug 1"
|
||||||
isEnabled = "YES">
|
isEnabled = "YES">
|
||||||
</CommandLineArgument>
|
</CommandLineArgument>
|
||||||
|
<CommandLineArgument
|
||||||
|
argument = "-com.apple.CoreData.SQLiteIntegrityCheck 1"
|
||||||
|
isEnabled = "NO">
|
||||||
|
</CommandLineArgument>
|
||||||
|
<CommandLineArgument
|
||||||
|
argument = "-com.apple.CoreData.SQLDebug 1"
|
||||||
|
isEnabled = "NO">
|
||||||
|
</CommandLineArgument>
|
||||||
</CommandLineArguments>
|
</CommandLineArguments>
|
||||||
<AdditionalOptions>
|
<EnvironmentVariables>
|
||||||
</AdditionalOptions>
|
<EnvironmentVariable
|
||||||
|
key = "OS_ACTIVITY_MODE"
|
||||||
|
value = "$(DEBUG_ACTIVITY_MODE)"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "OBJC_DEBUG_DUPLICATE_CLASSES"
|
||||||
|
value = "$(DEBUG_DUPLICATE_CLASSES)"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
@@ -81,14 +91,14 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "BFD247692284B9A500981D42"
|
BlueprintIdentifier = "BFD247692284B9A500981D42"
|
||||||
BuildableName = "AltStore.app"
|
BuildableName = "SideStore.app"
|
||||||
BlueprintName = "AltStore"
|
BlueprintName = "SideStore"
|
||||||
ReferencedContainer = "container:AltStore.xcodeproj">
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
</ProfileAction>
|
</ProfileAction>
|
||||||
<AnalyzeAction
|
<AnalyzeAction
|
||||||
buildConfiguration = "Debug">
|
buildConfiguration = "Release">
|
||||||
</AnalyzeAction>
|
</AnalyzeAction>
|
||||||
<ArchiveAction
|
<ArchiveAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
130
AltStore.xcodeproj/xcshareddata/xcschemes/SideStore.xcscheme
Normal file
130
AltStore.xcodeproj/xcshareddata/xcschemes/SideStore.xcscheme
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1610"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BFD247692284B9A500981D42"
|
||||||
|
BuildableName = "SideStore.app"
|
||||||
|
BlueprintName = "SideStore"
|
||||||
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<TestPlans>
|
||||||
|
<TestPlanReference
|
||||||
|
reference = "container:SideStore/Tests/SideStoreTests.xctestplan"
|
||||||
|
default = "YES">
|
||||||
|
</TestPlanReference>
|
||||||
|
</TestPlans>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A8E2DB202D684CBD009E5D31"
|
||||||
|
BuildableName = "UITests.xctest"
|
||||||
|
BlueprintName = "UITests"
|
||||||
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
<SelectedTests>
|
||||||
|
<Test
|
||||||
|
Identifier = "UITests/testExample()">
|
||||||
|
</Test>
|
||||||
|
</SelectedTests>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BFD247692284B9A500981D42"
|
||||||
|
BuildableName = "SideStore.app"
|
||||||
|
BlueprintName = "SideStore"
|
||||||
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
<CommandLineArgument
|
||||||
|
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
|
||||||
|
isEnabled = "NO">
|
||||||
|
</CommandLineArgument>
|
||||||
|
<CommandLineArgument
|
||||||
|
argument = "-com.apple.CoreData.MigrationDebug 1"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</CommandLineArgument>
|
||||||
|
<CommandLineArgument
|
||||||
|
argument = "-com.apple.CoreData.SQLiteIntegrityCheck 1"
|
||||||
|
isEnabled = "NO">
|
||||||
|
</CommandLineArgument>
|
||||||
|
<CommandLineArgument
|
||||||
|
argument = "-com.apple.CoreData.SQLDebug 1"
|
||||||
|
isEnabled = "NO">
|
||||||
|
</CommandLineArgument>
|
||||||
|
</CommandLineArguments>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "OS_ACTIVITY_MODE"
|
||||||
|
value = "$(DEBUG_ACTIVITY_MODE)"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "OBJC_DEBUG_DUPLICATE_CLASSES"
|
||||||
|
value = "$(DEBUG_DUPLICATE_CLASSES)"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BFD247692284B9A500981D42"
|
||||||
|
BuildableName = "SideStore.app"
|
||||||
|
BlueprintName = "SideStore"
|
||||||
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
4
AltStore.xcworkspace/contents.xcworkspacedata
generated
4
AltStore.xcworkspace/contents.xcworkspacedata
generated
@@ -2,10 +2,10 @@
|
|||||||
<Workspace
|
<Workspace
|
||||||
version = "1.0">
|
version = "1.0">
|
||||||
<FileRef
|
<FileRef
|
||||||
location = "container:AltStore.xcodeproj">
|
location = "group:AltStore.xcodeproj">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
<FileRef
|
<FileRef
|
||||||
location = "group:Dependencies/AltSign/AltSign.xcodeproj">
|
location = "group:SideStore/AltSign">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
<FileRef
|
<FileRef
|
||||||
location = "group:Dependencies/Roxas/Roxas.xcodeproj">
|
location = "group:Dependencies/Roxas/Roxas.xcodeproj">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Use this file to import your target's public headers that you would like to expose to Swift.
|
// Use this file to import your target's public headers that you would like to expose to Swift.
|
||||||
//
|
//
|
||||||
|
|
||||||
#import "NSError+ALTServerError.h"
|
#import "NSAttributedString+Markdown.h"
|
||||||
#import "ALTAppPermission.h"
|
#import "ALTAppPatcher.h"
|
||||||
#import "ALTPatreonBenefitType.h"
|
|
||||||
|
#include "fragmentzip.h"
|
||||||
|
|||||||
@@ -2,7 +2,27 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<!-- <key>com.apple.security.files.user-selected.read-write</key>
|
||||||
|
<array>
|
||||||
|
<string></string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.applesignin</key>
|
||||||
|
<array>
|
||||||
|
<string></string>
|
||||||
|
</array> -->
|
||||||
|
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.developer.kernel.increased-debugging-memory-limit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.developer.kernel.increased-memory-limit</key>
|
||||||
|
<true/>
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.siri</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.$(APP_GROUP_IDENTIFIER)</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
115
AltStore/Analytics/AnalyticsManager.swift
Normal file
115
AltStore/Analytics/AnalyticsManager.swift
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
//
|
||||||
|
// AnalyticsManager.swift
|
||||||
|
// AltStore
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 3/31/20.
|
||||||
|
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
import AltStoreCore
|
||||||
|
|
||||||
|
import AppCenter
|
||||||
|
import AppCenterAnalytics
|
||||||
|
import AppCenterCrashes
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
private let appCenterAppSecret = "73532d3e-e573-4693-99a4-9f85840bbb44"
|
||||||
|
#elseif RELEASE
|
||||||
|
private let appCenterAppSecret = "73532d3e-e573-4693-99a4-9f85840bbb44"
|
||||||
|
#else
|
||||||
|
private let appCenterAppSecret = "73532d3e-e573-4693-99a4-9f85840bbb44"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
extension AnalyticsManager
|
||||||
|
{
|
||||||
|
enum EventProperty: String
|
||||||
|
{
|
||||||
|
case name
|
||||||
|
case bundleIdentifier
|
||||||
|
case developerName
|
||||||
|
case version
|
||||||
|
case buildVersion
|
||||||
|
case size
|
||||||
|
case tintColor
|
||||||
|
case sourceIdentifier
|
||||||
|
case sourceURL
|
||||||
|
case patreonURL
|
||||||
|
case pledgeAmount
|
||||||
|
case pledgeCurrency
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Event
|
||||||
|
{
|
||||||
|
case installedApp(InstalledApp)
|
||||||
|
case updatedApp(InstalledApp)
|
||||||
|
case refreshedApp(InstalledApp)
|
||||||
|
|
||||||
|
var name: String {
|
||||||
|
switch self
|
||||||
|
{
|
||||||
|
case .installedApp: return "installed_app"
|
||||||
|
case .updatedApp: return "updated_app"
|
||||||
|
case .refreshedApp: return "refreshed_app"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var properties: [EventProperty: String] {
|
||||||
|
let properties: [EventProperty: String?]
|
||||||
|
|
||||||
|
switch self
|
||||||
|
{
|
||||||
|
case .installedApp(let app), .updatedApp(let app), .refreshedApp(let app):
|
||||||
|
let appBundleURL = InstalledApp.fileURL(for: app)
|
||||||
|
let appBundleSize = FileManager.default.directorySize(at: appBundleURL)
|
||||||
|
|
||||||
|
properties = [
|
||||||
|
.name: app.name,
|
||||||
|
.bundleIdentifier: app.bundleIdentifier,
|
||||||
|
.developerName: app.storeApp?.developerName,
|
||||||
|
.version: app.version,
|
||||||
|
.buildVersion: app.buildVersion,
|
||||||
|
.size: appBundleSize?.description,
|
||||||
|
.tintColor: app.storeApp?.tintColor?.hexString,
|
||||||
|
.sourceIdentifier: app.storeApp?.sourceIdentifier,
|
||||||
|
.sourceURL: app.storeApp?.source?.sourceURL.absoluteString,
|
||||||
|
.patreonURL: app.storeApp?.source?.patreonURL?.absoluteString,
|
||||||
|
.pledgeAmount: app.storeApp?.pledgeAmount?.description,
|
||||||
|
.pledgeCurrency: app.storeApp?.pledgeCurrency
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return properties.compactMapValues { $0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AnalyticsManager
|
||||||
|
{
|
||||||
|
static let shared = AnalyticsManager()
|
||||||
|
|
||||||
|
private init()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AnalyticsManager
|
||||||
|
{
|
||||||
|
func start()
|
||||||
|
{
|
||||||
|
AppCenter.start(withAppSecret: appCenterAppSecret, services: [
|
||||||
|
Analytics.self,
|
||||||
|
Crashes.self
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func trackEvent(_ event: Event)
|
||||||
|
{
|
||||||
|
let properties = event.properties.reduce(into: [:]) { (properties, item) in
|
||||||
|
properties[item.key.rawValue] = item.value
|
||||||
|
}
|
||||||
|
|
||||||
|
Analytics.trackEvent(event.name, withProperties: properties)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
import AltStoreCore
|
||||||
import Roxas
|
import Roxas
|
||||||
|
|
||||||
import Nuke
|
import Nuke
|
||||||
@@ -24,13 +25,11 @@ extension AppContentViewController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppContentViewController: UITableViewController
|
final class AppContentViewController: UITableViewController
|
||||||
{
|
{
|
||||||
var app: StoreApp!
|
var app: StoreApp!
|
||||||
|
|
||||||
private lazy var screenshotsDataSource = self.makeScreenshotsDataSource()
|
// private lazy var screenshotsDataSource = self.makeScreenshotsDataSource()
|
||||||
private lazy var permissionsDataSource = self.makePermissionsDataSource()
|
|
||||||
|
|
||||||
private lazy var dateFormatter: DateFormatter = {
|
private lazy var dateFormatter: DateFormatter = {
|
||||||
let dateFormatter = DateFormatter()
|
let dateFormatter = DateFormatter()
|
||||||
dateFormatter.dateStyle = .medium
|
dateFormatter.dateStyle = .medium
|
||||||
@@ -44,135 +43,113 @@ class AppContentViewController: UITableViewController
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
@IBOutlet private var subtitleLabel: UILabel!
|
@IBOutlet private var subtitleLabel: UILabel!
|
||||||
@IBOutlet private var descriptionTextView: CollapsingTextView!
|
// @IBOutlet private var descriptionTextView: CollapsingTextView!
|
||||||
@IBOutlet private var versionDescriptionTextView: CollapsingTextView!
|
@IBOutlet private var descriptionTextView: CollapsingMarkdownView!
|
||||||
|
// @IBOutlet private var versionDescriptionTextView: CollapsingTextView!
|
||||||
|
@IBOutlet private var versionDescriptionTextView: CollapsingMarkdownView!
|
||||||
@IBOutlet private var versionLabel: UILabel!
|
@IBOutlet private var versionLabel: UILabel!
|
||||||
@IBOutlet private var versionDateLabel: UILabel!
|
@IBOutlet private var versionDateLabel: UILabel!
|
||||||
@IBOutlet private var sizeLabel: UILabel!
|
@IBOutlet private var sizeLabel: UILabel!
|
||||||
|
|
||||||
@IBOutlet private var screenshotsCollectionView: UICollectionView!
|
@IBOutlet private(set) var appScreenshotsViewController: AppScreenshotsViewController!
|
||||||
@IBOutlet private var permissionsCollectionView: UICollectionView!
|
@IBOutlet private var appScreenshotsHeightConstraint: NSLayoutConstraint!
|
||||||
|
|
||||||
var preferredScreenshotSize: CGSize? {
|
@IBOutlet private(set) var appDetailCollectionViewController: AppDetailCollectionViewController!
|
||||||
let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
@IBOutlet private var appDetailCollectionViewHeightConstraint: NSLayoutConstraint!
|
||||||
|
|
||||||
let aspectRatio: CGFloat = 16.0 / 9.0 // Hardcoded for now.
|
|
||||||
|
|
||||||
let width = self.screenshotsCollectionView.bounds.width - (layout.minimumInteritemSpacing * 2)
|
|
||||||
|
|
||||||
let itemWidth = width / 1.5
|
|
||||||
let itemHeight = itemWidth * aspectRatio
|
|
||||||
|
|
||||||
return CGSize(width: itemWidth, height: itemHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad()
|
override func viewDidLoad() {
|
||||||
{
|
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
self.tableView.contentInset.bottom = 20
|
self.tableView.contentInset.bottom = 20
|
||||||
|
|
||||||
self.screenshotsCollectionView.dataSource = self.screenshotsDataSource
|
|
||||||
self.permissionsCollectionView.dataSource = self.permissionsDataSource
|
|
||||||
|
|
||||||
self.subtitleLabel.text = self.app.subtitle
|
self.subtitleLabel.text = self.app.subtitle
|
||||||
self.descriptionTextView.text = self.app.localizedDescription
|
let desc = self.app.localizedDescription
|
||||||
self.versionDescriptionTextView.text = self.app.versionDescription
|
self.descriptionTextView.text = desc
|
||||||
self.versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), self.app.version)
|
|
||||||
self.versionDateLabel.text = Date().relativeDateString(since: self.app.versionDate, dateFormatter: self.dateFormatter)
|
if let version = self.app.latestAvailableVersion {
|
||||||
self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: Int64(self.app.size))
|
self.versionDescriptionTextView.text = version.localizedDescription ?? "nil"
|
||||||
|
self.versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), version.localizedVersion)
|
||||||
|
self.versionDateLabel.text = Date().relativeDateString(since: version.date)
|
||||||
|
self.sizeLabel.text = ByteCountFormatter.string(fromByteCount: version.size, countStyle: .file)
|
||||||
|
} else {
|
||||||
|
self.versionDescriptionTextView.text = "nil"
|
||||||
|
self.versionLabel.text = nil
|
||||||
|
self.versionDateLabel.text = nil
|
||||||
|
self.sizeLabel.text = ByteCountFormatter.string(fromByteCount: 0, countStyle: .file)
|
||||||
|
}
|
||||||
|
|
||||||
self.descriptionTextView.maximumNumberOfLines = 5
|
self.descriptionTextView.maximumNumberOfLines = 5
|
||||||
self.descriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
|
self.versionDescriptionTextView.maximumNumberOfLines = 5
|
||||||
|
|
||||||
self.versionDescriptionTextView.maximumNumberOfLines = 3
|
self.descriptionTextView.toggleButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
|
||||||
self.versionDescriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
|
self.versionDescriptionTextView.toggleButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLayoutSubviews()
|
override func viewDidLayoutSubviews()
|
||||||
{
|
{
|
||||||
super.viewDidLayoutSubviews()
|
super.viewDidLayoutSubviews()
|
||||||
|
|
||||||
guard var size = self.preferredScreenshotSize else { return }
|
var needsTableViewUpdate = false
|
||||||
size.height = min(size.height, self.screenshotsCollectionView.bounds.height) // Silence temporary "item too tall" warning.
|
|
||||||
|
|
||||||
let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
let screenshotsHeight = self.appScreenshotsViewController.collectionView.contentSize.height
|
||||||
layout.itemSize = size
|
if self.appScreenshotsHeightConstraint.constant != screenshotsHeight && screenshotsHeight > 0
|
||||||
}
|
{
|
||||||
|
self.appScreenshotsHeightConstraint.constant = screenshotsHeight
|
||||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?)
|
needsTableViewUpdate = true
|
||||||
{
|
}
|
||||||
guard segue.identifier == "showPermission" else { return }
|
|
||||||
|
|
||||||
guard let cell = sender as? UICollectionViewCell, let indexPath = self.permissionsCollectionView.indexPath(for: cell) else { return }
|
let permissionsHeight = self.appDetailCollectionViewController.collectionView.contentSize.height
|
||||||
|
if self.appDetailCollectionViewHeightConstraint.constant != permissionsHeight && permissionsHeight > 0
|
||||||
|
{
|
||||||
|
self.appDetailCollectionViewHeightConstraint.constant = permissionsHeight
|
||||||
|
needsTableViewUpdate = true
|
||||||
|
}
|
||||||
|
|
||||||
let permission = self.permissionsDataSource.item(at: indexPath)
|
if needsTableViewUpdate
|
||||||
|
{
|
||||||
let maximumWidth = self.view.bounds.width - 20
|
UIView.performWithoutAnimation {
|
||||||
|
// Update row height without animation.
|
||||||
let permissionPopoverViewController = segue.destination as! PermissionPopoverViewController
|
self.tableView.beginUpdates()
|
||||||
permissionPopoverViewController.permission = permission
|
self.tableView.endUpdates()
|
||||||
permissionPopoverViewController.view.widthAnchor.constraint(lessThanOrEqualToConstant: maximumWidth).isActive = true
|
}
|
||||||
|
}
|
||||||
let size = permissionPopoverViewController.view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
|
||||||
permissionPopoverViewController.preferredContentSize = size
|
|
||||||
|
|
||||||
permissionPopoverViewController.popoverPresentationController?.delegate = self
|
|
||||||
permissionPopoverViewController.popoverPresentationController?.sourceRect = cell.frame
|
|
||||||
permissionPopoverViewController.popoverPresentationController?.sourceView = self.permissionsCollectionView
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension AppContentViewController
|
private extension AppContentViewController
|
||||||
{
|
{
|
||||||
func makeScreenshotsDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>
|
@IBSegueAction
|
||||||
|
func makeAppScreenshotsViewController(_ coder: NSCoder, sender: Any?) -> UIViewController?
|
||||||
{
|
{
|
||||||
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>(items: self.app.screenshotURLs as [NSURL])
|
let appScreenshotsViewController = AppScreenshotsViewController(app: self.app, coder: coder)
|
||||||
dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in
|
self.appScreenshotsViewController = appScreenshotsViewController
|
||||||
let cell = cell as! ScreenshotCollectionViewCell
|
return appScreenshotsViewController
|
||||||
cell.imageView.image = nil
|
}
|
||||||
cell.imageView.isIndicatingActivity = true
|
|
||||||
}
|
func makePermissionsDataSource() -> RSTArrayCollectionViewDataSource<AppPermission>
|
||||||
dataSource.prefetchHandler = { (imageURL, indexPath, completionHandler) in
|
{
|
||||||
return RSTAsyncBlockOperation() { (operation) in
|
let dataSource = RSTArrayCollectionViewDataSource(items: Array(self.app.permissions))
|
||||||
ImagePipeline.shared.loadImage(with: imageURL as URL, progress: nil, completion: { (response, error) in
|
dataSource.cellConfigurationHandler = { (cell, permission, indexPath) in
|
||||||
guard !operation.isCancelled else { return operation.finish() }
|
let cell = cell as! PermissionCollectionViewCell
|
||||||
|
// cell.button.setImage(permission.type.icon, for: .normal)
|
||||||
if let image = response?.image
|
// cell.button.tintColor = .label
|
||||||
{
|
// cell.textLabel.text = permission.type.localizedShortName ?? permission.type.localizedName
|
||||||
completionHandler(image, nil)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
completionHandler(nil, error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
|
|
||||||
let cell = cell as! ScreenshotCollectionViewCell
|
|
||||||
cell.imageView.isIndicatingActivity = false
|
|
||||||
cell.imageView.image = image
|
|
||||||
|
|
||||||
if let error = error
|
let icon = UIImage(systemName: permission.symbolName ?? "lock")
|
||||||
{
|
cell.button.setImage(icon, for: .normal)
|
||||||
print("Error loading image:", error)
|
|
||||||
}
|
cell.textLabel.text = permission.localizedDisplayName
|
||||||
}
|
}
|
||||||
|
|
||||||
return dataSource
|
return dataSource
|
||||||
}
|
}
|
||||||
|
|
||||||
func makePermissionsDataSource() -> RSTArrayCollectionViewDataSource<AppPermission>
|
@IBSegueAction
|
||||||
{
|
func makeAppDetailCollectionViewController(_ coder: NSCoder, sender: Any?) -> UIViewController?
|
||||||
let dataSource = RSTArrayCollectionViewDataSource(items: self.app.permissions)
|
{
|
||||||
dataSource.cellConfigurationHandler = { (cell, permission, indexPath) in
|
let appDetailViewController = AppDetailCollectionViewController(app: self.app, coder: coder)
|
||||||
let cell = cell as! PermissionCollectionViewCell
|
self.appDetailCollectionViewController = appDetailViewController
|
||||||
cell.button.setImage(permission.type.icon, for: .normal)
|
return appDetailViewController
|
||||||
cell.textLabel.text = permission.type.localizedShortName
|
|
||||||
}
|
|
||||||
|
|
||||||
return dataSource
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,8 +161,12 @@ private extension AppContentViewController
|
|||||||
|
|
||||||
switch sender
|
switch sender
|
||||||
{
|
{
|
||||||
case self.descriptionTextView.moreButton: indexPath = IndexPath(row: Row.description.rawValue, section: 0)
|
case self.descriptionTextView.toggleButton:
|
||||||
case self.versionDescriptionTextView.moreButton: indexPath = IndexPath(row: Row.versionDescription.rawValue, section: 0)
|
indexPath = IndexPath(row: Row.description.rawValue, section: 0)
|
||||||
|
|
||||||
|
case self.versionDescriptionTextView.toggleButton:
|
||||||
|
indexPath = IndexPath(row: Row.versionDescription.rawValue, section: 0)
|
||||||
|
|
||||||
default: return
|
default: return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,17 +186,18 @@ extension AppContentViewController
|
|||||||
|
|
||||||
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat
|
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat
|
||||||
{
|
{
|
||||||
guard indexPath.row == Row.screenshots.rawValue else { return super.tableView(tableView, heightForRowAt: indexPath) }
|
switch Row.allCases[indexPath.row]
|
||||||
|
{
|
||||||
guard let size = self.preferredScreenshotSize else { return 0.0 }
|
case .screenshots:
|
||||||
return size.height
|
guard !self.app.allScreenshots.isEmpty else { return 0.0 }
|
||||||
}
|
return UITableView.automaticDimension
|
||||||
}
|
|
||||||
|
case .permissions:
|
||||||
extension AppContentViewController: UIPopoverPresentationControllerDelegate
|
guard !self.app.permissions.isEmpty else { return 0.0 }
|
||||||
{
|
return UITableView.automaticDimension
|
||||||
func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle
|
|
||||||
{
|
default:
|
||||||
return .none
|
return super.tableView(tableView, heightForRowAt: indexPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class PermissionCollectionViewCell: UICollectionViewCell
|
final class PermissionCollectionViewCell: UICollectionViewCell
|
||||||
{
|
{
|
||||||
@IBOutlet var button: UIButton!
|
@IBOutlet var button: UIButton!
|
||||||
@IBOutlet var textLabel: UILabel!
|
@IBOutlet var textLabel: UILabel!
|
||||||
@@ -29,7 +29,7 @@ class PermissionCollectionViewCell: UICollectionViewCell
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppContentTableViewCell: UITableViewCell
|
final class AppContentTableViewCell: UITableViewCell
|
||||||
{
|
{
|
||||||
override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize
|
override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize
|
||||||
{
|
{
|
||||||
|
|||||||
300
AltStore/App Detail/AppDetailCollectionViewController.swift
Normal file
300
AltStore/App Detail/AppDetailCollectionViewController.swift
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
//
|
||||||
|
// AppDetailCollectionViewController.swift
|
||||||
|
// AltStore
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 5/5/23.
|
||||||
|
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
import AltStoreCore
|
||||||
|
import Roxas
|
||||||
|
|
||||||
|
extension AppDetailCollectionViewController
|
||||||
|
{
|
||||||
|
private enum Section: Int
|
||||||
|
{
|
||||||
|
case privacy
|
||||||
|
case knownEntitlements
|
||||||
|
case unknownEntitlements
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ElementKind: String
|
||||||
|
{
|
||||||
|
case title
|
||||||
|
case button
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc(SafeAreaIgnoringCollectionView)
|
||||||
|
private class SafeAreaIgnoringCollectionView: UICollectionView
|
||||||
|
{
|
||||||
|
override var safeAreaInsets: UIEdgeInsets {
|
||||||
|
get {
|
||||||
|
// Fixes incorrect layout if collection view height is taller than safe area height.
|
||||||
|
return .zero
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
// There MUST be a setter for this to work, even if it does nothing ¯\_(ツ)_/¯
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppDetailCollectionViewController: UICollectionViewController
|
||||||
|
{
|
||||||
|
let app: StoreApp
|
||||||
|
private let privacyPermissions: [AppPermission]
|
||||||
|
private let knownEntitlementPermissions: [AppPermission]
|
||||||
|
private let unknownEntitlementPermissions: [AppPermission]
|
||||||
|
|
||||||
|
private lazy var dataSource = self.makeDataSource()
|
||||||
|
private lazy var privacyDataSource = self.makePrivacyDataSource()
|
||||||
|
private lazy var entitlementsDataSource = self.makeEntitlementsDataSource()
|
||||||
|
|
||||||
|
private var headerRegistration: UICollectionView.SupplementaryRegistration<UICollectionViewListCell>!
|
||||||
|
|
||||||
|
override var collectionViewLayout: UICollectionViewCompositionalLayout {
|
||||||
|
return self.collectionView.collectionViewLayout as! UICollectionViewCompositionalLayout
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(app: StoreApp, coder: NSCoder)
|
||||||
|
{
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
let comparator: (AppPermission, AppPermission) -> Bool = { (permissionA, permissionB) -> Bool in
|
||||||
|
switch (permissionA.localizedName, permissionB.localizedName)
|
||||||
|
{
|
||||||
|
case (let nameA?, let nameB?):
|
||||||
|
// Sort by localizedName, if both have one.
|
||||||
|
return nameA.localizedStandardCompare(nameB) == .orderedAscending
|
||||||
|
|
||||||
|
case (nil, nil):
|
||||||
|
// Sort by raw permission value as fallback.
|
||||||
|
return permissionA.permission.rawValue < permissionB.permission.rawValue
|
||||||
|
|
||||||
|
// Sort "known" permissions before "unknown" ones.
|
||||||
|
case (_?, nil): return true
|
||||||
|
case (nil, _?): return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.privacyPermissions = app.permissions.filter { $0.type == .privacy }.sorted(by: comparator)
|
||||||
|
|
||||||
|
let entitlementPermissions = app.permissions.lazy.filter { $0.type == .entitlement }
|
||||||
|
self.knownEntitlementPermissions = entitlementPermissions.filter { $0.isKnown }.sorted(by: comparator)
|
||||||
|
self.unknownEntitlementPermissions = entitlementPermissions.filter { !$0.isKnown }.sorted(by: comparator)
|
||||||
|
|
||||||
|
super.init(coder: coder)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad()
|
||||||
|
{
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
// Allow parent background color to show through.
|
||||||
|
self.collectionView.backgroundColor = nil
|
||||||
|
|
||||||
|
// Match the parent table view margins.
|
||||||
|
self.collectionView.directionalLayoutMargins.leading = 20
|
||||||
|
self.collectionView.directionalLayoutMargins.trailing = 20
|
||||||
|
|
||||||
|
let collectionViewLayout = self.makeLayout()
|
||||||
|
self.collectionView.collectionViewLayout = collectionViewLayout
|
||||||
|
|
||||||
|
self.collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "PrivacyCell")
|
||||||
|
self.collectionView.register(UICollectionViewListCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||||
|
|
||||||
|
self.headerRegistration = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { [weak self] (headerView, elementKind, indexPath) in
|
||||||
|
var configuration = UIListContentConfiguration.plainHeader()
|
||||||
|
|
||||||
|
// Match parent table view section headers.
|
||||||
|
configuration.textProperties.font = UIFont.systemFont(ofSize: 22, weight: .bold) // .boldSystemFont(ofSize:) returns *semi-bold* color smh.
|
||||||
|
configuration.textProperties.color = .label
|
||||||
|
|
||||||
|
switch Section(rawValue: indexPath.section)!
|
||||||
|
{
|
||||||
|
case .privacy: break
|
||||||
|
case .knownEntitlements:
|
||||||
|
configuration.text = nil
|
||||||
|
|
||||||
|
configuration.secondaryTextProperties.font = UIFont.preferredFont(forTextStyle: .callout)
|
||||||
|
configuration.textToSecondaryTextVerticalPadding = 8
|
||||||
|
configuration.secondaryText = NSLocalizedString("Entitlements are additional permissions that grant access to certain system services, including potentially sensitive information.", comment: "")
|
||||||
|
|
||||||
|
case .unknownEntitlements:
|
||||||
|
configuration.text = NSLocalizedString("Other Entitlements", comment: "")
|
||||||
|
|
||||||
|
let action = UIAction(image: UIImage(systemName: "questionmark.circle")) { _ in
|
||||||
|
self?.showUnknownEntitlementsAlert()
|
||||||
|
}
|
||||||
|
|
||||||
|
let helpButton = UIButton(primaryAction: action)
|
||||||
|
let customAccessory = UICellAccessory.customView(configuration: .init(customView: helpButton, placement: .trailing(), tintColor: self?.app.tintColor ?? .altPrimary))
|
||||||
|
headerView.accessories = [customAccessory]
|
||||||
|
}
|
||||||
|
|
||||||
|
headerView.contentConfiguration = configuration
|
||||||
|
headerView.backgroundConfiguration = UIBackgroundConfiguration.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.dataSource.proxy = self
|
||||||
|
self.collectionView.dataSource = self.dataSource
|
||||||
|
self.collectionView.delegate = self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension AppDetailCollectionViewController
|
||||||
|
{
|
||||||
|
func makeLayout() -> UICollectionViewCompositionalLayout
|
||||||
|
{
|
||||||
|
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
|
||||||
|
layoutConfig.contentInsetsReference = .layoutMargins
|
||||||
|
|
||||||
|
let layout = UICollectionViewCompositionalLayout(sectionProvider: { [privacyPermissions, knownEntitlementPermissions, unknownEntitlementPermissions] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
|
||||||
|
guard let section = Section(rawValue: sectionIndex) else { return nil }
|
||||||
|
switch section
|
||||||
|
{
|
||||||
|
case .privacy:
|
||||||
|
guard !privacyPermissions.isEmpty, #available(iOS 16, *) else { return nil } // Hide section pre-iOS 16.
|
||||||
|
|
||||||
|
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(50)) // Underestimate height to prevent jumping size abruptly.
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||||
|
|
||||||
|
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(50))
|
||||||
|
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
|
||||||
|
|
||||||
|
let layoutSection = NSCollectionLayoutSection(group: group)
|
||||||
|
layoutSection.interGroupSpacing = 10
|
||||||
|
return layoutSection
|
||||||
|
|
||||||
|
case .knownEntitlements where !knownEntitlementPermissions.isEmpty: fallthrough
|
||||||
|
case .unknownEntitlements where !unknownEntitlementPermissions.isEmpty:
|
||||||
|
var configuration = UICollectionLayoutListConfiguration(appearance: .plain)
|
||||||
|
configuration.headerMode = .supplementary
|
||||||
|
configuration.showsSeparators = false
|
||||||
|
configuration.backgroundColor = .altBackground
|
||||||
|
|
||||||
|
let layoutSection = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: layoutEnvironment)
|
||||||
|
layoutSection.contentInsets.top = 4
|
||||||
|
return layoutSection
|
||||||
|
|
||||||
|
case .knownEntitlements, .unknownEntitlements: return nil
|
||||||
|
}
|
||||||
|
}, configuration: layoutConfig)
|
||||||
|
|
||||||
|
return layout
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeDataSource() -> RSTCompositeCollectionViewDataSource<AppPermission>
|
||||||
|
{
|
||||||
|
let dataSource = RSTCompositeCollectionViewDataSource(dataSources: [self.privacyDataSource, self.entitlementsDataSource])
|
||||||
|
return dataSource
|
||||||
|
}
|
||||||
|
|
||||||
|
func makePrivacyDataSource() -> RSTDynamicCollectionViewDataSource<AppPermission>
|
||||||
|
{
|
||||||
|
let dataSource = RSTDynamicCollectionViewDataSource<AppPermission>()
|
||||||
|
dataSource.cellIdentifierHandler = { _ in "PrivacyCell" }
|
||||||
|
dataSource.numberOfSectionsHandler = { 1 }
|
||||||
|
dataSource.cellConfigurationHandler = { [weak self] (cell, _, indexPath) in
|
||||||
|
guard let self, #available(iOS 16, *) else { return }
|
||||||
|
|
||||||
|
cell.contentConfiguration = UIHostingConfiguration {
|
||||||
|
AppPermissionsCard(title: "Privacy",
|
||||||
|
description: "\(self.app.name) may request access to the following:",
|
||||||
|
tintColor: Color(uiColor: self.app.tintColor ?? .altPrimary),
|
||||||
|
permissions: self.privacyPermissions)
|
||||||
|
}
|
||||||
|
.margins(.horizontal, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 16, *)
|
||||||
|
{
|
||||||
|
dataSource.numberOfItemsHandler = { [privacyPermissions] _ in !privacyPermissions.isEmpty ? 1 : 0 }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
dataSource.numberOfItemsHandler = { _ in 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataSource
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeEntitlementsDataSource() -> RSTCompositeCollectionViewDataSource<AppPermission>
|
||||||
|
{
|
||||||
|
let knownEntitlementsDataSource = RSTArrayCollectionViewDataSource(items: self.knownEntitlementPermissions)
|
||||||
|
let unknownEntitlementsDataSource = RSTArrayCollectionViewDataSource(items: self.unknownEntitlementPermissions)
|
||||||
|
|
||||||
|
let dataSource = RSTCompositeCollectionViewDataSource(dataSources: [knownEntitlementsDataSource, unknownEntitlementsDataSource])
|
||||||
|
dataSource.cellConfigurationHandler = { [weak self] (cell, appPermission, _) in
|
||||||
|
let cell = cell as! UICollectionViewListCell
|
||||||
|
let tintColor = self?.app.tintColor ?? .altPrimary
|
||||||
|
|
||||||
|
var content = cell.defaultContentConfiguration()
|
||||||
|
content.text = appPermission.localizedDisplayName
|
||||||
|
content.secondaryText = appPermission.permission.rawValue
|
||||||
|
content.secondaryTextProperties.color = .secondaryLabel
|
||||||
|
|
||||||
|
if appPermission.isKnown
|
||||||
|
{
|
||||||
|
content.image = UIImage(systemName: appPermission.effectiveSymbolName)
|
||||||
|
content.imageProperties.tintColor = tintColor
|
||||||
|
|
||||||
|
if #available(iOS 15.4, *) /*, let self */ // Capturing self leads to strong-reference cycle.
|
||||||
|
{
|
||||||
|
let detailAccessory = UICellAccessory.detail(options: .init(tintColor: tintColor)) {
|
||||||
|
self?.showPermissionAlert(for: appPermission)
|
||||||
|
}
|
||||||
|
cell.accessories = [detailAccessory]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cell.contentConfiguration = content
|
||||||
|
cell.backgroundConfiguration = UIBackgroundConfiguration.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension AppDetailCollectionViewController
|
||||||
|
{
|
||||||
|
func showPermissionAlert(for permission: AppPermission)
|
||||||
|
{
|
||||||
|
let alertController = UIAlertController(title: permission.localizedDisplayName, message: permission.localizedDescription, preferredStyle: .alert)
|
||||||
|
alertController.addAction(.ok)
|
||||||
|
self.present(alertController, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func showUnknownEntitlementsAlert()
|
||||||
|
{
|
||||||
|
let alertController = UIAlertController(title: NSLocalizedString("Other Entitlements", comment: ""), message: NSLocalizedString("SideStore does not have detailed information for these entitlements.", comment: ""), preferredStyle: .alert)
|
||||||
|
alertController.addAction(.ok)
|
||||||
|
self.present(alertController, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppDetailCollectionViewController
|
||||||
|
{
|
||||||
|
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
|
||||||
|
{
|
||||||
|
let headerView = self.collectionView.dequeueConfiguredReusableSupplementary(using: self.headerRegistration, for: indexPath)
|
||||||
|
return headerView
|
||||||
|
}
|
||||||
|
|
||||||
|
override func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool
|
||||||
|
{
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool
|
||||||
|
{
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
276
AltStore/App Detail/AppPermissionsCard.swift
Normal file
276
AltStore/App Detail/AppPermissionsCard.swift
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
//
|
||||||
|
// AppPermissionsCard.swift
|
||||||
|
// AltStore
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 5/4/23.
|
||||||
|
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
import AltStoreCore
|
||||||
|
|
||||||
|
@available(iOS 16, *)
|
||||||
|
extension AppPermissionsCard
|
||||||
|
{
|
||||||
|
private struct TransitionKey: Hashable
|
||||||
|
{
|
||||||
|
static func name(_ permission: Permission) -> TransitionKey {
|
||||||
|
TransitionKey(key: "name", permission: permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func icon(_ permission: Permission) -> TransitionKey {
|
||||||
|
TransitionKey(key: "icon", permission: permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
let key: String
|
||||||
|
let permission: Permission
|
||||||
|
|
||||||
|
private init(key: String, permission: Permission)
|
||||||
|
{
|
||||||
|
self.key = key
|
||||||
|
self.permission = permission
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16, *)
|
||||||
|
struct AppPermissionsCard<Permission: AppPermissionProtocol>: View
|
||||||
|
{
|
||||||
|
let title: LocalizedStringKey
|
||||||
|
let description: LocalizedStringKey
|
||||||
|
let tintColor: Color
|
||||||
|
|
||||||
|
let permissions: [Permission]
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var selectedPermission: Permission?
|
||||||
|
|
||||||
|
@Namespace
|
||||||
|
private var animation
|
||||||
|
|
||||||
|
private var isTitleVisible: Bool {
|
||||||
|
if selectedPermission == nil
|
||||||
|
{
|
||||||
|
// Title should always be visible when showing all permissions.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If showing permission details, only show title if there
|
||||||
|
// are more than 2 permissions total to save vertical space.
|
||||||
|
let isTitleVisible = permissions.count > 2
|
||||||
|
return isTitleVisible
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let title = Text(title)
|
||||||
|
.font(.title3)
|
||||||
|
.bold()
|
||||||
|
.minimumScaleFactor(0.1) // Avoid clipping during matchedGeometryEffect animation.
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if isTitleVisible
|
||||||
|
{
|
||||||
|
// If title is visible, place _outside_ `content`
|
||||||
|
// to avoid being covered by permissionDetailView.
|
||||||
|
title
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = VStack(spacing: 8) {
|
||||||
|
if !isTitleVisible
|
||||||
|
{
|
||||||
|
// Place title inside `content` when not visible
|
||||||
|
// so it's covered by permissionDetailView.
|
||||||
|
title
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Text(description)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
Grid(verticalSpacing: 15) {
|
||||||
|
ForEach(permissions, id: \.self) { permission in
|
||||||
|
permissionRow(for: permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Tap a permission to learn more.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let selectedPermission
|
||||||
|
{
|
||||||
|
// Hide content with overlay to preserve existing size.
|
||||||
|
content.hidden().overlay {
|
||||||
|
permissionDetailView(for: selectedPermission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .topTrailing) {
|
||||||
|
if selectedPermission != nil
|
||||||
|
{
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.imageScale(.medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(20)
|
||||||
|
.overlay {
|
||||||
|
if selectedPermission != nil
|
||||||
|
{
|
||||||
|
// Make entire view tappable when overlay is visible.
|
||||||
|
SwiftUI.Button(action: hidePermission) {
|
||||||
|
VStack {}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundColor(.secondary) // Vibrancy
|
||||||
|
.background(.regularMaterial) // Blur background for auto-legibility correction.
|
||||||
|
.background(tintColor, in: RoundedRectangle(cornerRadius: 30, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func permissionRow(for permission: Permission) -> some View
|
||||||
|
{
|
||||||
|
GridRow {
|
||||||
|
SwiftUI.Button(action: { show(permission) }) {
|
||||||
|
HStack {
|
||||||
|
let text = Text(permission.localizedDisplayName)
|
||||||
|
.font(.body)
|
||||||
|
.bold()
|
||||||
|
.minimumScaleFactor(0.33)
|
||||||
|
.lineLimit(.max) // Setting lineLimit to anything fixes text wrapping at large text sizes.
|
||||||
|
|
||||||
|
let image = Image(systemName: permission.effectiveSymbolName)
|
||||||
|
.gridColumnAlignment(.center)
|
||||||
|
|
||||||
|
if selectedPermission != nil
|
||||||
|
{
|
||||||
|
Label(title: { text }, icon: { image })
|
||||||
|
.hidden()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Label {
|
||||||
|
text.matchedGeometryEffect(id: TransitionKey.name(permission), in: animation)
|
||||||
|
} icon: {
|
||||||
|
image.matchedGeometryEffect(id: TransitionKey.icon(permission), in: animation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.imageScale(.large)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle()) // Make entire HStack tappable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minHeight: 30) // Make row tall enough to tap.
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func permissionDetailView(for permission: Permission) -> some View
|
||||||
|
{
|
||||||
|
VStack(spacing: 15) {
|
||||||
|
Image(systemName: permission.effectiveSymbolName)
|
||||||
|
.font(.largeTitle)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.matchedGeometryEffect(id: TransitionKey.icon(permission), in: animation)
|
||||||
|
|
||||||
|
Text(permission.localizedDisplayName)
|
||||||
|
.font(.title2)
|
||||||
|
.bold()
|
||||||
|
.minimumScaleFactor(0.1) // Avoid clipping during matchedGeometryEffect animation.
|
||||||
|
.matchedGeometryEffect(id: TransitionKey.name(permission), in: animation)
|
||||||
|
|
||||||
|
if let usageDescription = permission.usageDescription
|
||||||
|
{
|
||||||
|
Text(usageDescription)
|
||||||
|
.font(.subheadline)
|
||||||
|
.minimumScaleFactor(0.75)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(title: LocalizedStringKey, description: LocalizedStringKey, tintColor: Color, permissions: [Permission])
|
||||||
|
{
|
||||||
|
self.init(title: title, description: description, tintColor: tintColor, permissions: permissions, selectedPermission: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate init(title: LocalizedStringKey, description: LocalizedStringKey, tintColor: Color, permissions: [Permission], selectedPermission: Permission? = nil)
|
||||||
|
{
|
||||||
|
self.title = title
|
||||||
|
self.description = description
|
||||||
|
self.tintColor = tintColor
|
||||||
|
self.permissions = permissions
|
||||||
|
|
||||||
|
// Set _selectedPermission directly or else the preview won't detect it.
|
||||||
|
self._selectedPermission = State(initialValue: selectedPermission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16, *)
|
||||||
|
private extension AppPermissionsCard
|
||||||
|
{
|
||||||
|
func show(_ permission: Permission)
|
||||||
|
{
|
||||||
|
withAnimation {
|
||||||
|
self.selectedPermission = permission
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hidePermission()
|
||||||
|
{
|
||||||
|
withAnimation {
|
||||||
|
self.selectedPermission = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16, *)
|
||||||
|
struct AppPermissionsCard_Previews: PreviewProvider
|
||||||
|
{
|
||||||
|
static var previews: some View {
|
||||||
|
let appPermissions = [
|
||||||
|
PreviewAppPermission(permission: ALTAppPrivacyPermission.localNetwork),
|
||||||
|
PreviewAppPermission(permission: ALTAppPrivacyPermission.microphone),
|
||||||
|
PreviewAppPermission(permission: ALTAppPrivacyPermission.photos),
|
||||||
|
PreviewAppPermission(permission: ALTAppPrivacyPermission.camera),
|
||||||
|
PreviewAppPermission(permission: ALTAppPrivacyPermission.faceID),
|
||||||
|
PreviewAppPermission(permission: ALTAppPrivacyPermission.appleMusic),
|
||||||
|
PreviewAppPermission(permission: ALTAppPrivacyPermission.bluetooth),
|
||||||
|
PreviewAppPermission(permission: ALTAppPrivacyPermission.calendars),
|
||||||
|
]
|
||||||
|
|
||||||
|
let tintColor = Color(uiColor: .deltaPrimary!)
|
||||||
|
|
||||||
|
return ForEach(1...8, id: \.self) { index in
|
||||||
|
AppPermissionsCard(title: "Privacy",
|
||||||
|
description: "Delta may request access to the following:",
|
||||||
|
tintColor: tintColor,
|
||||||
|
permissions: Array(appPermissions.prefix(index)))
|
||||||
|
.frame(width: 350)
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
|
||||||
|
AppPermissionsCard(title: "Privacy",
|
||||||
|
description: "Delta may request access to the following:",
|
||||||
|
tintColor: tintColor,
|
||||||
|
permissions: Array(appPermissions.prefix(index)),
|
||||||
|
selectedPermission: appPermissions.first)
|
||||||
|
.frame(width: 350)
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,12 @@
|
|||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
import AltStoreCore
|
||||||
import Roxas
|
import Roxas
|
||||||
|
|
||||||
import Nuke
|
import Nuke
|
||||||
|
|
||||||
class AppViewController: UIViewController
|
final class AppViewController: UIViewController
|
||||||
{
|
{
|
||||||
var app: StoreApp!
|
var app: StoreApp!
|
||||||
|
|
||||||
@@ -27,18 +28,11 @@ class AppViewController: UIViewController
|
|||||||
@IBOutlet private var scrollView: UIScrollView!
|
@IBOutlet private var scrollView: UIScrollView!
|
||||||
@IBOutlet private var contentView: UIView!
|
@IBOutlet private var contentView: UIView!
|
||||||
|
|
||||||
@IBOutlet private var headerView: UIView!
|
@IBOutlet private var bannerView: AppBannerView!
|
||||||
@IBOutlet private var headerContentView: UIView!
|
|
||||||
|
|
||||||
@IBOutlet private var backButton: UIButton!
|
@IBOutlet private var backButton: UIButton!
|
||||||
@IBOutlet private var backButtonContainerView: UIVisualEffectView!
|
@IBOutlet private var backButtonContainerView: UIVisualEffectView!
|
||||||
|
|
||||||
@IBOutlet private var nameLabel: UILabel!
|
|
||||||
@IBOutlet private var developerLabel: UILabel!
|
|
||||||
@IBOutlet private var downloadButton: PillButton!
|
|
||||||
@IBOutlet private var appIconImageView: UIImageView!
|
|
||||||
@IBOutlet private var betaBadgeView: UIImageView!
|
|
||||||
|
|
||||||
@IBOutlet private var backgroundAppIconImageView: UIImageView!
|
@IBOutlet private var backgroundAppIconImageView: UIImageView!
|
||||||
@IBOutlet private var backgroundBlurView: UIVisualEffectView!
|
@IBOutlet private var backgroundBlurView: UIVisualEffectView!
|
||||||
|
|
||||||
@@ -48,9 +42,24 @@ class AppViewController: UIViewController
|
|||||||
@IBOutlet private var navigationBarAppNameLabel: UILabel!
|
@IBOutlet private var navigationBarAppNameLabel: UILabel!
|
||||||
|
|
||||||
private var _shouldResetLayout = false
|
private var _shouldResetLayout = false
|
||||||
|
private var _viewDidAppear = false
|
||||||
private var _backgroundBlurEffect: UIBlurEffect?
|
private var _backgroundBlurEffect: UIBlurEffect?
|
||||||
private var _backgroundBlurTintColor: UIColor?
|
private var _backgroundBlurTintColor: UIColor?
|
||||||
|
|
||||||
|
private var _preferredStatusBarStyle: UIStatusBarStyle = .default
|
||||||
|
|
||||||
|
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||||
|
if #available(iOS 17, *)
|
||||||
|
{
|
||||||
|
// On iOS 17+, .default will update the status bar automatically.
|
||||||
|
return .default
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return _preferredStatusBarStyle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override func viewDidLoad()
|
override func viewDidLoad()
|
||||||
{
|
{
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
@@ -58,6 +67,11 @@ class AppViewController: UIViewController
|
|||||||
self.navigationBarTitleView.sizeToFit()
|
self.navigationBarTitleView.sizeToFit()
|
||||||
self.navigationItem.titleView = self.navigationBarTitleView
|
self.navigationItem.titleView = self.navigationBarTitleView
|
||||||
|
|
||||||
|
// spacing in storyboard wasn't working, so had to do programatically
|
||||||
|
if let stackView = self.navigationBarTitleView as? UIStackView {
|
||||||
|
stackView.spacing = 8
|
||||||
|
}
|
||||||
|
|
||||||
self.contentViewControllerShadowView = UIView()
|
self.contentViewControllerShadowView = UIView()
|
||||||
self.contentViewControllerShadowView.backgroundColor = .white
|
self.contentViewControllerShadowView.backgroundColor = .white
|
||||||
self.contentViewControllerShadowView.layer.cornerRadius = 38
|
self.contentViewControllerShadowView.layer.cornerRadius = 38
|
||||||
@@ -73,27 +87,26 @@ class AppViewController: UIViewController
|
|||||||
self.contentViewController.view.layer.masksToBounds = true
|
self.contentViewController.view.layer.masksToBounds = true
|
||||||
|
|
||||||
self.contentViewController.tableView.panGestureRecognizer.require(toFail: self.scrollView.panGestureRecognizer)
|
self.contentViewController.tableView.panGestureRecognizer.require(toFail: self.scrollView.panGestureRecognizer)
|
||||||
|
self.contentViewController.appDetailCollectionViewController.collectionView.panGestureRecognizer.require(toFail: self.scrollView.panGestureRecognizer)
|
||||||
self.contentViewController.tableView.showsVerticalScrollIndicator = false
|
self.contentViewController.tableView.showsVerticalScrollIndicator = false
|
||||||
|
|
||||||
self.headerView.frame = CGRect(x: 0, y: 0, width: 300, height: 93)
|
|
||||||
self.headerView.layer.cornerRadius = 24
|
|
||||||
self.headerView.layer.masksToBounds = true
|
|
||||||
|
|
||||||
// Bring to front so the scroll indicators are visible.
|
// Bring to front so the scroll indicators are visible.
|
||||||
self.view.bringSubviewToFront(self.scrollView)
|
self.view.bringSubviewToFront(self.scrollView)
|
||||||
self.scrollView.isUserInteractionEnabled = false
|
self.scrollView.isUserInteractionEnabled = false
|
||||||
|
|
||||||
self.nameLabel.text = self.app.name
|
self.bannerView.frame = CGRect(x: 0, y: 0, width: 300, height: 93)
|
||||||
self.developerLabel.text = self.app.developerName
|
self.bannerView.backgroundEffectView.effect = UIBlurEffect(style: .regular)
|
||||||
self.developerLabel.textColor = self.app.tintColor
|
self.bannerView.backgroundEffectView.backgroundColor = .clear
|
||||||
self.appIconImageView.image = nil
|
self.bannerView.iconImageView.image = nil
|
||||||
self.appIconImageView.tintColor = self.app.tintColor
|
self.bannerView.iconImageView.tintColor = self.app.tintColor
|
||||||
self.downloadButton.tintColor = self.app.tintColor
|
self.bannerView.button.tintColor = self.app.tintColor
|
||||||
self.betaBadgeView.isHidden = !self.app.isBeta
|
self.bannerView.tintColor = self.app.tintColor
|
||||||
|
self.bannerView.accessibilityTraits.remove(.button)
|
||||||
|
|
||||||
|
self.bannerView.button.addTarget(self, action: #selector(AppViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
||||||
|
|
||||||
self.backButtonContainerView.tintColor = self.app.tintColor
|
self.backButtonContainerView.tintColor = self.app.tintColor
|
||||||
|
|
||||||
self.navigationController?.navigationBar.tintColor = self.app.tintColor
|
|
||||||
self.navigationBarDownloadButton.tintColor = self.app.tintColor
|
self.navigationBarDownloadButton.tintColor = self.app.tintColor
|
||||||
self.navigationBarAppNameLabel.text = self.app.name
|
self.navigationBarAppNameLabel.text = self.app.name
|
||||||
self.navigationBarAppIconImageView.tintColor = self.app.tintColor
|
self.navigationBarAppIconImageView.tintColor = self.app.tintColor
|
||||||
@@ -107,22 +120,27 @@ class AppViewController: UIViewController
|
|||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(AppViewController.didChangeApp(_:)), name: .NSManagedObjectContextObjectsDidChange, object: DatabaseManager.shared.viewContext)
|
NotificationCenter.default.addObserver(self, selector: #selector(AppViewController.didChangeApp(_:)), name: .NSManagedObjectContextObjectsDidChange, object: DatabaseManager.shared.viewContext)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(AppViewController.willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(AppViewController.willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(AppViewController.didBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||||
|
|
||||||
self._backgroundBlurEffect = self.backgroundBlurView.effect as? UIBlurEffect
|
self._backgroundBlurEffect = self.backgroundBlurView.effect as? UIBlurEffect
|
||||||
self._backgroundBlurTintColor = self.backgroundBlurView.contentView.backgroundColor
|
self._backgroundBlurTintColor = self.backgroundBlurView.contentView.backgroundColor
|
||||||
|
|
||||||
// Load Images
|
// Load Images
|
||||||
for imageView in [self.appIconImageView!, self.backgroundAppIconImageView!, self.navigationBarAppIconImageView!]
|
for imageView in [self.bannerView.iconImageView!, self.backgroundAppIconImageView!, self.navigationBarAppIconImageView!]
|
||||||
{
|
{
|
||||||
imageView.isIndicatingActivity = true
|
imageView.isIndicatingActivity = true
|
||||||
|
|
||||||
Nuke.loadImage(with: self.app.iconURL, options: .shared, into: imageView, progress: nil) { [weak imageView] (response, error) in
|
Nuke.loadImage(with: self.app.iconURL, options: .shared, into: imageView, progress: nil) { [weak imageView] (result) in
|
||||||
if response?.image != nil
|
switch result
|
||||||
{
|
{
|
||||||
imageView?.isIndicatingActivity = false
|
case .success: imageView?.isIndicatingActivity = false
|
||||||
|
case .failure(let error): print("[ALTLog] Failed to load app icons.", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start with navigation bar hidden.
|
||||||
|
self.hideNavigationBar()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool)
|
override func viewWillAppear(_ animated: Bool)
|
||||||
@@ -134,42 +152,26 @@ class AppViewController: UIViewController
|
|||||||
// Update blur immediately.
|
// Update blur immediately.
|
||||||
self.view.setNeedsLayout()
|
self.view.setNeedsLayout()
|
||||||
self.view.layoutIfNeeded()
|
self.view.layoutIfNeeded()
|
||||||
|
}
|
||||||
self.transitionCoordinator?.animate(alongsideTransition: { (context) in
|
|
||||||
self.hideNavigationBar()
|
override func viewIsAppearing(_ animated: Bool)
|
||||||
}, completion: nil)
|
{
|
||||||
|
super.viewIsAppearing(animated)
|
||||||
|
|
||||||
|
// Prevent banner temporarily flashing a color due to being added back to self.view.
|
||||||
|
self.bannerView.backgroundEffectView.backgroundColor = .clear
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool)
|
override func viewDidAppear(_ animated: Bool)
|
||||||
{
|
{
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
|
self._viewDidAppear = true
|
||||||
|
|
||||||
self._shouldResetLayout = true
|
self._shouldResetLayout = true
|
||||||
self.view.setNeedsLayout()
|
self.view.setNeedsLayout()
|
||||||
self.view.layoutIfNeeded()
|
self.view.layoutIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillDisappear(_ animated: Bool)
|
|
||||||
{
|
|
||||||
super.viewWillDisappear(animated)
|
|
||||||
|
|
||||||
// Guard against "dismissing" when presenting via 3D Touch pop.
|
|
||||||
guard self.navigationController != nil else { return }
|
|
||||||
|
|
||||||
// Store reference since self.navigationController will be nil after disappearing.
|
|
||||||
let navigationController = self.navigationController
|
|
||||||
navigationController?.navigationBar.barStyle = .default // Don't animate, or else status bar might appear messed-up.
|
|
||||||
|
|
||||||
self.transitionCoordinator?.animate(alongsideTransition: { (context) in
|
|
||||||
self.showNavigationBar(for: navigationController)
|
|
||||||
}, completion: { (context) in
|
|
||||||
if !context.isCancelled
|
|
||||||
{
|
|
||||||
self.showNavigationBar(for: navigationController)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidDisappear(_ animated: Bool)
|
override func viewDidDisappear(_ animated: Bool)
|
||||||
{
|
{
|
||||||
super.viewDidDisappear(animated)
|
super.viewDidDisappear(animated)
|
||||||
@@ -186,6 +188,12 @@ class AppViewController: UIViewController
|
|||||||
|
|
||||||
self.contentViewController = segue.destination as? AppContentViewController
|
self.contentViewController = segue.destination as? AppContentViewController
|
||||||
self.contentViewController.app = self.app
|
self.contentViewController.app = self.app
|
||||||
|
|
||||||
|
if #available(iOS 15, *)
|
||||||
|
{
|
||||||
|
// Fix navigation bar + tab bar appearance on iOS 15.
|
||||||
|
self.setContentScrollView(self.scrollView)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLayoutSubviews()
|
override func viewDidLayoutSubviews()
|
||||||
@@ -196,11 +204,6 @@ class AppViewController: UIViewController
|
|||||||
{
|
{
|
||||||
// Various events can cause UI to mess up, so reset affected components now.
|
// Various events can cause UI to mess up, so reset affected components now.
|
||||||
|
|
||||||
if self.navigationController?.topViewController == self
|
|
||||||
{
|
|
||||||
self.hideNavigationBar()
|
|
||||||
}
|
|
||||||
|
|
||||||
self.prepareBlur()
|
self.prepareBlur()
|
||||||
|
|
||||||
// Reset navigation bar animation, and create a new one later in this method if necessary.
|
// Reset navigation bar animation, and create a new one later in this method if necessary.
|
||||||
@@ -208,8 +211,22 @@ class AppViewController: UIViewController
|
|||||||
|
|
||||||
self._shouldResetLayout = false
|
self._shouldResetLayout = false
|
||||||
}
|
}
|
||||||
|
|
||||||
let statusBarHeight = UIApplication.shared.statusBarFrame.height
|
let statusBarHeight: Double
|
||||||
|
|
||||||
|
if let navigationController, navigationController.presentingViewController != nil, navigationController.modalPresentationStyle != .fullScreen
|
||||||
|
{
|
||||||
|
statusBarHeight = 20
|
||||||
|
}
|
||||||
|
else if let statusBarManager = (self.view.window ?? self.presentedViewController?.view.window)?.windowScene?.statusBarManager
|
||||||
|
{
|
||||||
|
statusBarHeight = statusBarManager.statusBarFrame.height
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
statusBarHeight = 0
|
||||||
|
}
|
||||||
|
|
||||||
let cornerRadius = self.contentViewControllerShadowView.layer.cornerRadius
|
let cornerRadius = self.contentViewControllerShadowView.layer.cornerRadius
|
||||||
|
|
||||||
let inset = 12 as CGFloat
|
let inset = 12 as CGFloat
|
||||||
@@ -219,7 +236,7 @@ class AppViewController: UIViewController
|
|||||||
var backButtonFrame = CGRect(x: inset, y: statusBarHeight,
|
var backButtonFrame = CGRect(x: inset, y: statusBarHeight,
|
||||||
width: backButtonSize.width + 20, height: backButtonSize.height + 20)
|
width: backButtonSize.width + 20, height: backButtonSize.height + 20)
|
||||||
|
|
||||||
var headerFrame = CGRect(x: inset, y: 0, width: self.view.bounds.width - inset * 2, height: self.headerView.bounds.height)
|
var headerFrame = CGRect(x: inset, y: 0, width: self.view.bounds.width - inset * 2, height: self.bannerView.bounds.height)
|
||||||
var contentFrame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: self.view.bounds.height)
|
var contentFrame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: self.view.bounds.height)
|
||||||
var backgroundIconFrame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: self.view.bounds.width)
|
var backgroundIconFrame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: self.view.bounds.width)
|
||||||
|
|
||||||
@@ -268,13 +285,25 @@ class AppViewController: UIViewController
|
|||||||
}
|
}
|
||||||
|
|
||||||
let difference = self.scrollView.contentOffset.y - showNavigationBarThreshold
|
let difference = self.scrollView.contentOffset.y - showNavigationBarThreshold
|
||||||
let range = (headerFrame.height + padding) - (self.navigationController?.navigationBar.bounds.height ?? self.view.safeAreaInsets.top)
|
|
||||||
|
let range: Double
|
||||||
|
if self.presentingViewController == nil && self.parent?.presentingViewController == nil
|
||||||
|
{
|
||||||
|
// Not presented modally, so rely on safe area + navigation bar height.
|
||||||
|
range = (headerFrame.height + padding) - (self.navigationController?.navigationBar.bounds.height ?? self.view.safeAreaInsets.top)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Presented modally, so rely on maximumContentY.
|
||||||
|
range = maximumContentY - (maximumContentY - padding - headerFrame.height) - inset
|
||||||
|
}
|
||||||
|
|
||||||
let fractionComplete = min(difference, range) / range
|
let fractionComplete = min(difference, range) / range
|
||||||
self.navigationBarAnimator?.fractionComplete = fractionComplete
|
self.navigationBarAnimator?.fractionComplete = fractionComplete
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
self.navigationBarAnimator?.fractionComplete = 0.0
|
||||||
self.resetNavigationBarAnimation()
|
self.resetNavigationBarAnimation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,17 +334,16 @@ class AppViewController: UIViewController
|
|||||||
|
|
||||||
// Set frames.
|
// Set frames.
|
||||||
self.contentViewController.view.superview?.frame = contentFrame
|
self.contentViewController.view.superview?.frame = contentFrame
|
||||||
self.headerView.frame = headerFrame
|
self.bannerView.frame = headerFrame
|
||||||
self.backgroundAppIconImageView.frame = backgroundIconFrame
|
self.backgroundAppIconImageView.frame = backgroundIconFrame
|
||||||
self.backgroundBlurView.frame = backgroundIconFrame
|
self.backgroundBlurView.frame = backgroundIconFrame
|
||||||
self.backButtonContainerView.frame = backButtonFrame
|
self.backButtonContainerView.frame = backButtonFrame
|
||||||
|
|
||||||
self.headerContentView.frame = CGRect(x: 0, y: 0, width: self.headerView.bounds.width, height: self.headerView.bounds.height)
|
|
||||||
self.contentViewControllerShadowView.frame = self.contentViewController.view.frame
|
self.contentViewControllerShadowView.frame = self.contentViewController.view.frame
|
||||||
|
|
||||||
self.backButtonContainerView.layer.cornerRadius = self.backButtonContainerView.bounds.midY
|
self.backButtonContainerView.layer.cornerRadius = self.backButtonContainerView.bounds.midY
|
||||||
|
|
||||||
self.scrollView.scrollIndicatorInsets.top = statusBarHeight
|
self.scrollView.verticalScrollIndicatorInsets.top = statusBarHeight
|
||||||
|
|
||||||
// Adjust content offset + size.
|
// Adjust content offset + size.
|
||||||
let contentOffset = self.scrollView.contentOffset
|
let contentOffset = self.scrollView.contentOffset
|
||||||
@@ -325,6 +353,18 @@ class AppViewController: UIViewController
|
|||||||
|
|
||||||
self.scrollView.contentSize = contentSize
|
self.scrollView.contentSize = contentSize
|
||||||
self.scrollView.contentOffset = contentOffset
|
self.scrollView.contentOffset = contentOffset
|
||||||
|
|
||||||
|
self.bannerView.backgroundEffectView.backgroundColor = .clear
|
||||||
|
}
|
||||||
|
|
||||||
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?)
|
||||||
|
{
|
||||||
|
super.traitCollectionDidChange(previousTraitCollection)
|
||||||
|
|
||||||
|
if self._viewDidAppear
|
||||||
|
{
|
||||||
|
self._shouldResetLayout = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit
|
deinit
|
||||||
@@ -336,7 +376,7 @@ class AppViewController: UIViewController
|
|||||||
|
|
||||||
extension AppViewController
|
extension AppViewController
|
||||||
{
|
{
|
||||||
class func makeAppViewController(app: StoreApp) -> AppViewController
|
final class func makeAppViewController(app: StoreApp) -> AppViewController
|
||||||
{
|
{
|
||||||
let storyboard = UIStoryboard(name: "Main", bundle: nil)
|
let storyboard = UIStoryboard(name: "Main", bundle: nil)
|
||||||
|
|
||||||
@@ -350,57 +390,95 @@ private extension AppViewController
|
|||||||
{
|
{
|
||||||
func update()
|
func update()
|
||||||
{
|
{
|
||||||
for button in [self.downloadButton!, self.navigationBarDownloadButton!]
|
var buttonAction: AppBannerView.AppAction?
|
||||||
|
|
||||||
|
// if let installedApp = self.app.installedApp, let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion), !self.app.isPledgeRequired || self.app.isPledged
|
||||||
|
if let installedApp = self.app.installedApp, installedApp.hasUpdate
|
||||||
|
{
|
||||||
|
// Explicitly set button action to .update if there is an update available, even if it's not supported.
|
||||||
|
buttonAction = .update
|
||||||
|
}
|
||||||
|
|
||||||
|
for button in [self.bannerView.button!, self.navigationBarDownloadButton!]
|
||||||
{
|
{
|
||||||
button.tintColor = self.app.tintColor
|
button.tintColor = self.app.tintColor
|
||||||
button.isIndicatingActivity = false
|
button.isIndicatingActivity = false
|
||||||
|
|
||||||
if self.app.installedApp == nil
|
|
||||||
{
|
|
||||||
button.setTitle(NSLocalizedString("FREE", comment: ""), for: .normal)
|
|
||||||
button.isInverted = false
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
|
|
||||||
button.isInverted = true
|
|
||||||
}
|
|
||||||
|
|
||||||
let progress = AppManager.shared.installationProgress(for: self.app)
|
|
||||||
button.progress = progress
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if Date() < self.app.versionDate
|
self.bannerView.configure(for: self.app, action: buttonAction)
|
||||||
{
|
|
||||||
self.downloadButton.countdownDate = self.app.versionDate
|
let title = self.bannerView.button.title(for: .normal)
|
||||||
self.navigationBarDownloadButton.countdownDate = self.app.versionDate
|
self.navigationBarDownloadButton.setTitle(title, for: .normal)
|
||||||
}
|
self.navigationBarDownloadButton.progress = self.bannerView.button.progress
|
||||||
else
|
self.navigationBarDownloadButton.countdownDate = self.bannerView.button.countdownDate
|
||||||
{
|
|
||||||
self.downloadButton.countdownDate = nil
|
|
||||||
self.navigationBarDownloadButton.countdownDate = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let barButtonItem = self.navigationItem.rightBarButtonItem
|
let barButtonItem = self.navigationItem.rightBarButtonItem
|
||||||
self.navigationItem.rightBarButtonItem = nil
|
self.navigationItem.rightBarButtonItem = nil
|
||||||
self.navigationItem.rightBarButtonItem = barButtonItem
|
self.navigationItem.rightBarButtonItem = barButtonItem
|
||||||
}
|
}
|
||||||
|
|
||||||
func showNavigationBar(for navigationController: UINavigationController? = nil)
|
func showNavigationBar()
|
||||||
{
|
{
|
||||||
let navigationController = navigationController ?? self.navigationController
|
self.navigationBarAppIconImageView.alpha = 1.0
|
||||||
navigationController?.navigationBar.barStyle = .default
|
self.navigationBarAppNameLabel.alpha = 1.0
|
||||||
navigationController?.navigationBar.alpha = 1.0
|
self.navigationBarDownloadButton.alpha = 1.0
|
||||||
navigationController?.navigationBar.barTintColor = .white
|
|
||||||
navigationController?.navigationBar.tintColor = .altPrimary
|
self.updateNavigationBarAppearance(isHidden: false)
|
||||||
|
|
||||||
|
if self.traitCollection.userInterfaceStyle == .dark
|
||||||
|
{
|
||||||
|
self._preferredStatusBarStyle = .lightContent
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self._preferredStatusBarStyle = .default
|
||||||
|
}
|
||||||
|
|
||||||
|
if #unavailable(iOS 17)
|
||||||
|
{
|
||||||
|
self.navigationController?.setNeedsStatusBarAppearanceUpdate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func hideNavigationBar(for navigationController: UINavigationController? = nil)
|
func hideNavigationBar()
|
||||||
{
|
{
|
||||||
let navigationController = navigationController ?? self.navigationController
|
self.navigationBarAppIconImageView.alpha = 0.0
|
||||||
navigationController?.navigationBar.barStyle = .black
|
self.navigationBarAppNameLabel.alpha = 0.0
|
||||||
navigationController?.navigationBar.alpha = 0.0
|
self.navigationBarDownloadButton.alpha = 0.0
|
||||||
navigationController?.navigationBar.barTintColor = .white
|
|
||||||
|
self.updateNavigationBarAppearance(isHidden: true)
|
||||||
|
|
||||||
|
self._preferredStatusBarStyle = .lightContent
|
||||||
|
|
||||||
|
if #unavailable(iOS 17)
|
||||||
|
{
|
||||||
|
self.navigationController?.setNeedsStatusBarAppearanceUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from HeaderContentViewController
|
||||||
|
func updateNavigationBarAppearance(isHidden: Bool)
|
||||||
|
{
|
||||||
|
let barAppearance = self.navigationItem.standardAppearance as? NavigationBarAppearance ?? NavigationBarAppearance()
|
||||||
|
|
||||||
|
if isHidden
|
||||||
|
{
|
||||||
|
barAppearance.configureWithTransparentBackground()
|
||||||
|
barAppearance.ignoresUserInteraction = true
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
barAppearance.configureWithDefaultBackground()
|
||||||
|
barAppearance.ignoresUserInteraction = false
|
||||||
|
}
|
||||||
|
|
||||||
|
barAppearance.titleTextAttributes = [.foregroundColor: UIColor.clear]
|
||||||
|
|
||||||
|
let tintColor = isHidden ? UIColor.clear : self.app.tintColor ?? .altPrimary
|
||||||
|
barAppearance.configureWithTintColor(tintColor)
|
||||||
|
|
||||||
|
self.navigationItem.standardAppearance = barAppearance
|
||||||
|
self.navigationItem.scrollEdgeAppearance = barAppearance
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareBlur()
|
func prepareBlur()
|
||||||
@@ -428,8 +506,10 @@ private extension AppViewController
|
|||||||
|
|
||||||
self.navigationBarAnimator = UIViewPropertyAnimator(duration: 1.0, curve: .linear) { [weak self] in
|
self.navigationBarAnimator = UIViewPropertyAnimator(duration: 1.0, curve: .linear) { [weak self] in
|
||||||
self?.showNavigationBar()
|
self?.showNavigationBar()
|
||||||
self?.navigationController?.navigationBar.tintColor = self?.app.tintColor
|
|
||||||
self?.navigationController?.navigationBar.barTintColor = nil
|
// Must call layoutIfNeeded() to animate appearance change.
|
||||||
|
self?.navigationController?.navigationBar.layoutIfNeeded()
|
||||||
|
|
||||||
self?.contentViewController.view.layer.cornerRadius = 0
|
self?.contentViewController.view.layer.cornerRadius = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,11 +521,13 @@ private extension AppViewController
|
|||||||
|
|
||||||
func resetNavigationBarAnimation()
|
func resetNavigationBarAnimation()
|
||||||
{
|
{
|
||||||
|
guard self.navigationBarAnimator != nil else { return }
|
||||||
|
|
||||||
self.navigationBarAnimator?.stopAnimation(true)
|
self.navigationBarAnimator?.stopAnimation(true)
|
||||||
self.navigationBarAnimator = nil
|
self.navigationBarAnimator = nil
|
||||||
|
|
||||||
self.hideNavigationBar()
|
self.hideNavigationBar()
|
||||||
self.navigationController?.navigationBar.barTintColor = .white
|
|
||||||
self.contentViewController.view.layer.cornerRadius = self.contentViewControllerShadowView.layer.cornerRadius
|
self.contentViewController.view.layer.cornerRadius = self.contentViewControllerShadowView.layer.cornerRadius
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -461,7 +543,15 @@ extension AppViewController
|
|||||||
{
|
{
|
||||||
if let installedApp = self.app.installedApp
|
if let installedApp = self.app.installedApp
|
||||||
{
|
{
|
||||||
self.open(installedApp)
|
// if let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion), !self.app.isPledgeRequired || self.app.isPledged
|
||||||
|
if let latestVersion = self.app.latestAvailableVersion, installedApp.hasUpdate
|
||||||
|
{
|
||||||
|
self.updateApp(installedApp, to: latestVersion)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.open(installedApp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -473,36 +563,72 @@ extension AppViewController
|
|||||||
{
|
{
|
||||||
guard self.app.installedApp == nil else { return }
|
guard self.app.installedApp == nil else { return }
|
||||||
|
|
||||||
let progress = AppManager.shared.install(self.app, presentingViewController: self) { (result) in
|
Task<Void, Never>(priority: .userInitiated) {
|
||||||
do
|
let group = await AppManager.shared.installAsync(self.app, presentingViewController: self) { (result) in
|
||||||
{
|
do
|
||||||
_ = try result.get()
|
{
|
||||||
}
|
_ = try result.get()
|
||||||
catch OperationError.cancelled
|
}
|
||||||
{
|
catch OperationError.cancelled
|
||||||
// Ignore
|
{
|
||||||
}
|
// Ignore
|
||||||
catch
|
}
|
||||||
{
|
catch
|
||||||
|
{
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let toastView = ToastView(error: error)
|
||||||
|
toastView.opensErrorLog = true
|
||||||
|
toastView.show(in: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
|
self.bannerView.button.progress = nil
|
||||||
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2)
|
self.navigationBarDownloadButton.progress = nil
|
||||||
|
self.update()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
if !group.progress.isCancelled
|
||||||
self.downloadButton.progress = nil
|
{
|
||||||
self.update()
|
self.bannerView.button.progress = group.progress
|
||||||
|
self.navigationBarDownloadButton.progress = group.progress
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.downloadButton.progress = progress
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func open(_ installedApp: InstalledApp)
|
func open(_ installedApp: InstalledApp)
|
||||||
{
|
{
|
||||||
UIApplication.shared.open(installedApp.openAppURL)
|
UIApplication.shared.open(installedApp.openAppURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateApp(_ installedApp: InstalledApp, to version: AppVersion)
|
||||||
|
{
|
||||||
|
let previousProgress = AppManager.shared.installationProgress(for: installedApp)
|
||||||
|
guard previousProgress == nil else {
|
||||||
|
//TODO: Handle cancellation
|
||||||
|
//previousProgress?.cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
AppManager.shared.update(installedApp, to: version, presentingViewController: self) { (result) in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
switch result
|
||||||
|
{
|
||||||
|
case .success: print("Updated app from AppViewController:", installedApp.bundleIdentifier)
|
||||||
|
case .failure(OperationError.cancelled): break
|
||||||
|
case .failure(let error):
|
||||||
|
let toastView = ToastView(error: error)
|
||||||
|
toastView.opensErrorLog = true
|
||||||
|
toastView.show(in: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.update()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.update()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension AppViewController
|
private extension AppViewController
|
||||||
@@ -522,6 +648,15 @@ private extension AppViewController
|
|||||||
self._shouldResetLayout = true
|
self._shouldResetLayout = true
|
||||||
self.view.setNeedsLayout()
|
self.view.setNeedsLayout()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func didBecomeActive(_ notification: Notification)
|
||||||
|
{
|
||||||
|
guard let navigationController = self.navigationController, navigationController.topViewController == self else { return }
|
||||||
|
|
||||||
|
// Fixes Navigation Bar appearing after app becomes inactive -> active again.
|
||||||
|
self._shouldResetLayout = true
|
||||||
|
self.view.setNeedsLayout()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AppViewController: UIScrollViewDelegate
|
extension AppViewController: UIScrollViewDelegate
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class PermissionPopoverViewController: UIViewController
|
import AltStoreCore
|
||||||
|
|
||||||
|
final class PermissionPopoverViewController: UIViewController
|
||||||
{
|
{
|
||||||
var permission: AppPermission!
|
var permission: AppPermission!
|
||||||
|
|
||||||
@@ -19,7 +21,7 @@ class PermissionPopoverViewController: UIViewController
|
|||||||
{
|
{
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
self.nameLabel.text = self.permission.type.localizedName
|
self.nameLabel.text = self.permission.localizedName ?? self.permission.permission.rawValue
|
||||||
self.descriptionLabel.text = self.permission.usageDescription
|
self.descriptionLabel.text = self.permission.usageDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
//
|
||||||
|
// AppScreenshotCollectionViewCell.swift
|
||||||
|
// AltStore
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 10/11/23.
|
||||||
|
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
import AltStoreCore
|
||||||
|
|
||||||
|
extension AppScreenshotCollectionViewCell
|
||||||
|
{
|
||||||
|
private class ImageView: UIImageView
|
||||||
|
{
|
||||||
|
override func layoutSubviews()
|
||||||
|
{
|
||||||
|
super.layoutSubviews()
|
||||||
|
|
||||||
|
// Explicitly layout cell to ensure rounded corners are accurate.
|
||||||
|
self.superview?.superview?.setNeedsLayout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppScreenshotCollectionViewCell: UICollectionViewCell
|
||||||
|
{
|
||||||
|
let imageView: UIImageView
|
||||||
|
|
||||||
|
var aspectRatio: CGSize = AppScreenshot.defaultAspectRatio {
|
||||||
|
didSet {
|
||||||
|
self.updateAspectRatio()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isRounded: Bool = false {
|
||||||
|
didSet {
|
||||||
|
self.setNeedsLayout()
|
||||||
|
self.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var aspectRatioConstraint: NSLayoutConstraint?
|
||||||
|
|
||||||
|
override init(frame: CGRect)
|
||||||
|
{
|
||||||
|
self.imageView = ImageView(frame: .zero)
|
||||||
|
self.imageView.clipsToBounds = true
|
||||||
|
self.imageView.layer.cornerCurve = .continuous
|
||||||
|
self.imageView.layer.borderColor = UIColor.tertiaryLabel.cgColor
|
||||||
|
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
self.imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
self.contentView.addSubview(self.imageView)
|
||||||
|
|
||||||
|
let widthConstraint = self.imageView.widthAnchor.constraint(equalTo: self.contentView.widthAnchor)
|
||||||
|
widthConstraint.priority = .defaultHigh
|
||||||
|
|
||||||
|
let heightConstraint = self.imageView.heightAnchor.constraint(equalTo: self.contentView.heightAnchor)
|
||||||
|
heightConstraint.priority = .defaultHigh
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
widthConstraint,
|
||||||
|
heightConstraint,
|
||||||
|
self.imageView.widthAnchor.constraint(lessThanOrEqualTo: self.contentView.widthAnchor),
|
||||||
|
self.imageView.heightAnchor.constraint(lessThanOrEqualTo: self.contentView.heightAnchor),
|
||||||
|
self.imageView.centerXAnchor.constraint(equalTo: self.contentView.centerXAnchor),
|
||||||
|
self.imageView.centerYAnchor.constraint(equalTo: self.contentView.centerYAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
self.updateAspectRatio()
|
||||||
|
self.updateTraits()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?)
|
||||||
|
{
|
||||||
|
super.traitCollectionDidChange(previousTraitCollection)
|
||||||
|
|
||||||
|
self.updateTraits()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews()
|
||||||
|
{
|
||||||
|
super.layoutSubviews()
|
||||||
|
|
||||||
|
if self.isRounded
|
||||||
|
{
|
||||||
|
let cornerRadius = self.imageView.bounds.width / 9.0 // Based on iPhone 15
|
||||||
|
self.imageView.layer.cornerRadius = cornerRadius
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.imageView.layer.cornerRadius = 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppScreenshotCollectionViewCell
|
||||||
|
{
|
||||||
|
func setImage(_ image: UIImage?)
|
||||||
|
{
|
||||||
|
guard var image, let cgImage = image.cgImage else {
|
||||||
|
self.imageView.image = image
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if image.size.width > image.size.height && self.aspectRatio.width < self.aspectRatio.height
|
||||||
|
{
|
||||||
|
// Image is landscape, but cell has portrait aspect ratio, so rotate image to match.
|
||||||
|
image = UIImage(cgImage: cgImage, scale: image.scale, orientation: .right)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.imageView.image = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension AppScreenshotCollectionViewCell
|
||||||
|
{
|
||||||
|
func updateAspectRatio()
|
||||||
|
{
|
||||||
|
self.aspectRatioConstraint?.isActive = false
|
||||||
|
|
||||||
|
self.aspectRatioConstraint = self.imageView.widthAnchor.constraint(equalTo: self.imageView.heightAnchor, multiplier: self.aspectRatio.width / self.aspectRatio.height)
|
||||||
|
self.aspectRatioConstraint?.isActive = true
|
||||||
|
|
||||||
|
let aspectRatio: Double
|
||||||
|
if self.aspectRatio.width > self.aspectRatio.height
|
||||||
|
{
|
||||||
|
aspectRatio = self.aspectRatio.height / self.aspectRatio.width
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
aspectRatio = self.aspectRatio.width / self.aspectRatio.height
|
||||||
|
}
|
||||||
|
|
||||||
|
let tolerance = 0.001 as Double
|
||||||
|
let modernAspectRatio = AppScreenshot.defaultAspectRatio.width / AppScreenshot.defaultAspectRatio.height
|
||||||
|
|
||||||
|
let isRounded = (aspectRatio >= modernAspectRatio - tolerance) && (aspectRatio <= modernAspectRatio + tolerance)
|
||||||
|
self.isRounded = isRounded
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateTraits()
|
||||||
|
{
|
||||||
|
let displayScale = (self.traitCollection.displayScale == 0.0) ? 1.0 : self.traitCollection.displayScale
|
||||||
|
self.imageView.layer.borderWidth = 1.0 / displayScale
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
//
|
||||||
|
// AppScreenshotsViewController.swift
|
||||||
|
// AltStore
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 9/18/23.
|
||||||
|
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
import AltStoreCore
|
||||||
|
import Roxas
|
||||||
|
|
||||||
|
import Nuke
|
||||||
|
|
||||||
|
class AppScreenshotsViewController: UICollectionViewController
|
||||||
|
{
|
||||||
|
let app: StoreApp
|
||||||
|
|
||||||
|
private lazy var dataSource = self.makeDataSource()
|
||||||
|
|
||||||
|
init?(app: StoreApp, coder: NSCoder)
|
||||||
|
{
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
super.init(coder: coder)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad()
|
||||||
|
{
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
self.collectionView.showsHorizontalScrollIndicator = false
|
||||||
|
|
||||||
|
// Allow parent background color to show through.
|
||||||
|
self.collectionView.backgroundColor = nil
|
||||||
|
|
||||||
|
// Match the parent table view margins.
|
||||||
|
self.collectionView.directionalLayoutMargins.top = 0
|
||||||
|
self.collectionView.directionalLayoutMargins.bottom = 0
|
||||||
|
self.collectionView.directionalLayoutMargins.leading = 20
|
||||||
|
self.collectionView.directionalLayoutMargins.trailing = 20
|
||||||
|
|
||||||
|
let collectionViewLayout = self.makeLayout()
|
||||||
|
self.collectionView.collectionViewLayout = collectionViewLayout
|
||||||
|
|
||||||
|
self.collectionView.register(AppScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||||
|
|
||||||
|
self.collectionView.dataSource = self.dataSource
|
||||||
|
self.collectionView.prefetchDataSource = self.dataSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension AppScreenshotsViewController
|
||||||
|
{
|
||||||
|
func makeLayout() -> UICollectionViewCompositionalLayout
|
||||||
|
{
|
||||||
|
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
|
||||||
|
layoutConfig.contentInsetsReference = .layoutMargins
|
||||||
|
|
||||||
|
let preferredHeight = 400.0
|
||||||
|
let estimatedWidth = preferredHeight * (AppScreenshot.defaultAspectRatio.width / AppScreenshot.defaultAspectRatio.height)
|
||||||
|
|
||||||
|
let layout = UICollectionViewCompositionalLayout(sectionProvider: { [dataSource] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
|
||||||
|
let screenshotWidths = dataSource.items.map { screenshot in
|
||||||
|
var aspectRatio = screenshot.size ?? AppScreenshot.defaultAspectRatio
|
||||||
|
if aspectRatio.width > aspectRatio.height
|
||||||
|
{
|
||||||
|
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
let screenshotWidth = (preferredHeight * (aspectRatio.width / aspectRatio.height)).rounded()
|
||||||
|
return screenshotWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
let smallestWidth = screenshotWidths.sorted().first
|
||||||
|
let itemWidth = smallestWidth ?? estimatedWidth // Use smallestWidth to ensure we never overshoot an item when paging.
|
||||||
|
|
||||||
|
let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(itemWidth), heightDimension: .fractionalHeight(1.0))
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||||
|
|
||||||
|
let groupSize = NSCollectionLayoutSize(widthDimension: .estimated(itemWidth), heightDimension: .absolute(preferredHeight))
|
||||||
|
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
|
||||||
|
|
||||||
|
let layoutSection = NSCollectionLayoutSection(group: group)
|
||||||
|
layoutSection.interGroupSpacing = 10
|
||||||
|
layoutSection.orthogonalScrollingBehavior = .groupPaging
|
||||||
|
|
||||||
|
return layoutSection
|
||||||
|
}, configuration: layoutConfig)
|
||||||
|
|
||||||
|
return layout
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>
|
||||||
|
{
|
||||||
|
let screenshots = self.app.preferredScreenshots()
|
||||||
|
|
||||||
|
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>(items: screenshots)
|
||||||
|
dataSource.cellConfigurationHandler = { [weak self] (cell, screenshot, indexPath) in
|
||||||
|
let cell = cell as! AppScreenshotCollectionViewCell
|
||||||
|
cell.imageView.isIndicatingActivity = true
|
||||||
|
cell.setImage(nil)
|
||||||
|
|
||||||
|
var aspectRatio = screenshot.size ?? AppScreenshot.defaultAspectRatio
|
||||||
|
if aspectRatio.width > aspectRatio.height
|
||||||
|
{
|
||||||
|
switch screenshot.deviceType
|
||||||
|
{
|
||||||
|
case .iphone:
|
||||||
|
// Always rotate landscape iPhone screenshots regardless of horizontal size class.
|
||||||
|
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
||||||
|
|
||||||
|
case .ipad where self?.traitCollection.horizontalSizeClass == .compact:
|
||||||
|
// Only rotate landscape iPad screenshots if we're in horizontally compact environment.
|
||||||
|
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
||||||
|
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cell.aspectRatio = aspectRatio
|
||||||
|
}
|
||||||
|
dataSource.prefetchHandler = { (screenshot, indexPath, completionHandler) in
|
||||||
|
let imageURL = screenshot.imageURL
|
||||||
|
return RSTAsyncBlockOperation() { (operation) in
|
||||||
|
let request = ImageRequest(url: imageURL)
|
||||||
|
ImagePipeline.shared.loadImage(with: request, progress: nil) { result in
|
||||||
|
guard !operation.isCancelled else { return operation.finish() }
|
||||||
|
|
||||||
|
switch result
|
||||||
|
{
|
||||||
|
case .success(let response): completionHandler(response.image, nil)
|
||||||
|
case .failure(let error): completionHandler(nil, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
|
||||||
|
let cell = cell as! AppScreenshotCollectionViewCell
|
||||||
|
cell.imageView.isIndicatingActivity = false
|
||||||
|
cell.setImage(image)
|
||||||
|
|
||||||
|
if let error = error
|
||||||
|
{
|
||||||
|
print("Error loading image:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppScreenshotsViewController
|
||||||
|
{
|
||||||
|
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
||||||
|
{
|
||||||
|
let screenshot = self.dataSource.item(at: indexPath)
|
||||||
|
|
||||||
|
let previewViewController = PreviewAppScreenshotsViewController(app: self.app)
|
||||||
|
previewViewController.currentScreenshot = screenshot
|
||||||
|
|
||||||
|
let navigationController = UINavigationController(rootViewController: previewViewController)
|
||||||
|
navigationController.modalPresentationStyle = .fullScreen
|
||||||
|
self.present(navigationController, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 17, *)
|
||||||
|
#Preview(traits: .portrait) {
|
||||||
|
DatabaseManager.shared.startForPreview()
|
||||||
|
|
||||||
|
let fetchRequest = StoreApp.fetchRequest()
|
||||||
|
let storeApp = try! DatabaseManager.shared.viewContext.fetch(fetchRequest).first!
|
||||||
|
|
||||||
|
let storyboard = UIStoryboard(name: "Main", bundle: .main)
|
||||||
|
let appViewConttroller = storyboard.instantiateViewController(withIdentifier: "appViewController") as! AppViewController
|
||||||
|
appViewConttroller.app = storeApp
|
||||||
|
|
||||||
|
let navigationController = UINavigationController(rootViewController: appViewConttroller)
|
||||||
|
return navigationController
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
//
|
||||||
|
// PreviewAppScreenshotsViewController.swift
|
||||||
|
// AltStore
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 9/19/23.
|
||||||
|
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
import AltStoreCore
|
||||||
|
import Roxas
|
||||||
|
|
||||||
|
import Nuke
|
||||||
|
|
||||||
|
class PreviewAppScreenshotsViewController: UICollectionViewController
|
||||||
|
{
|
||||||
|
let app: StoreApp
|
||||||
|
|
||||||
|
var currentScreenshot: AppScreenshot?
|
||||||
|
|
||||||
|
private lazy var dataSource = self.makeDataSource()
|
||||||
|
|
||||||
|
init(app: StoreApp)
|
||||||
|
{
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
super.init(collectionViewLayout: UICollectionViewFlowLayout())
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad()
|
||||||
|
{
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
let tintColor = self.app.tintColor ?? .altPrimary
|
||||||
|
self.navigationController?.view.tintColor = tintColor
|
||||||
|
|
||||||
|
self.view.backgroundColor = .systemBackground
|
||||||
|
self.collectionView.backgroundColor = nil
|
||||||
|
|
||||||
|
let collectionViewLayout = self.makeLayout()
|
||||||
|
self.collectionView.collectionViewLayout = collectionViewLayout
|
||||||
|
|
||||||
|
self.collectionView.directionalLayoutMargins.leading = 20
|
||||||
|
self.collectionView.directionalLayoutMargins.trailing = 20
|
||||||
|
|
||||||
|
self.collectionView.preservesSuperviewLayoutMargins = true
|
||||||
|
self.collectionView.insetsLayoutMarginsFromSafeArea = true
|
||||||
|
|
||||||
|
self.collectionView.alwaysBounceVertical = false
|
||||||
|
self.collectionView.register(AppScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||||
|
|
||||||
|
self.collectionView.dataSource = self.dataSource
|
||||||
|
self.collectionView.prefetchDataSource = self.dataSource
|
||||||
|
|
||||||
|
let doneButton = UIBarButtonItem(systemItem: .done, primaryAction: UIAction { [weak self] _ in
|
||||||
|
self?.dismissPreview()
|
||||||
|
})
|
||||||
|
self.navigationItem.rightBarButtonItem = doneButton
|
||||||
|
|
||||||
|
let swipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(PreviewAppScreenshotsViewController.dismissPreview))
|
||||||
|
swipeGestureRecognizer.direction = .down
|
||||||
|
self.view.addGestureRecognizer(swipeGestureRecognizer)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewIsAppearing(_ animated: Bool)
|
||||||
|
{
|
||||||
|
super.viewIsAppearing(animated)
|
||||||
|
|
||||||
|
if let screenshot = self.currentScreenshot, let index = self.dataSource.items.firstIndex(of: screenshot)
|
||||||
|
{
|
||||||
|
let indexPath = IndexPath(item: index, section: 0)
|
||||||
|
self.collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension PreviewAppScreenshotsViewController
|
||||||
|
{
|
||||||
|
func makeLayout() -> UICollectionViewCompositionalLayout
|
||||||
|
{
|
||||||
|
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
|
||||||
|
layoutConfig.contentInsetsReference = .none
|
||||||
|
|
||||||
|
let layout = UICollectionViewCompositionalLayout(sectionProvider: { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
|
||||||
|
guard let self else { return nil }
|
||||||
|
|
||||||
|
let contentInsets = self.collectionView.directionalLayoutMargins
|
||||||
|
let groupWidth = layoutEnvironment.container.contentSize.width - (contentInsets.leading + contentInsets.trailing)
|
||||||
|
let groupHeight = layoutEnvironment.container.contentSize.height - (contentInsets.top + contentInsets.bottom)
|
||||||
|
|
||||||
|
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||||
|
|
||||||
|
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(groupWidth), heightDimension: .absolute(groupHeight))
|
||||||
|
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
|
||||||
|
|
||||||
|
let layoutSection = NSCollectionLayoutSection(group: group)
|
||||||
|
layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
|
||||||
|
layoutSection.interGroupSpacing = 10
|
||||||
|
return layoutSection
|
||||||
|
}, configuration: layoutConfig)
|
||||||
|
|
||||||
|
return layout
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>
|
||||||
|
{
|
||||||
|
let screenshots = self.app.preferredScreenshots()
|
||||||
|
|
||||||
|
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>(items: screenshots)
|
||||||
|
dataSource.cellConfigurationHandler = { [weak self] (cell, screenshot, indexPath) in
|
||||||
|
let cell = cell as! AppScreenshotCollectionViewCell
|
||||||
|
cell.imageView.isIndicatingActivity = true
|
||||||
|
cell.setImage(nil)
|
||||||
|
|
||||||
|
var aspectRatio = screenshot.size ?? AppScreenshot.defaultAspectRatio
|
||||||
|
if aspectRatio.width > aspectRatio.height
|
||||||
|
{
|
||||||
|
switch screenshot.deviceType
|
||||||
|
{
|
||||||
|
case .iphone:
|
||||||
|
// Always rotate landscape iPhone screenshots regardless of horizontal size class.
|
||||||
|
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
||||||
|
|
||||||
|
case .ipad where self?.traitCollection.horizontalSizeClass == .compact:
|
||||||
|
// Only rotate landscape iPad screenshots if we're in horizontally compact environment.
|
||||||
|
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
||||||
|
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cell.aspectRatio = aspectRatio
|
||||||
|
}
|
||||||
|
dataSource.prefetchHandler = { (screenshot, indexPath, completionHandler) in
|
||||||
|
let imageURL = screenshot.imageURL
|
||||||
|
return RSTAsyncBlockOperation() { (operation) in
|
||||||
|
let request = ImageRequest(url: imageURL)
|
||||||
|
ImagePipeline.shared.loadImage(with: request, progress: nil) { result in
|
||||||
|
guard !operation.isCancelled else { return operation.finish() }
|
||||||
|
|
||||||
|
switch result
|
||||||
|
{
|
||||||
|
case .success(let response): completionHandler(response.image, nil)
|
||||||
|
case .failure(let error): completionHandler(nil, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
|
||||||
|
let cell = cell as! AppScreenshotCollectionViewCell
|
||||||
|
cell.imageView.isIndicatingActivity = false
|
||||||
|
cell.setImage(image)
|
||||||
|
|
||||||
|
if let error = error
|
||||||
|
{
|
||||||
|
print("Error loading image:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension PreviewAppScreenshotsViewController
|
||||||
|
{
|
||||||
|
@objc func dismissPreview()
|
||||||
|
{
|
||||||
|
self.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 17, *)
|
||||||
|
#Preview(traits: .portrait) {
|
||||||
|
DatabaseManager.shared.startForPreview()
|
||||||
|
|
||||||
|
let fetchRequest = StoreApp.fetchRequest()
|
||||||
|
let storeApp = try! DatabaseManager.shared.viewContext.fetch(fetchRequest).first!
|
||||||
|
|
||||||
|
let previewViewController = PreviewAppScreenshotsViewController(app: storeApp)
|
||||||
|
|
||||||
|
let navigationController = UINavigationController(rootViewController: previewViewController)
|
||||||
|
return navigationController
|
||||||
|
}
|
||||||
255
AltStore/App IDs/AppIDsViewController.swift
Normal file
255
AltStore/App IDs/AppIDsViewController.swift
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
//
|
||||||
|
// AppIDsViewController.swift
|
||||||
|
// AltStore
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 1/27/20.
|
||||||
|
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
import AltStoreCore
|
||||||
|
import Roxas
|
||||||
|
|
||||||
|
final class AppIDsViewController: UICollectionViewController
|
||||||
|
{
|
||||||
|
private lazy var dataSource = self.makeDataSource()
|
||||||
|
|
||||||
|
private var didInitialFetch = false
|
||||||
|
private var isLoading = false {
|
||||||
|
didSet {
|
||||||
|
self.update()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@IBOutlet var activityIndicatorBarButtonItem: UIBarButtonItem!
|
||||||
|
|
||||||
|
override func viewDidLoad()
|
||||||
|
{
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
self.collectionView.dataSource = self.dataSource
|
||||||
|
|
||||||
|
self.activityIndicatorBarButtonItem.isIndicatingActivity = true
|
||||||
|
|
||||||
|
let refreshControl = UIRefreshControl()
|
||||||
|
refreshControl.addTarget(self, action: #selector(AppIDsViewController.fetchAppIDs), for: .primaryActionTriggered)
|
||||||
|
self.collectionView.refreshControl = refreshControl
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool)
|
||||||
|
{
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
|
if !self.didInitialFetch
|
||||||
|
{
|
||||||
|
self.fetchAppIDs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension AppIDsViewController
|
||||||
|
{
|
||||||
|
func makeDataSource() -> RSTFetchedResultsCollectionViewDataSource<AppID>
|
||||||
|
{
|
||||||
|
let fetchRequest = AppID.fetchRequest() as NSFetchRequest<AppID>
|
||||||
|
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \AppID.name, ascending: true),
|
||||||
|
NSSortDescriptor(keyPath: \AppID.bundleIdentifier, ascending: true),
|
||||||
|
NSSortDescriptor(keyPath: \AppID.expirationDate, ascending: true)]
|
||||||
|
fetchRequest.returnsObjectsAsFaults = false
|
||||||
|
|
||||||
|
if let team = DatabaseManager.shared.activeTeam()
|
||||||
|
{
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(AppID.team), team)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
fetchRequest.predicate = NSPredicate(value: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
let dataSource = RSTFetchedResultsCollectionViewDataSource<AppID>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
||||||
|
dataSource.proxy = self
|
||||||
|
dataSource.cellConfigurationHandler = { (cell, appID, indexPath) in
|
||||||
|
let tintColor = UIColor.altPrimary
|
||||||
|
|
||||||
|
let cell = cell as! AppBannerCollectionViewCell
|
||||||
|
cell.tintColor = tintColor
|
||||||
|
|
||||||
|
cell.contentView.preservesSuperviewLayoutMargins = false
|
||||||
|
cell.contentView.layoutMargins = UIEdgeInsets(top: 0, left: self.view.layoutMargins.left, bottom: 0, right: self.view.layoutMargins.right)
|
||||||
|
|
||||||
|
cell.bannerView.iconImageView.isHidden = true
|
||||||
|
cell.bannerView.button.isIndicatingActivity = false
|
||||||
|
|
||||||
|
cell.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "")
|
||||||
|
|
||||||
|
let attributedAccessibilityLabel = NSMutableAttributedString(string: appID.name + ". ")
|
||||||
|
|
||||||
|
if let expirationDate = appID.expirationDate
|
||||||
|
{
|
||||||
|
cell.bannerView.button.isHidden = false
|
||||||
|
cell.bannerView.button.isUserInteractionEnabled = false
|
||||||
|
|
||||||
|
cell.bannerView.buttonLabel.isHidden = false
|
||||||
|
|
||||||
|
let currentDate = Date()
|
||||||
|
|
||||||
|
let formatter = DateComponentsFormatter()
|
||||||
|
formatter.unitsStyle = .full
|
||||||
|
formatter.includesApproximationPhrase = false
|
||||||
|
formatter.includesTimeRemainingPhrase = false
|
||||||
|
formatter.allowedUnits = [.minute, .hour, .day]
|
||||||
|
formatter.maximumUnitCount = 1
|
||||||
|
|
||||||
|
let timeInterval = formatter.string(from: currentDate, to: expirationDate)
|
||||||
|
let timeIntervalText = timeInterval ?? NSLocalizedString("Unknown", comment: "")
|
||||||
|
cell.bannerView.button.setTitle(timeIntervalText.uppercased(), for: .normal)
|
||||||
|
|
||||||
|
// formatter.includesTimeRemainingPhrase = true
|
||||||
|
attributedAccessibilityLabel.mutableString.append(timeIntervalText)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cell.bannerView.button.isHidden = true
|
||||||
|
cell.bannerView.button.isUserInteractionEnabled = true
|
||||||
|
|
||||||
|
cell.bannerView.buttonLabel.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
cell.bannerView.titleLabel.text = appID.name
|
||||||
|
cell.bannerView.subtitleLabel.text = appID.bundleIdentifier
|
||||||
|
cell.bannerView.subtitleLabel.numberOfLines = 2
|
||||||
|
cell.bannerView.subtitleLabel.minimumScaleFactor = 1.0 // Disable font shrinking
|
||||||
|
|
||||||
|
let attributedBundleIdentifier = NSMutableAttributedString(string: appID.bundleIdentifier.lowercased(), attributes: [.accessibilitySpeechPunctuation: true])
|
||||||
|
|
||||||
|
if let team = appID.team, let range = attributedBundleIdentifier.string.range(of: team.identifier.lowercased())
|
||||||
|
{
|
||||||
|
// Prefer to speak the team ID one character at a time.
|
||||||
|
let nsRange = NSRange(range, in: attributedBundleIdentifier.string)
|
||||||
|
attributedBundleIdentifier.addAttributes([.accessibilitySpeechSpellOut: true], range: nsRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
attributedAccessibilityLabel.append(attributedBundleIdentifier)
|
||||||
|
cell.bannerView.accessibilityAttributedLabel = attributedAccessibilityLabel
|
||||||
|
|
||||||
|
// Make sure refresh button is correct size.
|
||||||
|
cell.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataSource
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func fetchAppIDs()
|
||||||
|
{
|
||||||
|
guard !self.isLoading else { return }
|
||||||
|
self.isLoading = true
|
||||||
|
|
||||||
|
AppManager.shared.fetchAppIDs { (result) in
|
||||||
|
do
|
||||||
|
{
|
||||||
|
let (_, context) = try result.get()
|
||||||
|
try context.save()
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let toastView = ToastView(error: error)
|
||||||
|
toastView.show(in: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func update()
|
||||||
|
{
|
||||||
|
if !self.isLoading
|
||||||
|
{
|
||||||
|
self.collectionView.refreshControl?.endRefreshing()
|
||||||
|
self.activityIndicatorBarButtonItem.isIndicatingActivity = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppIDsViewController: UICollectionViewDelegateFlowLayout
|
||||||
|
{
|
||||||
|
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
|
||||||
|
{
|
||||||
|
return CGSize(width: collectionView.bounds.width, height: 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize
|
||||||
|
{
|
||||||
|
// let indexPath = IndexPath(row: 0, section: section)
|
||||||
|
// let headerView = self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionHeader, at: indexPath)
|
||||||
|
|
||||||
|
// // Use this view to calculate the optimal size based on the collection view's width
|
||||||
|
// let size = headerView.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingCompressedSize.height),
|
||||||
|
// withHorizontalFittingPriority: .required, // Width is fixed
|
||||||
|
// verticalFittingPriority: .fittingSizeLevel) // Height can be as large as needed
|
||||||
|
// return size
|
||||||
|
|
||||||
|
// NOTE: double dequeue of cell has been discontinued
|
||||||
|
// TODO: Using harcoded value until this is fixed
|
||||||
|
return CGSize(width: collectionView.bounds.width, height: 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize
|
||||||
|
{
|
||||||
|
return CGSize(width: collectionView.bounds.width, height: 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
|
||||||
|
{
|
||||||
|
switch kind
|
||||||
|
{
|
||||||
|
case UICollectionView.elementKindSectionHeader:
|
||||||
|
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Header", for: indexPath) as! TextCollectionReusableView
|
||||||
|
headerView.layoutMargins.left = self.view.layoutMargins.left
|
||||||
|
headerView.layoutMargins.right = self.view.layoutMargins.right
|
||||||
|
|
||||||
|
if let activeTeam = DatabaseManager.shared.activeTeam(), activeTeam.type == .free
|
||||||
|
{
|
||||||
|
let text = NSLocalizedString("""
|
||||||
|
Each app and app extension installed with SideStore must register an App ID with Apple. Apple limits non-developer Apple IDs to 10 App IDs at a time.
|
||||||
|
|
||||||
|
**App IDs can't be deleted**, but they do expire after one week. SideStore will automatically renew App IDs for all active apps once they've expired.
|
||||||
|
""", comment: "")
|
||||||
|
|
||||||
|
let attributedText = NSAttributedString(markdownRepresentation: text, attributes: [.font: headerView.textLabel.font as Any])
|
||||||
|
headerView.textLabel.attributedText = attributedText
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
headerView.textLabel.text = NSLocalizedString("""
|
||||||
|
Each app and app extension installed with SideStore must register an App ID with Apple.
|
||||||
|
|
||||||
|
App IDs for paid developer accounts never expire, and there is no limit to how many you can create.
|
||||||
|
""", comment: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return headerView
|
||||||
|
|
||||||
|
case UICollectionView.elementKindSectionFooter:
|
||||||
|
let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Footer", for: indexPath) as! TextCollectionReusableView
|
||||||
|
|
||||||
|
let count = self.dataSource.itemCount
|
||||||
|
if count == 1
|
||||||
|
{
|
||||||
|
footerView.textLabel.text = NSLocalizedString("1 App ID", comment: "")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
footerView.textLabel.text = String(format: NSLocalizedString("%@ App IDs", comment: ""), NSNumber(value: count))
|
||||||
|
}
|
||||||
|
|
||||||
|
return footerView
|
||||||
|
|
||||||
|
default: fatalError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,61 +9,98 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
import Intents
|
||||||
|
|
||||||
|
import AltStoreCore
|
||||||
import AltSign
|
import AltSign
|
||||||
import AltKit
|
|
||||||
import Roxas
|
import Roxas
|
||||||
|
|
||||||
private enum RefreshError: LocalizedError
|
import Nuke
|
||||||
{
|
|
||||||
case noInstalledApps
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self
|
|
||||||
{
|
|
||||||
case .noInstalledApps: return NSLocalizedString("No installed apps to refresh.", comment: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension CFNotificationName
|
extension UIApplication: LegacyBackgroundFetching {}
|
||||||
{
|
|
||||||
static let requestAppState = CFNotificationName("com.altstore.RequestAppState" as CFString)
|
|
||||||
static let appIsRunning = CFNotificationName("com.altstore.AppState.Running" as CFString)
|
|
||||||
|
|
||||||
static func requestAppState(for appID: String) -> CFNotificationName
|
|
||||||
{
|
|
||||||
let name = String(CFNotificationName.requestAppState.rawValue) + "." + appID
|
|
||||||
return CFNotificationName(name as CFString)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func appIsRunning(for appID: String) -> CFNotificationName
|
|
||||||
{
|
|
||||||
let name = String(CFNotificationName.appIsRunning.rawValue) + "." + appID
|
|
||||||
return CFNotificationName(name as CFString)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private let ReceivedApplicationState: @convention(c) (CFNotificationCenter?, UnsafeMutableRawPointer?, CFNotificationName?, UnsafeRawPointer?, CFDictionary?) -> Void =
|
extension AppDelegate
|
||||||
{ (center, observer, name, object, userInfo) in
|
{
|
||||||
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate, let name = name else { return }
|
static let openPatreonSettingsDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".OpenPatreonSettingsDeepLinkNotification")
|
||||||
appDelegate.receivedApplicationState(notification: name)
|
static let importAppDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".ImportAppDeepLinkNotification")
|
||||||
|
static let addSourceDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".AddSourceDeepLinkNotification")
|
||||||
|
|
||||||
|
static let appBackupDidFinish = Notification.Name(Bundle.Info.appbundleIdentifier + ".AppBackupDidFinish")
|
||||||
|
static let exportCertificateNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".ExportCertificateNotification")
|
||||||
|
|
||||||
|
static let importAppDeepLinkURLKey = "fileURL"
|
||||||
|
static let appBackupResultKey = "result"
|
||||||
|
static let addSourceDeepLinkURLKey = "sourceURL"
|
||||||
|
static let exportCertificateCallbackTemplateKey = "callback"
|
||||||
}
|
}
|
||||||
|
|
||||||
@UIApplicationMain
|
@UIApplicationMain
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
|
|
||||||
private var runningApplications: Set<String>?
|
private let intentHandler = IntentHandler()
|
||||||
|
private let viewAppIntentHandler = ViewAppIntentHandler()
|
||||||
|
|
||||||
|
public let consoleLog = ConsoleLog()
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool
|
||||||
{
|
{
|
||||||
|
// navigation bar buttons spacing is too much (so hack it to use minimal spacing)
|
||||||
|
// this is swift-5 specific behavior and might change
|
||||||
|
// https://stackoverflow.com/a/64988363/11971304
|
||||||
|
//
|
||||||
|
// Warning: this affects all screens through out the app, and basically overrides storyboard
|
||||||
|
let stackViewAppearance = UIStackView.appearance(whenContainedInInstancesOf: [UINavigationBar.self])
|
||||||
|
stackViewAppearance.spacing = -8 // adjust as needed
|
||||||
|
|
||||||
|
consoleLog.startCapturing()
|
||||||
|
print("===================================================")
|
||||||
|
print("| App is Starting up |")
|
||||||
|
print("===================================================")
|
||||||
|
print("| Console Logger started capturing output streams |")
|
||||||
|
print("===================================================")
|
||||||
|
print("\n ")
|
||||||
|
|
||||||
|
// Override point for customization after application launch.
|
||||||
|
// UserDefaults.standard.setValue(true, forKey: "com.apple.CoreData.MigrationDebug")
|
||||||
|
// UserDefaults.standard.setValue(true, forKey: "com.apple.CoreData.SQLDebug")
|
||||||
|
|
||||||
|
// Register default settings before doing anything else.
|
||||||
|
UserDefaults.registerDefaults()
|
||||||
|
|
||||||
|
|
||||||
|
// Recreate Database if requested
|
||||||
|
// NOTE: Userdefaults are local to the SideStore.app sandbox and are not shared
|
||||||
|
if UserDefaults.standard.recreateDatabaseOnNextStart{
|
||||||
|
// reset the state
|
||||||
|
UserDefaults.standard.recreateDatabaseOnNextStart = false
|
||||||
|
|
||||||
|
// re-create database
|
||||||
|
DatabaseManager.recreateDatabase()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DatabaseManager.shared.start { (error) in
|
||||||
|
if let error = error
|
||||||
|
{
|
||||||
|
print("Failed to start DatabaseManager. Error:", error as Any)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
print("Started DatabaseManager.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnalyticsManager.shared.start()
|
||||||
|
|
||||||
self.setTintColor()
|
self.setTintColor()
|
||||||
|
self.prepareImageCache()
|
||||||
ServerManager.shared.startDiscovering()
|
|
||||||
|
// TODO: @mahee96: find if we need to start em_proxy as in altstore?
|
||||||
UserDefaults.standard.registerDefaults()
|
// start_em_proxy(bind_addr: Consts.Proxy.serverURL)
|
||||||
|
|
||||||
|
SecureValueTransformer.register()
|
||||||
|
|
||||||
if UserDefaults.standard.firstLaunch == nil
|
if UserDefaults.standard.firstLaunch == nil
|
||||||
{
|
{
|
||||||
@@ -73,6 +110,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
|
|
||||||
UserDefaults.standard.preferredServerID = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.serverID) as? String
|
UserDefaults.standard.preferredServerID = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.serverID) as? String
|
||||||
|
|
||||||
|
#if DEBUG && targetEnvironment(simulator)
|
||||||
|
UserDefaults.standard.isDebugModeEnabled = true
|
||||||
|
#endif
|
||||||
|
|
||||||
self.prepareForBackgroundFetch()
|
self.prepareForBackgroundFetch()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -80,16 +121,71 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
|
|
||||||
func applicationDidEnterBackground(_ application: UIApplication)
|
func applicationDidEnterBackground(_ application: UIApplication)
|
||||||
{
|
{
|
||||||
ServerManager.shared.stopDiscovering()
|
// Make sure to update SceneDelegate.sceneDidEnterBackground() as well.
|
||||||
|
// TODO: @mahee96: find if we need to stop em_proxy as in altstore?
|
||||||
|
// stop_em_proxy()
|
||||||
|
guard let oneMonthAgo = Calendar.current.date(byAdding: .month, value: -1, to: Date()) else { return }
|
||||||
|
|
||||||
|
let midnightOneMonthAgo = Calendar.current.startOfDay(for: oneMonthAgo)
|
||||||
|
DatabaseManager.shared.purgeLoggedErrors(before: midnightOneMonthAgo) { result in
|
||||||
|
switch result
|
||||||
|
{
|
||||||
|
case .success: break
|
||||||
|
case .failure(let error): print("[ALTLog] Failed to purge logged errors before \(midnightOneMonthAgo).", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillEnterForeground(_ application: UIApplication)
|
func applicationWillEnterForeground(_ application: UIApplication)
|
||||||
{
|
{
|
||||||
AppManager.shared.update()
|
AppManager.shared.update()
|
||||||
ServerManager.shared.startDiscovering()
|
|
||||||
|
|
||||||
PatreonAPI.shared.refreshPatreonAccount()
|
PatreonAPI.shared.refreshPatreonAccount()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) -> Bool
|
||||||
|
{
|
||||||
|
return self.open(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, handlerFor intent: INIntent) -> Any?
|
||||||
|
{
|
||||||
|
switch intent
|
||||||
|
{
|
||||||
|
case is RefreshAllIntent: return self.intentHandler
|
||||||
|
case is ViewAppIntent: return self.viewAppIntentHandler
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationWillTerminate(_ application: UIApplication) {
|
||||||
|
// Stop console logging and clean up resources
|
||||||
|
print("\n ")
|
||||||
|
print("===================================================")
|
||||||
|
print("| Console Logger stopped capturing output streams |")
|
||||||
|
print("===================================================")
|
||||||
|
print("| App is being terminated |")
|
||||||
|
print("===================================================")
|
||||||
|
consoleLog.stopCapturing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppDelegate
|
||||||
|
{
|
||||||
|
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration
|
||||||
|
{
|
||||||
|
// Called when a new scene session is being created.
|
||||||
|
// Use this method to select a configuration to create the new scene with.
|
||||||
|
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>)
|
||||||
|
{
|
||||||
|
// Called when the user discards a scene session.
|
||||||
|
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
|
||||||
|
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension AppDelegate
|
private extension AppDelegate
|
||||||
@@ -98,6 +194,128 @@ private extension AppDelegate
|
|||||||
{
|
{
|
||||||
self.window?.tintColor = .altPrimary
|
self.window?.tintColor = .altPrimary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prepareImageCache()
|
||||||
|
{
|
||||||
|
// Avoid caching responses twice.
|
||||||
|
DataLoader.sharedUrlCache.diskCapacity = 0
|
||||||
|
|
||||||
|
let pipeline = ImagePipeline { configuration in
|
||||||
|
do
|
||||||
|
{
|
||||||
|
let dataCache = try DataCache(name: "io.sidestore.Nuke")
|
||||||
|
dataCache.sizeLimit = 512 * 1024 * 1024 // 512MB
|
||||||
|
|
||||||
|
configuration.dataCache = dataCache
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Logger.main.error("Failed to create image disk cache. Falling back to URL cache. \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImagePipeline.shared = pipeline
|
||||||
|
|
||||||
|
if let dataCache = ImagePipeline.shared.configuration.dataCache as? DataCache, #available(iOS 15, *)
|
||||||
|
{
|
||||||
|
Logger.main.info("Current image cache size: \(dataCache.totalSize.formatted(.byteCount(style: .file)), privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func open(_ url: URL) -> Bool
|
||||||
|
{
|
||||||
|
if url.isFileURL
|
||||||
|
{
|
||||||
|
guard url.pathExtension.lowercased() == "ipa" else { return false }
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(name: AppDelegate.importAppDeepLinkNotification, object: nil, userInfo: [AppDelegate.importAppDeepLinkURLKey: url])
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false }
|
||||||
|
guard let host = components.host?.lowercased() else { return false }
|
||||||
|
|
||||||
|
switch host
|
||||||
|
{
|
||||||
|
case "patreon":
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(name: AppDelegate.openPatreonSettingsDeepLinkNotification, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
|
||||||
|
case "appbackupresponse":
|
||||||
|
let result: Result<Void, Error>
|
||||||
|
|
||||||
|
switch url.path.lowercased()
|
||||||
|
{
|
||||||
|
case "/success": result = .success(())
|
||||||
|
case "/failure":
|
||||||
|
let queryItems = components.queryItems?.reduce(into: [String: String]()) { $0[$1.name] = $1.value } ?? [:]
|
||||||
|
guard
|
||||||
|
let errorDomain = queryItems["errorDomain"],
|
||||||
|
let errorCodeString = queryItems["errorCode"], let errorCode = Int(errorCodeString),
|
||||||
|
let errorDescription = queryItems["errorDescription"]
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
let error = NSError(domain: errorDomain, code: errorCode, userInfo: [NSLocalizedDescriptionKey: errorDescription])
|
||||||
|
result = .failure(error)
|
||||||
|
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.post(name: AppDelegate.appBackupDidFinish, object: nil, userInfo: [AppDelegate.appBackupResultKey: result])
|
||||||
|
|
||||||
|
return true
|
||||||
|
|
||||||
|
case "install":
|
||||||
|
let queryItems = components.queryItems?.reduce(into: [String: String]()) { $0[$1.name.lowercased()] = $1.value } ?? [:]
|
||||||
|
guard let downloadURLString = queryItems["url"], let downloadURL = URL(string: downloadURLString) else { return false }
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(name: AppDelegate.importAppDeepLinkNotification, object: nil, userInfo: [AppDelegate.importAppDeepLinkURLKey: downloadURL])
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
|
||||||
|
case "source":
|
||||||
|
let queryItems = components.queryItems?.reduce(into: [String: String]()) { $0[$1.name.lowercased()] = $1.value } ?? [:]
|
||||||
|
guard let sourceURLString = queryItems["url"], let sourceURL = URL(string: sourceURLString) else { return false }
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(name: AppDelegate.addSourceDeepLinkNotification, object: nil, userInfo: [AppDelegate.addSourceDeepLinkURLKey: sourceURL])
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
|
||||||
|
case "pairing":
|
||||||
|
let queryItems = components.queryItems?.reduce(into: [String: String]()) { $0[$1.name.lowercased()] = $1.value } ?? [:]
|
||||||
|
guard let callbackTemplate = queryItems["urlName"]?.removingPercentEncoding else { return false }
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
exportPairingFile(callbackTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
|
||||||
|
case "certificate":
|
||||||
|
let queryItems = components.queryItems?.reduce(into: [String: String]()) { $0[$1.name.lowercased()] = $1.value } ?? [:]
|
||||||
|
guard let callbackTemplate = queryItems["callback_template"]?.removingPercentEncoding else { return false }
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(name: AppDelegate.exportCertificateNotification, object: nil, userInfo: [AppDelegate.exportCertificateCallbackTemplateKey: callbackTemplate])
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AppDelegate
|
extension AppDelegate
|
||||||
@@ -105,12 +323,12 @@ extension AppDelegate
|
|||||||
private func prepareForBackgroundFetch()
|
private func prepareForBackgroundFetch()
|
||||||
{
|
{
|
||||||
// "Fetch" every hour, but then refresh only those that need to be refreshed (so we don't drain the battery).
|
// "Fetch" every hour, but then refresh only those that need to be refreshed (so we don't drain the battery).
|
||||||
UIApplication.shared.setMinimumBackgroundFetchInterval(1 * 60 * 60)
|
(UIApplication.shared as LegacyBackgroundFetching).setMinimumBackgroundFetchInterval(1 * 60 * 60)
|
||||||
|
|
||||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { (success, error) in
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { (success, error) in
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG && targetEnvironment(simulator)
|
||||||
UIApplication.shared.registerForRemoteNotifications()
|
UIApplication.shared.registerForRemoteNotifications()
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -132,83 +350,99 @@ extension AppDelegate
|
|||||||
|
|
||||||
func application(_ application: UIApplication, performFetchWithCompletionHandler backgroundFetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void)
|
func application(_ application: UIApplication, performFetchWithCompletionHandler backgroundFetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void)
|
||||||
{
|
{
|
||||||
if UserDefaults.standard.isBackgroundRefreshEnabled
|
if UserDefaults.standard.isBackgroundRefreshEnabled && !UserDefaults.standard.presentedLaunchReminderNotification
|
||||||
{
|
{
|
||||||
ServerManager.shared.startDiscovering()
|
let threeHours: TimeInterval = 3 * 60 * 60
|
||||||
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: threeHours, repeats: false)
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = NSLocalizedString("App Refresh Tip", comment: "")
|
||||||
|
content.body = NSLocalizedString("The more you open SideStore, the more chances it's given to refresh apps in the background.", comment: "")
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(identifier: "background-refresh-reminder5", content: content, trigger: trigger)
|
||||||
|
UNUserNotificationCenter.current().add(request)
|
||||||
|
|
||||||
|
UserDefaults.standard.presentedLaunchReminderNotification = true
|
||||||
}
|
}
|
||||||
|
|
||||||
let refreshIdentifier = UUID().uuidString
|
|
||||||
|
|
||||||
BackgroundTaskManager.shared.performExtendedBackgroundTask { (taskResult, taskCompletionHandler) in
|
BackgroundTaskManager.shared.performExtendedBackgroundTask { (taskResult, taskCompletionHandler) in
|
||||||
|
|
||||||
func finish(_ result: Result<[String: Result<InstalledApp, Error>], Error>)
|
|
||||||
{
|
|
||||||
// If finish is actually called, that means an error occured during installation.
|
|
||||||
|
|
||||||
if UserDefaults.standard.isBackgroundRefreshEnabled
|
|
||||||
{
|
|
||||||
ServerManager.shared.stopDiscovering()
|
|
||||||
self.scheduleFinishedRefreshingNotification(for: result, identifier: refreshIdentifier, delay: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
taskCompletionHandler()
|
|
||||||
}
|
|
||||||
|
|
||||||
if let error = taskResult.error
|
if let error = taskResult.error
|
||||||
{
|
{
|
||||||
print("Error starting extended background task. Aborting.", error)
|
print("Error starting extended background task. Aborting.", error)
|
||||||
backgroundFetchCompletionHandler(.failed)
|
backgroundFetchCompletionHandler(.failed)
|
||||||
finish(.failure(error))
|
taskCompletionHandler()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !DatabaseManager.shared.isStarted
|
if !DatabaseManager.shared.isStarted
|
||||||
{
|
{
|
||||||
DatabaseManager.shared.start() { (error) in
|
DatabaseManager.shared.start() { (error) in
|
||||||
if let error = error
|
if error != nil
|
||||||
{
|
{
|
||||||
backgroundFetchCompletionHandler(.failed)
|
backgroundFetchCompletionHandler(.failed)
|
||||||
finish(.failure(error))
|
taskCompletionHandler()
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
self.refreshApps(identifier: refreshIdentifier, backgroundFetchCompletionHandler: backgroundFetchCompletionHandler, completionHandler: finish(_:))
|
self.performBackgroundFetch { (backgroundFetchResult) in
|
||||||
|
backgroundFetchCompletionHandler(backgroundFetchResult)
|
||||||
|
} refreshAppsCompletionHandler: { (refreshAppsResult) in
|
||||||
|
taskCompletionHandler()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
self.refreshApps(identifier: refreshIdentifier, backgroundFetchCompletionHandler: backgroundFetchCompletionHandler, completionHandler: finish(_:))
|
self.performBackgroundFetch { (backgroundFetchResult) in
|
||||||
|
backgroundFetchCompletionHandler(backgroundFetchResult)
|
||||||
|
} refreshAppsCompletionHandler: { (refreshAppsResult) in
|
||||||
|
taskCompletionHandler()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func performBackgroundFetch(backgroundFetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void,
|
||||||
|
refreshAppsCompletionHandler: @escaping (Result<[String: Result<InstalledApp, Error>], Error>) -> Void)
|
||||||
|
{
|
||||||
|
self.fetchSources { (result) in
|
||||||
|
switch result
|
||||||
|
{
|
||||||
|
case .failure: backgroundFetchCompletionHandler(.failed)
|
||||||
|
case .success: backgroundFetchCompletionHandler(.newData)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !UserDefaults.standard.isBackgroundRefreshEnabled
|
||||||
|
{
|
||||||
|
refreshAppsCompletionHandler(.success([:]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard UserDefaults.standard.isBackgroundRefreshEnabled else { return }
|
||||||
|
|
||||||
|
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
||||||
|
let installedApps = InstalledApp.fetchAppsForBackgroundRefresh(in: context)
|
||||||
|
AppManager.shared.backgroundRefresh(installedApps, completionHandler: refreshAppsCompletionHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension AppDelegate
|
private extension AppDelegate
|
||||||
{
|
{
|
||||||
func refreshApps(identifier: String,
|
func fetchSources(completionHandler: @escaping (Result<Set<Source>, Error>) -> Void)
|
||||||
backgroundFetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void,
|
|
||||||
completionHandler: @escaping (Result<[String: Result<InstalledApp, Error>], Error>) -> Void)
|
|
||||||
{
|
{
|
||||||
var fetchSourceResult: Result<Source, Error>?
|
AppManager.shared.fetchSources() { (result) in
|
||||||
var serversResult: Result<Void, Error>?
|
|
||||||
|
|
||||||
let dispatchGroup = DispatchGroup()
|
|
||||||
dispatchGroup.enter()
|
|
||||||
|
|
||||||
AppManager.shared.fetchSource() { (result) in
|
|
||||||
fetchSourceResult = result
|
|
||||||
|
|
||||||
do
|
do
|
||||||
{
|
{
|
||||||
let source = try result.get()
|
let (sources, context) = try result.get()
|
||||||
|
|
||||||
guard let context = source.managedObjectContext else { return }
|
let previousUpdatesFetchRequest = InstalledApp.supportedUpdatesFetchRequest() as! NSFetchRequest<NSFetchRequestResult>
|
||||||
|
|
||||||
let previousUpdatesFetchRequest = InstalledApp.updatesFetchRequest() as! NSFetchRequest<NSFetchRequestResult>
|
|
||||||
previousUpdatesFetchRequest.includesPendingChanges = false
|
previousUpdatesFetchRequest.includesPendingChanges = false
|
||||||
previousUpdatesFetchRequest.resultType = .dictionaryResultType
|
previousUpdatesFetchRequest.resultType = .dictionaryResultType
|
||||||
previousUpdatesFetchRequest.propertiesToFetch = [#keyPath(InstalledApp.bundleIdentifier)]
|
previousUpdatesFetchRequest.propertiesToFetch = [#keyPath(InstalledApp.bundleIdentifier),
|
||||||
|
#keyPath(InstalledApp.storeApp.latestSupportedVersion.version),
|
||||||
|
#keyPath(InstalledApp.storeApp.latestSupportedVersion._buildVersion)]
|
||||||
|
|
||||||
let previousNewsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NSFetchRequestResult>
|
let previousNewsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NSFetchRequestResult>
|
||||||
previousNewsItemsFetchRequest.includesPendingChanges = false
|
previousNewsItemsFetchRequest.includesPendingChanges = false
|
||||||
@@ -220,7 +454,9 @@ private extension AppDelegate
|
|||||||
|
|
||||||
try context.save()
|
try context.save()
|
||||||
|
|
||||||
let updatesFetchRequest = InstalledApp.updatesFetchRequest()
|
|
||||||
|
|
||||||
|
let updatesFetchRequest = InstalledApp.supportedUpdatesFetchRequest()
|
||||||
let newsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NewsItem>
|
let newsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NewsItem>
|
||||||
|
|
||||||
let updates = try context.fetch(updatesFetchRequest)
|
let updates = try context.fetch(updatesFetchRequest)
|
||||||
@@ -228,12 +464,24 @@ private extension AppDelegate
|
|||||||
|
|
||||||
for update in updates
|
for update in updates
|
||||||
{
|
{
|
||||||
guard !previousUpdates.contains(where: { $0[#keyPath(InstalledApp.bundleIdentifier)] == update.bundleIdentifier }) else { continue }
|
guard let storeApp = update.storeApp, let latestSupportedVersion = storeApp.latestSupportedVersion, latestSupportedVersion.isSupported else { continue }
|
||||||
guard let storeApp = update.storeApp else { continue }
|
|
||||||
|
if let previousUpdate = previousUpdates.first(where: { $0[#keyPath(InstalledApp.bundleIdentifier)] == update.bundleIdentifier })
|
||||||
|
{
|
||||||
|
// An update for this app was already available, so check whether the version or build version is different.
|
||||||
|
guard let previousVersion = previousUpdate[#keyPath(InstalledApp.storeApp.latestSupportedVersion.version)] else { continue }
|
||||||
|
|
||||||
|
// previousUpdate might not contain buildVersion, but if it does then map empty string to nil to match AppVersion.
|
||||||
|
let previousBuildVersion = previousUpdate[#keyPath(InstalledApp.storeApp.latestSupportedVersion._buildVersion)].map { $0.isEmpty ? nil : "" }
|
||||||
|
|
||||||
|
// Only show notification if previous latestSupportedVersion does not _exactly_ match current latestSupportedVersion.
|
||||||
|
guard previousVersion != latestSupportedVersion.version || previousBuildVersion != latestSupportedVersion.buildVersion else { continue }
|
||||||
|
}
|
||||||
|
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = NSLocalizedString("New Update Available", comment: "")
|
content.title = NSLocalizedString("New Update Available", comment: "")
|
||||||
content.body = String(format: NSLocalizedString("%@ %@ is now available for download.", comment: ""), update.name, storeApp.version)
|
content.body = String(format: NSLocalizedString("%@ %@ is now available for download.", comment: ""), update.name, latestSupportedVersion.localizedVersion)
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||||
UNUserNotificationCenter.current().add(request)
|
UNUserNotificationCenter.current().add(request)
|
||||||
@@ -252,10 +500,11 @@ private extension AppDelegate
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
content.title = NSLocalizedString("AltStore News", comment: "")
|
content.title = NSLocalizedString("SideStore News", comment: "")
|
||||||
}
|
}
|
||||||
|
|
||||||
content.body = newsItem.title
|
content.body = newsItem.title
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||||
UNUserNotificationCenter.current().add(request)
|
UNUserNotificationCenter.current().add(request)
|
||||||
@@ -264,223 +513,14 @@ private extension AppDelegate
|
|||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
UIApplication.shared.applicationIconBadgeNumber = updates.count
|
UIApplication.shared.applicationIconBadgeNumber = updates.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
completionHandler(.success(sources))
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
print("Error fetching apps:", error)
|
print("Error fetching apps:", error)
|
||||||
|
completionHandler(.failure(error))
|
||||||
fetchSourceResult = .failure(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatchGroup.leave()
|
|
||||||
}
|
|
||||||
|
|
||||||
if UserDefaults.standard.isBackgroundRefreshEnabled
|
|
||||||
{
|
|
||||||
dispatchGroup.enter()
|
|
||||||
|
|
||||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
|
||||||
|
|
||||||
let installedApps = InstalledApp.fetchAppsForBackgroundRefresh(in: context)
|
|
||||||
guard !installedApps.isEmpty else {
|
|
||||||
serversResult = .success(())
|
|
||||||
dispatchGroup.leave()
|
|
||||||
|
|
||||||
completionHandler(.failure(RefreshError.noInstalledApps))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.runningApplications = []
|
|
||||||
|
|
||||||
let identifiers = installedApps.compactMap { $0.bundleIdentifier }
|
|
||||||
print("Apps to refresh:", identifiers)
|
|
||||||
|
|
||||||
DispatchQueue.global().async {
|
|
||||||
let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter()
|
|
||||||
|
|
||||||
for identifier in identifiers
|
|
||||||
{
|
|
||||||
let appIsRunningNotification = CFNotificationName.appIsRunning(for: identifier)
|
|
||||||
CFNotificationCenterAddObserver(notificationCenter, nil, ReceivedApplicationState, appIsRunningNotification.rawValue, nil, .deliverImmediately)
|
|
||||||
|
|
||||||
let requestAppStateNotification = CFNotificationName.requestAppState(for: identifier)
|
|
||||||
CFNotificationCenterPostNotification(notificationCenter, requestAppStateNotification, nil, nil, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for three seconds to:
|
|
||||||
// a) give us time to discover AltServers
|
|
||||||
// b) give other processes a chance to respond to requestAppState notification
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
|
|
||||||
context.perform {
|
|
||||||
if ServerManager.shared.discoveredServers.isEmpty
|
|
||||||
{
|
|
||||||
serversResult = .failure(ConnectionError.serverNotFound)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
serversResult = .success(())
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatchGroup.leave()
|
|
||||||
|
|
||||||
let filteredApps = installedApps.filter { !(self.runningApplications?.contains($0.bundleIdentifier) ?? false) }
|
|
||||||
print("Filtered Apps to Refresh:", filteredApps.map { $0.bundleIdentifier })
|
|
||||||
|
|
||||||
let group = AppManager.shared.refresh(filteredApps, presentingViewController: nil)
|
|
||||||
group.beginInstallationHandler = { (installedApp) in
|
|
||||||
guard installedApp.bundleIdentifier == StoreApp.altstoreAppID else { return }
|
|
||||||
|
|
||||||
// We're starting to install AltStore, which means the app is about to quit.
|
|
||||||
// So, we schedule a "refresh successful" local notification to be displayed after a delay,
|
|
||||||
// but if the app is still running, we cancel the notification.
|
|
||||||
// Then, we schedule another notification and repeat the process.
|
|
||||||
|
|
||||||
// Also since AltServer has already received the app, it can finish installing even if we're no longer running in background.
|
|
||||||
|
|
||||||
if let error = group.error
|
|
||||||
{
|
|
||||||
self.scheduleFinishedRefreshingNotification(for: .failure(error), identifier: identifier)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var results = group.results
|
|
||||||
results[installedApp.bundleIdentifier] = .success(installedApp)
|
|
||||||
|
|
||||||
self.scheduleFinishedRefreshingNotification(for: .success(results), identifier: identifier)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
group.completionHandler = { (result) in
|
|
||||||
completionHandler(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatchGroup.notify(queue: .main) {
|
|
||||||
if !UserDefaults.standard.isBackgroundRefreshEnabled
|
|
||||||
{
|
|
||||||
guard let fetchSourceResult = fetchSourceResult else {
|
|
||||||
backgroundFetchCompletionHandler(.failed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch fetchSourceResult
|
|
||||||
{
|
|
||||||
case .failure: backgroundFetchCompletionHandler(.failed)
|
|
||||||
case .success: backgroundFetchCompletionHandler(.newData)
|
|
||||||
}
|
|
||||||
|
|
||||||
completionHandler(.success([:]))
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
guard let fetchSourceResult = fetchSourceResult, let serversResult = serversResult else {
|
|
||||||
backgroundFetchCompletionHandler(.failed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call completionHandler early to improve chances of refreshing in the background again.
|
|
||||||
switch (fetchSourceResult, serversResult)
|
|
||||||
{
|
|
||||||
case (.success, .success): backgroundFetchCompletionHandler(.newData)
|
|
||||||
case (.success, .failure(ConnectionError.serverNotFound)): backgroundFetchCompletionHandler(.newData)
|
|
||||||
case (.failure, _), (_, .failure): backgroundFetchCompletionHandler(.failed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func receivedApplicationState(notification: CFNotificationName)
|
|
||||||
{
|
|
||||||
let baseName = String(CFNotificationName.appIsRunning.rawValue)
|
|
||||||
|
|
||||||
let appID = String(notification.rawValue).replacingOccurrences(of: baseName + ".", with: "")
|
|
||||||
self.runningApplications?.insert(appID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func scheduleFinishedRefreshingNotification(for result: Result<[String: Result<InstalledApp, Error>], Error>, identifier: String, delay: TimeInterval = 5)
|
|
||||||
{
|
|
||||||
func scheduleFinishedRefreshingNotification()
|
|
||||||
{
|
|
||||||
self.cancelFinishedRefreshingNotification(identifier: identifier)
|
|
||||||
|
|
||||||
let content = UNMutableNotificationContent()
|
|
||||||
|
|
||||||
var shouldPresentAlert = true
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let results = try result.get()
|
|
||||||
shouldPresentAlert = !results.isEmpty
|
|
||||||
|
|
||||||
for (_, result) in results
|
|
||||||
{
|
|
||||||
guard case let .failure(error) = result else { continue }
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
content.title = NSLocalizedString("Refreshed Apps", comment: "")
|
|
||||||
content.body = NSLocalizedString("All apps have been refreshed.", comment: "")
|
|
||||||
}
|
|
||||||
catch ConnectionError.serverNotFound
|
|
||||||
{
|
|
||||||
shouldPresentAlert = false
|
|
||||||
}
|
|
||||||
catch RefreshError.noInstalledApps
|
|
||||||
{
|
|
||||||
shouldPresentAlert = false
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
print("Failed to refresh apps in background.", error)
|
|
||||||
|
|
||||||
content.title = NSLocalizedString("Failed to Refresh Apps", comment: "")
|
|
||||||
content.body = error.localizedDescription
|
|
||||||
|
|
||||||
shouldPresentAlert = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if shouldPresentAlert
|
|
||||||
{
|
|
||||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay + 1, repeats: false)
|
|
||||||
|
|
||||||
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
|
|
||||||
UNUserNotificationCenter.current().add(request)
|
|
||||||
|
|
||||||
if delay > 0
|
|
||||||
{
|
|
||||||
DispatchQueue.global().asyncAfter(deadline: .now() + delay) {
|
|
||||||
UNUserNotificationCenter.current().getPendingNotificationRequests() { (requests) in
|
|
||||||
// If app is still running at this point, we schedule another notification with same identifier.
|
|
||||||
// This prevents the currently scheduled notification from displaying, and starts another countdown timer.
|
|
||||||
// First though, make sure there _is_ still a pending request, otherwise it's been cancelled
|
|
||||||
// and we should stop polling.
|
|
||||||
guard requests.contains(where: { $0.identifier == identifier }) else { return }
|
|
||||||
|
|
||||||
scheduleFinishedRefreshingNotification()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleFinishedRefreshingNotification()
|
|
||||||
|
|
||||||
// Perform synchronously to ensure app doesn't quit before we've finishing saving to disk.
|
|
||||||
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
|
||||||
context.performAndWait {
|
|
||||||
_ = RefreshAttempt(identifier: identifier, result: result, context: context)
|
|
||||||
|
|
||||||
do { try context.save() }
|
|
||||||
catch { print("Failed to save refresh attempt.", error) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancelFinishedRefreshingNotification(identifier: String)
|
|
||||||
{
|
|
||||||
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [identifier])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||||
<device id="retina4_7" orientation="portrait">
|
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||||
<adaptation id="fullscreen"/>
|
|
||||||
</device>
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<deployment identifier="iOS"/>
|
<deployment identifier="iOS"/>
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14490.49"/>
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
|
||||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
@@ -15,11 +13,11 @@
|
|||||||
<scene sceneID="lNR-II-WoW">
|
<scene sceneID="lNR-II-WoW">
|
||||||
<objects>
|
<objects>
|
||||||
<navigationController storyboardIdentifier="navigationController" id="ZTo-53-dSL" sceneMemberID="viewController">
|
<navigationController storyboardIdentifier="navigationController" id="ZTo-53-dSL" sceneMemberID="viewController">
|
||||||
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" barStyle="black" largeTitles="YES" id="Aej-RF-PfV" customClass="NavigationBar" customModule="AltStore" customModuleProvider="target">
|
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" barStyle="black" largeTitles="YES" id="Aej-RF-PfV" customClass="NavigationBar" customModule="SideStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="20" width="375" height="96"/>
|
<rect key="frame" x="0.0" y="20" width="375" height="96"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<color key="barTintColor" name="Primary"/>
|
<color key="barTintColor" name="SettingsBackground"/>
|
||||||
<textAttributes key="titleTextAttributes">
|
<textAttributes key="titleTextAttributes">
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
</textAttributes>
|
</textAttributes>
|
||||||
@@ -38,7 +36,7 @@
|
|||||||
<!--Authentication View Controller-->
|
<!--Authentication View Controller-->
|
||||||
<scene sceneID="OCd-xc-Ms7">
|
<scene sceneID="OCd-xc-Ms7">
|
||||||
<objects>
|
<objects>
|
||||||
<viewController storyboardIdentifier="authenticationViewController" id="yO1-iT-7NP" customClass="AuthenticationViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
|
<viewController storyboardIdentifier="authenticationViewController" id="yO1-iT-7NP" customClass="AuthenticationViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||||
<view key="view" contentMode="scaleToFill" id="mjy-4S-hyH">
|
<view key="view" contentMode="scaleToFill" id="mjy-4S-hyH">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
@@ -58,16 +56,16 @@
|
|||||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="6" translatesAutoresizingMaskIntoConstraints="NO" id="Yfu-hI-0B7" userLabel="Welcome">
|
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="6" translatesAutoresizingMaskIntoConstraints="NO" id="Yfu-hI-0B7" userLabel="Welcome">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="343" height="67.5"/>
|
<rect key="frame" x="0.0" y="0.0" width="343" height="67.5"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Welcome to AltStore." textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="EI2-V3-zQZ">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Welcome to SideStore." textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="EI2-V3-zQZ">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="333.5" height="41"/>
|
<rect key="frame" x="0.0" y="0.0" width="343" height="41"/>
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="34"/>
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="34"/>
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Sign in with your Apple ID to get started." textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="SNU-tv-8Au">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Sign in with your Apple ID to get started." textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="SNU-tv-8Au">
|
||||||
<rect key="frame" x="0.0" y="47" width="308.5" height="20.5"/>
|
<rect key="frame" x="0.0" y="47" width="306.5" height="20.5"/>
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
</subviews>
|
</subviews>
|
||||||
@@ -162,15 +160,15 @@
|
|||||||
</stackView>
|
</stackView>
|
||||||
</subviews>
|
</subviews>
|
||||||
</stackView>
|
</stackView>
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="2N5-zd-fUj">
|
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="2N5-zd-fUj">
|
||||||
<rect key="frame" x="0.0" y="191" width="343" height="51"/>
|
<rect key="frame" x="0.0" y="191" width="343" height="51"/>
|
||||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="backgroundColor" name="SettingsHighlighted"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="height" constant="51" id="4BK-Un-5pl"/>
|
<constraint firstAttribute="height" constant="51" id="4BK-Un-5pl"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="19"/>
|
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="19"/>
|
||||||
<state key="normal" title="Sign in">
|
<state key="normal" title="Sign in">
|
||||||
<color key="titleColor" name="Pink"/>
|
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
</state>
|
</state>
|
||||||
<connections>
|
<connections>
|
||||||
<action selector="authenticate" destination="yO1-iT-7NP" eventType="primaryActionTriggered" id="LER-a2-CbC"/>
|
<action selector="authenticate" destination="yO1-iT-7NP" eventType="primaryActionTriggered" id="LER-a2-CbC"/>
|
||||||
@@ -180,7 +178,7 @@
|
|||||||
</stackView>
|
</stackView>
|
||||||
</subviews>
|
</subviews>
|
||||||
</stackView>
|
</stackView>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="DBk-rT-ZE8">
|
<stackView opaque="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="250" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="DBk-rT-ZE8">
|
||||||
<rect key="frame" x="16" y="498.5" width="343" height="96.5"/>
|
<rect key="frame" x="16" y="498.5" width="343" height="96.5"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Why do we need this?" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="p9U-0q-Kn8">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Why do we need this?" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="p9U-0q-Kn8">
|
||||||
@@ -189,7 +187,7 @@
|
|||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="on2-62-waY">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="249" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" minimumScaleFactor="0.25" translatesAutoresizingMaskIntoConstraints="NO" id="on2-62-waY">
|
||||||
<rect key="frame" x="0.0" y="24.5" width="343" height="72"/>
|
<rect key="frame" x="0.0" y="24.5" width="343" height="72"/>
|
||||||
<string key="text">Your Apple ID is used to configure apps so they can be installed on this device. Your credentials will be stored securely in this device's Keychain and sent only to Apple for authentication.</string>
|
<string key="text">Your Apple ID is used to configure apps so they can be installed on this device. Your credentials will be stored securely in this device's Keychain and sent only to Apple for authentication.</string>
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="15"/>
|
<fontDescription key="fontDescription" type="system" pointSize="15"/>
|
||||||
@@ -199,6 +197,13 @@
|
|||||||
</subviews>
|
</subviews>
|
||||||
</stackView>
|
</stackView>
|
||||||
</subviews>
|
</subviews>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="DBk-rT-ZE8" firstAttribute="leading" secondItem="2wp-qG-f0Z" secondAttribute="leadingMargin" id="5AT-nV-ZP9"/>
|
||||||
|
<constraint firstAttribute="bottomMargin" secondItem="DBk-rT-ZE8" secondAttribute="bottom" id="HgY-oY-8KM"/>
|
||||||
|
<constraint firstAttribute="trailingMargin" secondItem="DBk-rT-ZE8" secondAttribute="trailing" id="VCf-bW-2K4"/>
|
||||||
|
<constraint firstItem="YmX-7v-pxh" firstAttribute="top" secondItem="2wp-qG-f0Z" secondAttribute="top" constant="6" id="iUr-Nd-tkt"/>
|
||||||
|
<constraint firstItem="DBk-rT-ZE8" firstAttribute="top" relation="greaterThanOrEqual" secondItem="YmX-7v-pxh" secondAttribute="bottom" constant="8" symbolic="YES" id="zTU-eY-DWd"/>
|
||||||
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
</subviews>
|
</subviews>
|
||||||
<constraints>
|
<constraints>
|
||||||
@@ -209,26 +214,22 @@
|
|||||||
</constraints>
|
</constraints>
|
||||||
</scrollView>
|
</scrollView>
|
||||||
</subviews>
|
</subviews>
|
||||||
<color key="backgroundColor" name="Primary"/>
|
<viewLayoutGuide key="safeArea" id="zMn-DV-fpy"/>
|
||||||
|
<color key="backgroundColor" name="SettingsBackground"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="bottom" secondItem="WXx-hX-AXv" secondAttribute="bottom" id="0jL-Ky-ju6"/>
|
<constraint firstAttribute="bottom" secondItem="WXx-hX-AXv" secondAttribute="bottom" id="0jL-Ky-ju6"/>
|
||||||
<constraint firstAttribute="leadingMargin" secondItem="YmX-7v-pxh" secondAttribute="leading" id="2PO-lG-dmB"/>
|
<constraint firstAttribute="leadingMargin" secondItem="YmX-7v-pxh" secondAttribute="leading" id="2PO-lG-dmB"/>
|
||||||
<constraint firstItem="DBk-rT-ZE8" firstAttribute="leading" secondItem="2wp-qG-f0Z" secondAttribute="leadingMargin" id="5AT-nV-ZP9"/>
|
|
||||||
<constraint firstItem="oyW-Fd-ojD" firstAttribute="top" secondItem="zMn-DV-fpy" secondAttribute="top" id="730-db-ukB"/>
|
<constraint firstItem="oyW-Fd-ojD" firstAttribute="top" secondItem="zMn-DV-fpy" secondAttribute="top" id="730-db-ukB"/>
|
||||||
<constraint firstItem="2wp-qG-f0Z" firstAttribute="bottomMargin" secondItem="DBk-rT-ZE8" secondAttribute="bottom" id="HgY-oY-8KM"/>
|
|
||||||
<constraint firstItem="zMn-DV-fpy" firstAttribute="trailing" secondItem="oyW-Fd-ojD" secondAttribute="trailing" id="KGE-CN-SWf"/>
|
<constraint firstItem="zMn-DV-fpy" firstAttribute="trailing" secondItem="oyW-Fd-ojD" secondAttribute="trailing" id="KGE-CN-SWf"/>
|
||||||
<constraint firstItem="WXx-hX-AXv" firstAttribute="top" secondItem="mjy-4S-hyH" secondAttribute="top" id="LPQ-bF-ic0"/>
|
<constraint firstItem="WXx-hX-AXv" firstAttribute="top" secondItem="mjy-4S-hyH" secondAttribute="top" id="LPQ-bF-ic0"/>
|
||||||
<constraint firstItem="zMn-DV-fpy" firstAttribute="trailing" secondItem="WXx-hX-AXv" secondAttribute="trailing" id="MG7-A6-pKp"/>
|
<constraint firstItem="zMn-DV-fpy" firstAttribute="trailing" secondItem="WXx-hX-AXv" secondAttribute="trailing" id="MG7-A6-pKp"/>
|
||||||
<constraint firstAttribute="trailingMargin" secondItem="YmX-7v-pxh" secondAttribute="trailing" id="O4T-nu-o3e"/>
|
<constraint firstAttribute="trailingMargin" secondItem="YmX-7v-pxh" secondAttribute="trailing" id="O4T-nu-o3e"/>
|
||||||
<constraint firstItem="zMn-DV-fpy" firstAttribute="bottom" secondItem="oyW-Fd-ojD" secondAttribute="bottom" id="PuX-ab-cEq"/>
|
<constraint firstItem="zMn-DV-fpy" firstAttribute="bottom" secondItem="oyW-Fd-ojD" secondAttribute="bottom" id="PuX-ab-cEq"/>
|
||||||
<constraint firstItem="oyW-Fd-ojD" firstAttribute="leading" secondItem="zMn-DV-fpy" secondAttribute="leading" id="SzC-gC-Nvi"/>
|
<constraint firstItem="oyW-Fd-ojD" firstAttribute="leading" secondItem="zMn-DV-fpy" secondAttribute="leading" id="SzC-gC-Nvi"/>
|
||||||
<constraint firstItem="2wp-qG-f0Z" firstAttribute="trailingMargin" secondItem="DBk-rT-ZE8" secondAttribute="trailing" id="VCf-bW-2K4"/>
|
|
||||||
<constraint firstItem="WXx-hX-AXv" firstAttribute="leading" secondItem="zMn-DV-fpy" secondAttribute="leading" id="d08-zF-5X6"/>
|
<constraint firstItem="WXx-hX-AXv" firstAttribute="leading" secondItem="zMn-DV-fpy" secondAttribute="leading" id="d08-zF-5X6"/>
|
||||||
<constraint firstItem="2wp-qG-f0Z" firstAttribute="height" secondItem="oyW-Fd-ojD" secondAttribute="height" id="dFN-pw-TWt"/>
|
<constraint firstItem="2wp-qG-f0Z" firstAttribute="height" secondItem="oyW-Fd-ojD" secondAttribute="height" id="dFN-pw-TWt"/>
|
||||||
<constraint firstItem="YmX-7v-pxh" firstAttribute="top" secondItem="2wp-qG-f0Z" secondAttribute="top" constant="6" id="iUr-Nd-tkt"/>
|
|
||||||
<constraint firstItem="2wp-qG-f0Z" firstAttribute="width" secondItem="oyW-Fd-ojD" secondAttribute="width" id="rYO-GN-0Lk"/>
|
<constraint firstItem="2wp-qG-f0Z" firstAttribute="width" secondItem="oyW-Fd-ojD" secondAttribute="width" id="rYO-GN-0Lk"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
<viewLayoutGuide key="safeArea" id="zMn-DV-fpy"/>
|
|
||||||
</view>
|
</view>
|
||||||
<toolbarItems/>
|
<toolbarItems/>
|
||||||
<navigationItem key="navigationItem" largeTitleDisplayMode="never" id="jCf-N4-xVD">
|
<navigationItem key="navigationItem" largeTitleDisplayMode="never" id="jCf-N4-xVD">
|
||||||
@@ -257,7 +258,7 @@
|
|||||||
<!--How it works-->
|
<!--How it works-->
|
||||||
<scene sceneID="dMt-EA-SGy">
|
<scene sceneID="dMt-EA-SGy">
|
||||||
<objects>
|
<objects>
|
||||||
<viewController storyboardIdentifier="instructionsViewController" hidesBottomBarWhenPushed="YES" id="aFi-fb-W0B" customClass="InstructionsViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
|
<viewController storyboardIdentifier="instructionsViewController" hidesBottomBarWhenPushed="YES" id="aFi-fb-W0B" customClass="InstructionsViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||||
<view key="view" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" id="Otz-hn-WGS">
|
<view key="view" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" id="Otz-hn-WGS">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
@@ -265,10 +266,41 @@
|
|||||||
<stackView opaque="NO" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" axis="vertical" distribution="equalSpacing" translatesAutoresizingMaskIntoConstraints="NO" id="bp6-55-IG2">
|
<stackView opaque="NO" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" axis="vertical" distribution="equalSpacing" translatesAutoresizingMaskIntoConstraints="NO" id="bp6-55-IG2">
|
||||||
<rect key="frame" x="0.0" y="64" width="375" height="544"/>
|
<rect key="frame" x="0.0" y="64" width="375" height="544"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="LpI-Jt-SzX">
|
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="FjP-tm-w7K">
|
||||||
<rect key="frame" x="16" y="35" width="343" height="95.5"/>
|
<rect key="frame" x="16" y="35" width="343" height="95.5"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="1" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="0LW-eE-qHa">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="1" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="i9V-3h-B8f">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstAttribute="width" constant="59" id="ILg-0e-PW8"/>
|
||||||
|
</constraints>
|
||||||
|
<fontDescription key="fontDescription" type="system" weight="black" pointSize="80"/>
|
||||||
|
<color key="textColor" white="1" alpha="0.5" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="Q20-ml-9D0">
|
||||||
|
<rect key="frame" x="79" y="16" width="264" height="64"/>
|
||||||
|
<subviews>
|
||||||
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Launch SideStore" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="XKD-XH-eB0">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
|
||||||
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
|
||||||
|
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Leave SideStore running in the background on your idevice." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="6HP-Xh-sAH">
|
||||||
|
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
|
||||||
|
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
||||||
|
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
</subviews>
|
||||||
|
</stackView>
|
||||||
|
</subviews>
|
||||||
|
</stackView>
|
||||||
|
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="LpI-Jt-SzX">
|
||||||
|
<rect key="frame" x="16" y="161" width="343" height="95.5"/>
|
||||||
|
<subviews>
|
||||||
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="2" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="0LW-eE-qHa">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
|
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="width" constant="59" id="HzE-AA-eE5"/>
|
<constraint firstAttribute="width" constant="59" id="HzE-AA-eE5"/>
|
||||||
@@ -280,13 +312,13 @@
|
|||||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="dMu-eg-gIO">
|
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="dMu-eg-gIO">
|
||||||
<rect key="frame" x="79" y="16" width="264" height="64"/>
|
<rect key="frame" x="79" y="16" width="264" height="64"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Launch AltServer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="esj-pD-D4A">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Connect to Wi-Fi and VPN" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="esj-pD-D4A">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
|
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="a.k.a. the Desktop app used to install AltStore." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="4rk-ge-FSj">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Enable StosVPN and be able to use Sidestore on the go." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="4rk-ge-FSj">
|
||||||
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
|
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
||||||
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
@@ -297,9 +329,9 @@
|
|||||||
</subviews>
|
</subviews>
|
||||||
</stackView>
|
</stackView>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="tfb-ja-9UC">
|
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="tfb-ja-9UC">
|
||||||
<rect key="frame" x="16" y="161" width="343" height="95.5"/>
|
<rect key="frame" x="16" y="287.5" width="343" height="95.5"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="2" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="nVr-El-Csi">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="3" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="nVr-El-Csi">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
|
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="width" constant="59" id="fRj-b4-VTe"/>
|
<constraint firstAttribute="width" constant="59" id="fRj-b4-VTe"/>
|
||||||
@@ -309,46 +341,15 @@
|
|||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="z6Y-zi-teL">
|
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="z6Y-zi-teL">
|
||||||
<rect key="frame" x="79" y="16" width="264" height="64"/>
|
|
||||||
<subviews>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Connect to WiFi" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="JeJ-bk-UCA">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
|
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
|
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Connect to the same WiFi as the computer running AltServer." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="M7T-9j-uyt">
|
|
||||||
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
|
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
|
||||||
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
</subviews>
|
|
||||||
</stackView>
|
|
||||||
</subviews>
|
|
||||||
</stackView>
|
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="SF8-an-Pku">
|
|
||||||
<rect key="frame" x="16" y="287.5" width="343" height="95.5"/>
|
|
||||||
<subviews>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="3" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="JJg-LC-FWK">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="width" constant="59" id="XLz-ga-1gX"/>
|
|
||||||
</constraints>
|
|
||||||
<fontDescription key="fontDescription" type="system" weight="black" pointSize="80"/>
|
|
||||||
<color key="textColor" white="1" alpha="0.5" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="roi-ZB-E34">
|
|
||||||
<rect key="frame" x="79" y="15.5" width="264" height="64"/>
|
<rect key="frame" x="79" y="15.5" width="264" height="64"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Download Apps" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="pKZ-nr-AYF">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Download Apps" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="JeJ-bk-UCA">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
|
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Apps will expire after a few days unless refreshed." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="dhL-Pt-4GO">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Browse and download apps directly from SideStore." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="M7T-9j-uyt">
|
||||||
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
|
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
||||||
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
@@ -371,16 +372,16 @@
|
|||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="Xs6-pJ-PUz">
|
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="Xs6-pJ-PUz">
|
||||||
<rect key="frame" x="79" y="16" width="264" height="64"/>
|
<rect key="frame" x="79" y="17" width="264" height="62"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Prevent Apps From Expiring" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="nvb-Aq-sYa">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Apps Refresh Automatically" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="nvb-Aq-sYa">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
|
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Leave AltServer running so AltStore can refresh apps in the background." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="HU5-Hv-E3d">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Apps are refreshed in the background while you are on SideStore VPN!" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="HU5-Hv-E3d">
|
||||||
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
|
<rect key="frame" x="0.0" y="25.5" width="264" height="36.5"/>
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
||||||
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
@@ -394,20 +395,21 @@
|
|||||||
</stackView>
|
</stackView>
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="qZ9-AR-2zK">
|
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="qZ9-AR-2zK">
|
||||||
<rect key="frame" x="16" y="608" width="343" height="51"/>
|
<rect key="frame" x="16" y="608" width="343" height="51"/>
|
||||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="backgroundColor" name="SettingsHighlighted"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="height" constant="51" id="LQz-qG-ZJK"/>
|
<constraint firstAttribute="height" constant="51" id="LQz-qG-ZJK"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="19"/>
|
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="19"/>
|
||||||
<state key="normal" title="Got it">
|
<state key="normal" title="Got it">
|
||||||
<color key="titleColor" name="Pink"/>
|
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
</state>
|
</state>
|
||||||
<connections>
|
<connections>
|
||||||
<action selector="dismiss" destination="aFi-fb-W0B" eventType="primaryActionTriggered" id="sBq-zj-Mln"/>
|
<action selector="dismiss" destination="aFi-fb-W0B" eventType="primaryActionTriggered" id="sBq-zj-Mln"/>
|
||||||
</connections>
|
</connections>
|
||||||
</button>
|
</button>
|
||||||
</subviews>
|
</subviews>
|
||||||
<color key="backgroundColor" name="Primary"/>
|
<viewLayoutGuide key="safeArea" id="Zek-aC-HOO"/>
|
||||||
|
<color key="backgroundColor" name="SettingsBackground"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="qZ9-AR-2zK" firstAttribute="top" secondItem="bp6-55-IG2" secondAttribute="bottom" id="3yt-cr-swd"/>
|
<constraint firstItem="qZ9-AR-2zK" firstAttribute="top" secondItem="bp6-55-IG2" secondAttribute="bottom" id="3yt-cr-swd"/>
|
||||||
<constraint firstItem="bp6-55-IG2" firstAttribute="top" secondItem="Zek-aC-HOO" secondAttribute="top" id="42S-q2-YZn"/>
|
<constraint firstItem="bp6-55-IG2" firstAttribute="top" secondItem="Zek-aC-HOO" secondAttribute="top" id="42S-q2-YZn"/>
|
||||||
@@ -417,7 +419,6 @@
|
|||||||
<constraint firstAttribute="bottomMargin" secondItem="qZ9-AR-2zK" secondAttribute="bottom" id="e8e-9l-Mkt"/>
|
<constraint firstAttribute="bottomMargin" secondItem="qZ9-AR-2zK" secondAttribute="bottom" id="e8e-9l-Mkt"/>
|
||||||
<constraint firstItem="qZ9-AR-2zK" firstAttribute="leading" secondItem="Otz-hn-WGS" secondAttribute="leadingMargin" id="t2b-3e-6ld"/>
|
<constraint firstItem="qZ9-AR-2zK" firstAttribute="leading" secondItem="Otz-hn-WGS" secondAttribute="leadingMargin" id="t2b-3e-6ld"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
<viewLayoutGuide key="safeArea" id="Zek-aC-HOO"/>
|
|
||||||
</view>
|
</view>
|
||||||
<navigationItem key="navigationItem" title="How it works" largeTitleDisplayMode="always" id="bCq-Jq-gf1"/>
|
<navigationItem key="navigationItem" title="How it works" largeTitleDisplayMode="always" id="bCq-Jq-gf1"/>
|
||||||
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
|
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
|
||||||
@@ -430,14 +431,138 @@
|
|||||||
</objects>
|
</objects>
|
||||||
<point key="canvasLocation" x="1353" y="736"/>
|
<point key="canvasLocation" x="1353" y="736"/>
|
||||||
</scene>
|
</scene>
|
||||||
|
<!--Refresh SideStore-->
|
||||||
|
<scene sceneID="9Vh-dM-OqX">
|
||||||
|
<objects>
|
||||||
|
<viewController storyboardIdentifier="refreshAltStoreViewController" id="aoK-yE-UVT" customClass="RefreshAltStoreViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||||
|
<view key="view" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" id="R83-kV-365">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<subviews>
|
||||||
|
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="fpO-Bf-gFY" customClass="RSTPlaceholderView">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||||
|
</view>
|
||||||
|
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="tDQ-ao-1Jg">
|
||||||
|
<rect key="frame" x="16" y="570" width="343" height="89"/>
|
||||||
|
<subviews>
|
||||||
|
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="xcg-hT-tDe" customClass="PillButton" customModule="SideStore" customModuleProvider="target">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="343" height="51"/>
|
||||||
|
<color key="backgroundColor" name="SettingsHighlighted"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstAttribute="height" constant="51" id="SJA-N9-Z6u"/>
|
||||||
|
</constraints>
|
||||||
|
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="19"/>
|
||||||
|
<color key="tintColor" name="SettingsHighlighted"/>
|
||||||
|
<state key="normal" title="Refresh Now">
|
||||||
|
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
</state>
|
||||||
|
<connections>
|
||||||
|
<action selector="refreshAltStore:" destination="aoK-yE-UVT" eventType="primaryActionTriggered" id="WQu-9b-Zgg"/>
|
||||||
|
</connections>
|
||||||
|
</button>
|
||||||
|
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="qua-VA-asJ">
|
||||||
|
<rect key="frame" x="0.0" y="59" width="343" height="30"/>
|
||||||
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="15"/>
|
||||||
|
<state key="normal" title="Refresh Later">
|
||||||
|
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
</state>
|
||||||
|
<connections>
|
||||||
|
<action selector="cancel:" destination="aoK-yE-UVT" eventType="primaryActionTriggered" id="ffO-0a-LdE"/>
|
||||||
|
</connections>
|
||||||
|
</button>
|
||||||
|
</subviews>
|
||||||
|
</stackView>
|
||||||
|
</subviews>
|
||||||
|
<viewLayoutGuide key="safeArea" id="iwE-xE-ziz"/>
|
||||||
|
<color key="backgroundColor" name="SettingsBackground"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="fpO-Bf-gFY" firstAttribute="leading" secondItem="iwE-xE-ziz" secondAttribute="leading" id="A77-nX-Wg2"/>
|
||||||
|
<constraint firstAttribute="trailingMargin" secondItem="tDQ-ao-1Jg" secondAttribute="trailing" id="KPg-sO-Rnc"/>
|
||||||
|
<constraint firstItem="fpO-Bf-gFY" firstAttribute="trailing" secondItem="iwE-xE-ziz" secondAttribute="trailing" id="SGI-1D-Eaw"/>
|
||||||
|
<constraint firstItem="fpO-Bf-gFY" firstAttribute="bottom" secondItem="R83-kV-365" secondAttribute="bottom" id="cHl-7X-dW1"/>
|
||||||
|
<constraint firstAttribute="bottomMargin" secondItem="tDQ-ao-1Jg" secondAttribute="bottom" id="kLN-e7-BJE"/>
|
||||||
|
<constraint firstItem="fpO-Bf-gFY" firstAttribute="top" secondItem="R83-kV-365" secondAttribute="top" id="oKo-10-7kD"/>
|
||||||
|
<constraint firstItem="tDQ-ao-1Jg" firstAttribute="leading" secondItem="R83-kV-365" secondAttribute="leadingMargin" id="zEt-Xr-kJx"/>
|
||||||
|
</constraints>
|
||||||
|
</view>
|
||||||
|
<navigationItem key="navigationItem" title="Refresh SideStore" largeTitleDisplayMode="always" id="5nk-NR-jtV"/>
|
||||||
|
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
|
||||||
|
<connections>
|
||||||
|
<outlet property="placeholderView" destination="fpO-Bf-gFY" id="q7d-au-d94"/>
|
||||||
|
</connections>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="Chr-7g-qEw" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="3025" y="734"/>
|
||||||
|
</scene>
|
||||||
|
<!--Select a Team-->
|
||||||
|
<scene sceneID="ioQ-WB-CLJ">
|
||||||
|
<objects>
|
||||||
|
<viewController storyboardIdentifier="selectTeamViewController" hidesBottomBarWhenPushed="YES" id="kOD-4P-a6L" customClass="SelectTeamViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||||
|
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" indicatorStyle="white" dataMode="prototypes" style="grouped" separatorStyle="none" rowHeight="60" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="fWW-kX-ifH">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<color key="backgroundColor" name="SettingsBackground"/>
|
||||||
|
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
<color key="separatorColor" white="1" alpha="0.25" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
<prototypes>
|
||||||
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" reuseIdentifier="TeamCell" textLabel="6ip-34-gmM" detailTextLabel="knk-Wf-PKf" style="IBUITableViewCellStyleSubtitle" id="qeQ-eb-2SC" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
||||||
|
<rect key="frame" x="0.0" y="55.5" width="375" height="60"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="qeQ-eb-2SC" id="bT4-Fc-u6I">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="334.5" height="60"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
<subviews>
|
||||||
|
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Team 1" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="6ip-34-gmM">
|
||||||
|
<rect key="frame" x="30" y="10" width="56.5" height="20.5"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
|
||||||
|
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Description" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="knk-Wf-PKf">
|
||||||
|
<rect key="frame" x="30" y="33.5" width="70" height="14.5"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="12"/>
|
||||||
|
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
</subviews>
|
||||||
|
</tableViewCellContentView>
|
||||||
|
<color key="backgroundColor" white="1" alpha="0.14999999999999999" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
<edgeInsets key="layoutMargins" top="8" left="30" bottom="8" right="30"/>
|
||||||
|
<userDefinedRuntimeAttributes>
|
||||||
|
<userDefinedRuntimeAttribute type="number" keyPath="style">
|
||||||
|
<integer key="value" value="0"/>
|
||||||
|
</userDefinedRuntimeAttribute>
|
||||||
|
<userDefinedRuntimeAttribute type="boolean" keyPath="isSelectable" value="YES"/>
|
||||||
|
</userDefinedRuntimeAttributes>
|
||||||
|
</tableViewCell>
|
||||||
|
</prototypes>
|
||||||
|
<sections/>
|
||||||
|
<connections>
|
||||||
|
<outlet property="dataSource" destination="kOD-4P-a6L" id="OLE-fk-1MD"/>
|
||||||
|
<outlet property="delegate" destination="kOD-4P-a6L" id="t9T-jO-TrR"/>
|
||||||
|
</connections>
|
||||||
|
</tableView>
|
||||||
|
<navigationItem key="navigationItem" title="Select a Team" largeTitleDisplayMode="always" id="qxJ-Go-OPq"/>
|
||||||
|
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="yH5-jU-aez" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="2114" y="734"/>
|
||||||
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
|
<color key="tintColor" name="Primary"/>
|
||||||
<resources>
|
<resources>
|
||||||
<namedColor name="Pink">
|
|
||||||
<color red="0.92549019607843142" green="0.25490196078431371" blue="0.69803921568627447" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
|
||||||
</namedColor>
|
|
||||||
<namedColor name="Primary">
|
<namedColor name="Primary">
|
||||||
<color red="0.0039215686274509803" green="0.50196078431372548" blue="0.51764705882352946" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color red="0.64313725490196083" green="0.019607843137254902" blue="0.98039215686274506" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
</namedColor>
|
||||||
|
<namedColor name="SettingsBackground">
|
||||||
|
<color red="0.45098039215686275" green="0.015686274509803921" blue="0.68627450980392157" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
</namedColor>
|
||||||
|
<namedColor name="SettingsHighlighted">
|
||||||
|
<color red="0.38823529411764707" green="0.011764705882352941" blue="0.58823529411764708" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
</namedColor>
|
</namedColor>
|
||||||
</resources>
|
</resources>
|
||||||
<color key="tintColor" name="Primary"/>
|
|
||||||
</document>
|
</document>
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import UIKit
|
|||||||
|
|
||||||
import AltSign
|
import AltSign
|
||||||
|
|
||||||
class AuthenticationViewController: UIViewController
|
final class AuthenticationViewController: UIViewController
|
||||||
{
|
{
|
||||||
var authenticationHandler: (((ALTAccount, String)?) -> Void)?
|
var authenticationHandler: ((String, String, @escaping (Result<(ALTAccount, ALTAppleAPISession), Error>) -> Void) -> Void)?
|
||||||
|
var completionHandler: (((ALTAccount, ALTAppleAPISession, String)?) -> Void)?
|
||||||
|
|
||||||
private weak var toastView: ToastView?
|
private weak var toastView: ToastView?
|
||||||
|
|
||||||
@@ -30,6 +31,22 @@ class AuthenticationViewController: UIViewController
|
|||||||
{
|
{
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
// fetch anisette servers asap when loading Auth Screen (if list is empty
|
||||||
|
if(UserDefaults.standard.menuAnisetteServersList.isEmpty){
|
||||||
|
Task{
|
||||||
|
let sourceURL = UserDefaults.standard.menuAnisetteList
|
||||||
|
do{
|
||||||
|
_ = try await AnisetteViewModel.getListOfServers(serverSource: sourceURL)
|
||||||
|
print("AuthenticationViewController: Server list refresh request completed for sourceURL: \(sourceURL)")
|
||||||
|
}catch{
|
||||||
|
print("AuthenticationViewController: Server list refresh request Failed for sourceURL: \(sourceURL) Error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.signInButton.activityIndicatorView.style = .medium
|
||||||
|
self.signInButton.activityIndicatorView.color = .white
|
||||||
|
|
||||||
for view in [self.appleIDBackgroundView!, self.passwordBackgroundView!, self.signInButton!]
|
for view in [self.appleIDBackgroundView!, self.passwordBackgroundView!, self.signInButton!]
|
||||||
{
|
{
|
||||||
view.clipsToBounds = true
|
view.clipsToBounds = true
|
||||||
@@ -94,23 +111,30 @@ private extension AuthenticationViewController
|
|||||||
|
|
||||||
self.signInButton.isIndicatingActivity = true
|
self.signInButton.isIndicatingActivity = true
|
||||||
|
|
||||||
ALTAppleAPI.shared.authenticate(appleID: emailAddress, password: password) { (account, error) in
|
self.authenticationHandler?(emailAddress, password) { (result) in
|
||||||
do
|
switch result
|
||||||
{
|
|
||||||
let account = try Result(account, error).get()
|
|
||||||
self.authenticationHandler?((account, password))
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
{
|
||||||
|
case .failure(ALTAppleAPIError.requiresTwoFactorAuthentication):
|
||||||
|
// Ignore
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
let toastView = ToastView(text: NSLocalizedString("Failed to Log In", comment: ""), detailText: error.localizedDescription)
|
self.signInButton.isIndicatingActivity = false
|
||||||
toastView.textLabel.textColor = .altPink
|
}
|
||||||
toastView.detailTextLabel.textColor = .altPink
|
|
||||||
toastView.show(in: self.navigationController?.view ?? self.view)
|
case .failure(let error as NSError):
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let error = error.withLocalizedTitle(NSLocalizedString("Failed to Log In", comment: ""))
|
||||||
|
let toastView = ToastView(error: error)
|
||||||
|
toastView.show(in: self)
|
||||||
|
toastView.backgroundColor = .white
|
||||||
|
toastView.textLabel.textColor = .altPrimary
|
||||||
|
toastView.detailTextLabel.textColor = .altPrimary
|
||||||
self.toastView = toastView
|
self.toastView = toastView
|
||||||
|
|
||||||
self.signInButton.isIndicatingActivity = false
|
self.signInButton.isIndicatingActivity = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case .success((let account, let session)):
|
||||||
|
self.completionHandler?((account, session, password))
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@@ -121,7 +145,7 @@ private extension AuthenticationViewController
|
|||||||
|
|
||||||
@IBAction func cancel(_ sender: UIBarButtonItem)
|
@IBAction func cancel(_ sender: UIBarButtonItem)
|
||||||
{
|
{
|
||||||
self.authenticationHandler?(nil)
|
self.completionHandler?(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class InstructionsViewController: UIViewController
|
final class InstructionsViewController: UIViewController
|
||||||
{
|
{
|
||||||
var completionHandler: (() -> Void)?
|
var completionHandler: (() -> Void)?
|
||||||
|
|
||||||
@@ -17,6 +17,10 @@ class InstructionsViewController: UIViewController
|
|||||||
@IBOutlet private var contentStackView: UIStackView!
|
@IBOutlet private var contentStackView: UIStackView!
|
||||||
@IBOutlet private var dismissButton: UIButton!
|
@IBOutlet private var dismissButton: UIButton!
|
||||||
|
|
||||||
|
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||||
|
return .lightContent
|
||||||
|
}
|
||||||
|
|
||||||
override func viewDidLoad()
|
override func viewDidLoad()
|
||||||
{
|
{
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|||||||
84
AltStore/Authentication/RefreshAltStoreViewController.swift
Normal file
84
AltStore/Authentication/RefreshAltStoreViewController.swift
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
//
|
||||||
|
// RefreshAltStoreViewController.swift
|
||||||
|
// AltStore
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 10/26/19.
|
||||||
|
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
import AltStoreCore
|
||||||
|
import AltSign
|
||||||
|
import Roxas
|
||||||
|
|
||||||
|
final class RefreshAltStoreViewController: UIViewController
|
||||||
|
{
|
||||||
|
var context: AuthenticatedOperationContext!
|
||||||
|
|
||||||
|
var completionHandler: ((Result<Void, Error>) -> Void)?
|
||||||
|
|
||||||
|
@IBOutlet private var placeholderView: RSTPlaceholderView!
|
||||||
|
|
||||||
|
override func viewDidLoad()
|
||||||
|
{
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
self.placeholderView.textLabel.isHidden = true
|
||||||
|
|
||||||
|
self.placeholderView.detailTextLabel.textAlignment = .left
|
||||||
|
self.placeholderView.detailTextLabel.textColor = UIColor.white.withAlphaComponent(0.6)
|
||||||
|
self.placeholderView.detailTextLabel.text = NSLocalizedString("SideStore was unable to use an existing signing certificate, so it had to create a new one. This will cause any apps installed with an existing certificate to expire — including SideStore.\n\nTo prevent SideStore from expiring early, please refresh the app now. SideStore will quit once refreshing is complete.", comment: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension RefreshAltStoreViewController
|
||||||
|
{
|
||||||
|
@IBAction func refreshAltStore(_ sender: PillButton)
|
||||||
|
{
|
||||||
|
guard let altStore = InstalledApp.fetchAltStore(in: DatabaseManager.shared.viewContext) else { return }
|
||||||
|
|
||||||
|
func refresh()
|
||||||
|
{
|
||||||
|
sender.isIndicatingActivity = true
|
||||||
|
|
||||||
|
if let progress = AppManager.shared.installationProgress(for: altStore)
|
||||||
|
{
|
||||||
|
// Cancel pending AltStore installation so we can start a new one.
|
||||||
|
progress.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install, _not_ refresh, to ensure we are installing with a non-revoked certificate.
|
||||||
|
let group = AppManager.shared.install(altStore, presentingViewController: self, context: self.context) { (result) in
|
||||||
|
switch result
|
||||||
|
{
|
||||||
|
case .success: self.completionHandler?(.success(()))
|
||||||
|
case .failure(let error as NSError):
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
sender.progress = nil
|
||||||
|
sender.isIndicatingActivity = false
|
||||||
|
|
||||||
|
let alertController = UIAlertController(title: NSLocalizedString("Failed to Refresh SideStore", comment: ""), message: error.localizedFailureReason ?? error.localizedDescription, preferredStyle: .alert)
|
||||||
|
alertController.addAction(UIAlertAction(title: NSLocalizedString("Try Again", comment: ""), style: .default, handler: { (action) in
|
||||||
|
refresh()
|
||||||
|
}))
|
||||||
|
alertController.addAction(UIAlertAction(title: NSLocalizedString("Refresh Later", comment: ""), style: .cancel, handler: { (action) in
|
||||||
|
self.completionHandler?(.failure(error))
|
||||||
|
}))
|
||||||
|
|
||||||
|
self.present(alertController, animated: true, completion: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sender.progress = group.progress
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
@IBAction func cancel(_ sender: UIButton)
|
||||||
|
{
|
||||||
|
self.completionHandler?(.failure(OperationError.cancelled))
|
||||||
|
}
|
||||||
|
}
|
||||||
61
AltStore/Authentication/SelectTeamViewController.swift
Normal file
61
AltStore/Authentication/SelectTeamViewController.swift
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
//
|
||||||
|
// SelectTeamViewController.swift
|
||||||
|
// AltStore
|
||||||
|
//
|
||||||
|
// Created by Megarushing on 4/26/21.
|
||||||
|
// Copyright © 2021 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SafariServices
|
||||||
|
import MessageUI
|
||||||
|
import Intents
|
||||||
|
import IntentsUI
|
||||||
|
|
||||||
|
import AltSign
|
||||||
|
|
||||||
|
final class SelectTeamViewController: UITableViewController
|
||||||
|
{
|
||||||
|
public var teams: [ALTTeam]?
|
||||||
|
public var completionHandler: ((Result<ALTTeam, Swift.Error>) -> Void)?
|
||||||
|
|
||||||
|
private var prototypeHeaderFooterView: SettingsHeaderFooterView!
|
||||||
|
|
||||||
|
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||||
|
return .lightContent
|
||||||
|
}
|
||||||
|
|
||||||
|
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
return teams?.count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
return self.completionHandler!(.success((self.teams?[indexPath.row])!))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "TeamCell", for: indexPath) as! InsetGroupTableViewCell
|
||||||
|
|
||||||
|
cell.textLabel?.text = self.teams?[indexPath.row].name
|
||||||
|
cell.detailTextLabel?.text = self.teams?[indexPath.row].type.localizedDescription
|
||||||
|
if indexPath.row == 0
|
||||||
|
{
|
||||||
|
cell.style = InsetGroupTableViewCell.Style.top
|
||||||
|
} else if indexPath.row == self.tableView(self.tableView, numberOfRowsInSection: indexPath.section) - 1 {
|
||||||
|
cell.style = InsetGroupTableViewCell.Style.bottom
|
||||||
|
} else {
|
||||||
|
cell.style = InsetGroupTableViewCell.Style.middle
|
||||||
|
}
|
||||||
|
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
|
"Teams"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15400" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="6NO-wl-tj1">
|
||||||
|
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15404"/>
|
||||||
|
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
@@ -11,15 +14,38 @@
|
|||||||
<objects>
|
<objects>
|
||||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color key="backgroundColor" name="Background"/>
|
||||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||||
</view>
|
</view>
|
||||||
|
<tabBarItem key="tabBarItem" title="" id="RiK-sx-Kgv"/>
|
||||||
</viewController>
|
</viewController>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
</objects>
|
</objects>
|
||||||
<point key="canvasLocation" x="53" y="375"/>
|
<point key="canvasLocation" x="962.31884057971024" y="375"/>
|
||||||
|
</scene>
|
||||||
|
<!--Tab Bar Controller-->
|
||||||
|
<scene sceneID="9Yy-ze-Trt">
|
||||||
|
<objects>
|
||||||
|
<tabBarController automaticallyAdjustsScrollViewInsets="NO" id="6NO-wl-tj1" sceneMemberID="viewController">
|
||||||
|
<toolbarItems/>
|
||||||
|
<tabBar key="tabBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="4lc-l2-vDf">
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
</tabBar>
|
||||||
|
<connections>
|
||||||
|
<segue destination="01J-lp-oVM" kind="relationship" relationship="viewControllers" id="2qH-aa-n0z"/>
|
||||||
|
</connections>
|
||||||
|
</tabBarController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="pxX-hL-ovw" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="52.173913043478265" y="375"/>
|
||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
|
<resources>
|
||||||
|
<namedColor name="Background">
|
||||||
|
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
</namedColor>
|
||||||
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,120 +0,0 @@
|
|||||||
//
|
|
||||||
// BrowseCollectionViewCell.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/15/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
import Nuke
|
|
||||||
|
|
||||||
@objc class BrowseCollectionViewCell: UICollectionViewCell
|
|
||||||
{
|
|
||||||
var imageURLs: [URL] = [] {
|
|
||||||
didSet {
|
|
||||||
self.dataSource.items = self.imageURLs as [NSURL]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private lazy var dataSource = self.makeDataSource()
|
|
||||||
|
|
||||||
@IBOutlet var nameLabel: UILabel!
|
|
||||||
@IBOutlet var developerLabel: UILabel!
|
|
||||||
@IBOutlet var appIconImageView: UIImageView!
|
|
||||||
@IBOutlet var actionButton: PillButton!
|
|
||||||
@IBOutlet var subtitleLabel: UILabel!
|
|
||||||
|
|
||||||
@IBOutlet var screenshotsCollectionView: UICollectionView!
|
|
||||||
@IBOutlet var betaBadgeView: UIImageView!
|
|
||||||
|
|
||||||
@IBOutlet private var screenshotsContentView: UIView!
|
|
||||||
|
|
||||||
override func awakeFromNib()
|
|
||||||
{
|
|
||||||
super.awakeFromNib()
|
|
||||||
|
|
||||||
// Must be registered programmatically, not in BrowseCollectionViewCell.xib, or else it'll throw an exception 🤷♂️.
|
|
||||||
self.screenshotsCollectionView.register(ScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
|
||||||
|
|
||||||
self.screenshotsCollectionView.delegate = self
|
|
||||||
self.screenshotsCollectionView.dataSource = self.dataSource
|
|
||||||
self.screenshotsCollectionView.prefetchDataSource = self.dataSource
|
|
||||||
|
|
||||||
self.screenshotsContentView.layer.cornerRadius = 20
|
|
||||||
self.screenshotsContentView.layer.masksToBounds = true
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tintColorDidChange()
|
|
||||||
{
|
|
||||||
super.tintColorDidChange()
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension BrowseCollectionViewCell
|
|
||||||
{
|
|
||||||
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>
|
|
||||||
{
|
|
||||||
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>(items: [])
|
|
||||||
dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in
|
|
||||||
let cell = cell as! ScreenshotCollectionViewCell
|
|
||||||
cell.imageView.image = nil
|
|
||||||
cell.imageView.isIndicatingActivity = true
|
|
||||||
}
|
|
||||||
dataSource.prefetchHandler = { (imageURL, indexPath, completionHandler) in
|
|
||||||
return RSTAsyncBlockOperation() { (operation) in
|
|
||||||
ImagePipeline.shared.loadImage(with: imageURL as URL, progress: nil, completion: { (response, error) in
|
|
||||||
guard !operation.isCancelled else { return operation.finish() }
|
|
||||||
|
|
||||||
if let image = response?.image
|
|
||||||
{
|
|
||||||
completionHandler(image, nil)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
completionHandler(nil, error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
|
|
||||||
let cell = cell as! ScreenshotCollectionViewCell
|
|
||||||
cell.imageView.isIndicatingActivity = false
|
|
||||||
cell.imageView.image = image
|
|
||||||
|
|
||||||
if let error = error
|
|
||||||
{
|
|
||||||
print("Error loading image:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dataSource
|
|
||||||
}
|
|
||||||
|
|
||||||
private func update()
|
|
||||||
{
|
|
||||||
self.subtitleLabel.textColor = self.tintColor
|
|
||||||
self.screenshotsContentView.backgroundColor = self.tintColor.withAlphaComponent(0.1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension BrowseCollectionViewCell: UICollectionViewDelegateFlowLayout
|
|
||||||
{
|
|
||||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
|
|
||||||
{
|
|
||||||
// Assuming 9.0 / 16.0 ratio for now.
|
|
||||||
let aspectRatio: CGFloat = 9.0 / 16.0
|
|
||||||
|
|
||||||
let itemHeight = collectionView.bounds.height
|
|
||||||
let itemWidth = itemHeight * aspectRatio
|
|
||||||
|
|
||||||
let size = CGSize(width: itemWidth.rounded(.down), height: itemHeight.rounded(.down))
|
|
||||||
return size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
|
||||||
<device id="retina6_1" orientation="portrait">
|
|
||||||
<adaptation id="fullscreen"/>
|
|
||||||
</device>
|
|
||||||
<dependencies>
|
|
||||||
<deployment identifier="iOS"/>
|
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14490.49"/>
|
|
||||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
|
||||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
|
||||||
</dependencies>
|
|
||||||
<objects>
|
|
||||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
|
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
|
||||||
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="Cell" id="ln4-pC-7KY" customClass="BrowseCollectionViewCell" customModule="AltStore" customModuleProvider="target">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="400"/>
|
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
|
||||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="400"/>
|
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
|
||||||
<subviews>
|
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="11" translatesAutoresizingMaskIntoConstraints="NO" id="Y3g-Md-6xH" userLabel="App Info">
|
|
||||||
<rect key="frame" x="20" y="20" width="335" height="79"/>
|
|
||||||
<subviews>
|
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="F2j-pX-09A" customClass="AppIconImageView" customModule="AltStore" customModuleProvider="target">
|
|
||||||
<rect key="frame" x="0.0" y="7" width="65" height="65"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="width" secondItem="F2j-pX-09A" secondAttribute="height" multiplier="1:1" id="c2j-8O-Diw"/>
|
|
||||||
<constraint firstAttribute="height" constant="65" id="ufl-3d-nkT"/>
|
|
||||||
</constraints>
|
|
||||||
</imageView>
|
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="100" axis="vertical" alignment="top" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="zkp-KH-OyV">
|
|
||||||
<rect key="frame" x="76" y="21" width="176" height="37"/>
|
|
||||||
<subviews>
|
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" spacing="6" translatesAutoresizingMaskIntoConstraints="NO" id="Ykl-yo-ncv">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="127.5" height="20.5"/>
|
|
||||||
<subviews>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="251" horizontalCompressionResistancePriority="500" text="App Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="xni-8I-ewW">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="80.5" height="20.5"/>
|
|
||||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
|
||||||
<nil key="textColor"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="1000" image="BetaBadge" translatesAutoresizingMaskIntoConstraints="NO" id="5gN-I2-QOB">
|
|
||||||
<rect key="frame" x="86.5" y="0.0" width="41" height="20.5"/>
|
|
||||||
</imageView>
|
|
||||||
</subviews>
|
|
||||||
</stackView>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="251" text="Developer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="B5S-HI-tWJ">
|
|
||||||
<rect key="frame" x="0.0" y="22.5" width="57.5" height="14.5"/>
|
|
||||||
<fontDescription key="fontDescription" style="UICTFontTextStyleCaption1"/>
|
|
||||||
<color key="textColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
</subviews>
|
|
||||||
</stackView>
|
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="DeC-Y2-fvR" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
|
|
||||||
<rect key="frame" x="263" y="24" width="72" height="31"/>
|
|
||||||
<color key="backgroundColor" red="0.22352941179999999" green="0.4941176471" blue="0.39607843139999999" alpha="0.10000000000000001" colorSpace="custom" customColorSpace="sRGB"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="72" id="X7D-DN-WnD"/>
|
|
||||||
<constraint firstAttribute="height" constant="31" id="svo-Sc-wpR"/>
|
|
||||||
</constraints>
|
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
|
|
||||||
<state key="normal" title="OPEN"/>
|
|
||||||
</button>
|
|
||||||
</subviews>
|
|
||||||
</stackView>
|
|
||||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="w1r-LJ-TDs" userLabel="Screenshots">
|
|
||||||
<rect key="frame" x="15" y="114" width="345" height="266"/>
|
|
||||||
<subviews>
|
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="15" translatesAutoresizingMaskIntoConstraints="NO" id="hRR-84-Owd">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="345" height="266"/>
|
|
||||||
<subviews>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Classic Nintendo games in your pocket." textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="Imx-Le-bcy">
|
|
||||||
<rect key="frame" x="20" y="15" width="305" height="17"/>
|
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
|
||||||
<color key="textColor" red="1" green="0.14901960780000001" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
<collectionView clipsSubviews="YES" multipleTouchEnabled="YES" userInteractionEnabled="NO" contentMode="scaleToFill" contentInsetAdjustmentBehavior="never" dataMode="prototypes" translatesAutoresizingMaskIntoConstraints="NO" id="RFs-qp-Ca4">
|
|
||||||
<rect key="frame" x="20" y="47" width="305" height="185"/>
|
|
||||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="0.0" minimumInteritemSpacing="10" id="jH9-Jo-IHA">
|
|
||||||
<size key="itemSize" width="120" height="213"/>
|
|
||||||
<size key="headerReferenceSize" width="0.0" height="0.0"/>
|
|
||||||
<size key="footerReferenceSize" width="0.0" height="0.0"/>
|
|
||||||
<inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
|
|
||||||
</collectionViewFlowLayout>
|
|
||||||
<cells/>
|
|
||||||
</collectionView>
|
|
||||||
</subviews>
|
|
||||||
<edgeInsets key="layoutMargins" top="15" left="20" bottom="20" right="20"/>
|
|
||||||
</stackView>
|
|
||||||
</subviews>
|
|
||||||
<color key="backgroundColor" red="1" green="0.14901960780000001" blue="0.0" alpha="0.050000000000000003" colorSpace="custom" customColorSpace="sRGB"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstItem="hRR-84-Owd" firstAttribute="leading" secondItem="w1r-LJ-TDs" secondAttribute="leading" id="3us-zR-peW"/>
|
|
||||||
<constraint firstItem="hRR-84-Owd" firstAttribute="top" secondItem="w1r-LJ-TDs" secondAttribute="top" id="HWW-aS-Scd"/>
|
|
||||||
<constraint firstAttribute="trailing" secondItem="hRR-84-Owd" secondAttribute="trailing" id="lbU-TC-jhJ"/>
|
|
||||||
<constraint firstAttribute="bottom" secondItem="hRR-84-Owd" secondAttribute="bottom" id="nOI-Qj-lbm"/>
|
|
||||||
</constraints>
|
|
||||||
</view>
|
|
||||||
</subviews>
|
|
||||||
</view>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="trailing" secondItem="w1r-LJ-TDs" secondAttribute="trailing" constant="15" id="4ns-Zq-D4j"/>
|
|
||||||
<constraint firstItem="w1r-LJ-TDs" firstAttribute="leading" secondItem="ln4-pC-7KY" secondAttribute="leading" constant="15" id="G1K-up-08u"/>
|
|
||||||
<constraint firstAttribute="bottom" secondItem="w1r-LJ-TDs" secondAttribute="bottom" constant="20" id="Kk0-dF-4OW"/>
|
|
||||||
<constraint firstItem="Y3g-Md-6xH" firstAttribute="top" secondItem="ln4-pC-7KY" secondAttribute="top" constant="20" id="PRR-aX-AiM"/>
|
|
||||||
<constraint firstAttribute="trailing" secondItem="Y3g-Md-6xH" secondAttribute="trailing" constant="20" id="g1Q-lg-I9O"/>
|
|
||||||
<constraint firstItem="w1r-LJ-TDs" firstAttribute="top" secondItem="Y3g-Md-6xH" secondAttribute="bottom" constant="15" id="i9W-bl-J9R"/>
|
|
||||||
<constraint firstItem="Y3g-Md-6xH" firstAttribute="leading" secondItem="ln4-pC-7KY" secondAttribute="leading" constant="20" id="j6L-IY-ALs"/>
|
|
||||||
</constraints>
|
|
||||||
<viewLayoutGuide key="safeArea" id="btu-iP-81i"/>
|
|
||||||
<connections>
|
|
||||||
<outlet property="actionButton" destination="DeC-Y2-fvR" id="VDk-4D-STy"/>
|
|
||||||
<outlet property="appIconImageView" destination="F2j-pX-09A" id="COe-74-adn"/>
|
|
||||||
<outlet property="betaBadgeView" destination="5gN-I2-QOB" id="hu7-Ax-Wbc"/>
|
|
||||||
<outlet property="developerLabel" destination="B5S-HI-tWJ" id="QGh-1g-fFv"/>
|
|
||||||
<outlet property="nameLabel" destination="xni-8I-ewW" id="V56-ZT-vFa"/>
|
|
||||||
<outlet property="screenshotsCollectionView" destination="RFs-qp-Ca4" id="xfi-AN-l17"/>
|
|
||||||
<outlet property="screenshotsContentView" destination="w1r-LJ-TDs" id="iWJ-52-rbA"/>
|
|
||||||
<outlet property="subtitleLabel" destination="Imx-Le-bcy" id="JVW-ZZ-51O"/>
|
|
||||||
</connections>
|
|
||||||
</collectionViewCell>
|
|
||||||
</objects>
|
|
||||||
<resources>
|
|
||||||
<image name="BetaBadge" width="41" height="17"/>
|
|
||||||
</resources>
|
|
||||||
</document>
|
|
||||||
@@ -7,131 +7,287 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
import minimuxer
|
||||||
|
import AltStoreCore
|
||||||
import Roxas
|
import Roxas
|
||||||
|
|
||||||
import Nuke
|
import Nuke
|
||||||
|
|
||||||
class BrowseViewController: UICollectionViewController
|
class BrowseViewController: UICollectionViewController, PeekPopPreviewing
|
||||||
{
|
{
|
||||||
private lazy var dataSource = self.makeDataSource()
|
// Nil == Show apps from all sources.
|
||||||
|
let source: Source?
|
||||||
|
|
||||||
private let prototypeCell = BrowseCollectionViewCell.instantiate(with: BrowseCollectionViewCell.nib!)!
|
private(set) var category: StoreCategory? {
|
||||||
|
didSet {
|
||||||
|
self.updateDataSource()
|
||||||
|
self.update()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchPredicate: NSPredicate? {
|
||||||
|
didSet {
|
||||||
|
self.updateDataSource()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var dataSource = self.makeDataSource()
|
||||||
|
private lazy var placeholderView = RSTPlaceholderView(frame: .zero)
|
||||||
|
|
||||||
|
private let prototypeCell = AppCardCollectionViewCell(frame: .zero)
|
||||||
|
private var sortButton: UIBarButtonItem?
|
||||||
|
|
||||||
|
private var preferredAppSorting: AppSorting = UserDefaults.shared.preferredAppSorting
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
private var titleStackView: UIStackView!
|
||||||
|
private var titleSourceIconView: AppIconImageView!
|
||||||
|
private var titleCategoryIconView: UIImageView!
|
||||||
|
private var titleLabel: UILabel!
|
||||||
|
|
||||||
|
init?(source: Source?, coder: NSCoder)
|
||||||
|
{
|
||||||
|
self.source = source
|
||||||
|
self.category = nil
|
||||||
|
|
||||||
|
super.init(coder: coder)
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(category: StoreCategory?, coder: NSCoder)
|
||||||
|
{
|
||||||
|
self.source = nil
|
||||||
|
self.category = category
|
||||||
|
|
||||||
|
super.init(coder: coder)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder)
|
||||||
|
{
|
||||||
|
self.source = nil
|
||||||
|
self.category = nil
|
||||||
|
|
||||||
|
super.init(coder: coder)
|
||||||
|
}
|
||||||
|
|
||||||
private var cachedItemSizes = [String: CGSize]()
|
private var cachedItemSizes = [String: CGSize]()
|
||||||
|
|
||||||
|
@IBOutlet private var sourcesBarButtonItem: UIBarButtonItem!
|
||||||
|
|
||||||
override func viewDidLoad()
|
override func viewDidLoad()
|
||||||
{
|
{
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
self.collectionView.backgroundColor = .altBackground
|
||||||
|
self.collectionView.alwaysBounceVertical = true
|
||||||
|
|
||||||
|
self.dataSource.searchController.searchableKeyPaths = [#keyPath(StoreApp.name),
|
||||||
|
#keyPath(StoreApp.subtitle),
|
||||||
|
#keyPath(StoreApp.developerName),
|
||||||
|
#keyPath(StoreApp.bundleIdentifier)]
|
||||||
|
self.navigationItem.searchController = self.dataSource.searchController
|
||||||
|
|
||||||
self.prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false
|
self.prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
self.collectionView.register(BrowseCollectionViewCell.nib, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
self.collectionView.register(AppCardCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||||
|
|
||||||
self.collectionView.dataSource = self.dataSource
|
self.collectionView.dataSource = self.dataSource
|
||||||
self.collectionView.prefetchDataSource = self.dataSource
|
self.collectionView.prefetchDataSource = self.dataSource
|
||||||
|
|
||||||
self.registerForPreviewing(with: self, sourceView: self.collectionView)
|
let collectionViewLayout = self.collectionViewLayout as! UICollectionViewFlowLayout
|
||||||
|
collectionViewLayout.minimumLineSpacing = 30
|
||||||
|
|
||||||
|
(self as PeekPopPreviewing).registerForPreviewing(with: self, sourceView: self.collectionView)
|
||||||
|
|
||||||
|
let refreshControl = UIRefreshControl(frame: .zero, primaryAction: UIAction { [weak self] _ in
|
||||||
|
self?.updateSources()
|
||||||
|
})
|
||||||
|
self.collectionView.refreshControl = refreshControl
|
||||||
|
|
||||||
|
if self.category != nil, #available(iOS 16, *)
|
||||||
|
{
|
||||||
|
let categoriesMenu = UIMenu(children: [
|
||||||
|
UIDeferredMenuElement.uncached { [weak self] completion in
|
||||||
|
let actions = self?.makeCategoryActions() ?? []
|
||||||
|
completion(actions)
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
self.navigationItem.titleMenuProvider = { _ in categoriesMenu }
|
||||||
|
}
|
||||||
|
|
||||||
|
self.titleSourceIconView = AppIconImageView(style: .circular)
|
||||||
|
|
||||||
|
self.titleCategoryIconView = UIImageView(frame: .zero)
|
||||||
|
self.titleCategoryIconView.contentMode = .scaleAspectFit
|
||||||
|
|
||||||
|
self.titleLabel = UILabel()
|
||||||
|
self.titleLabel.font = UIFont.preferredFont(forTextStyle: .headline)
|
||||||
|
|
||||||
|
self.titleStackView = UIStackView(arrangedSubviews: [self.titleSourceIconView, self.titleCategoryIconView, self.titleLabel])
|
||||||
|
self.titleStackView.spacing = 4
|
||||||
|
self.titleStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
self.navigationItem.largeTitleDisplayMode = .never
|
||||||
|
|
||||||
|
if #available(iOS 16, *)
|
||||||
|
{
|
||||||
|
self.navigationItem.preferredSearchBarPlacement = .automatic
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 15, *)
|
||||||
|
{
|
||||||
|
self.prepareAppSorting()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.preparePipeline()
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
// Source icon = equal width and height
|
||||||
|
self.titleSourceIconView.heightAnchor.constraint(equalToConstant: 26),
|
||||||
|
self.titleSourceIconView.widthAnchor.constraint(equalTo: self.titleSourceIconView.heightAnchor),
|
||||||
|
|
||||||
|
// Category icon = constant height, variable widths
|
||||||
|
self.titleCategoryIconView.heightAnchor.constraint(equalToConstant: 26)
|
||||||
|
])
|
||||||
|
|
||||||
|
self.updateDataSource()
|
||||||
|
self.update()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool)
|
override func viewWillAppear(_ animated: Bool)
|
||||||
{
|
{
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
self.fetchSource()
|
self.update()
|
||||||
self.updateDataSource()
|
}
|
||||||
|
|
||||||
|
override func viewDidDisappear(_ animated: Bool)
|
||||||
|
{
|
||||||
|
super.viewDidDisappear(animated)
|
||||||
|
|
||||||
|
self.navigationController?.navigationBar.tintColor = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension BrowseViewController
|
private extension BrowseViewController
|
||||||
{
|
{
|
||||||
func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>
|
func preparePipeline()
|
||||||
|
{
|
||||||
|
AppManager.shared.$updateSourcesResult
|
||||||
|
.receive(on: RunLoop.main) // Delay to next run loop so we receive _current_ value (not previous value).
|
||||||
|
.sink { [weak self] result in
|
||||||
|
self?.update()
|
||||||
|
}
|
||||||
|
.store(in: &self.cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeFetchRequest() -> NSFetchRequest<StoreApp>
|
||||||
{
|
{
|
||||||
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
|
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
|
||||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp.sortIndex, ascending: true), NSSortDescriptor(keyPath: \StoreApp.name, ascending: true)]
|
|
||||||
fetchRequest.returnsObjectsAsFaults = false
|
fetchRequest.returnsObjectsAsFaults = false
|
||||||
|
|
||||||
if let source = Source.fetchAltStoreSource(in: DatabaseManager.shared.viewContext)
|
let predicate = StoreApp.visibleAppsPredicate
|
||||||
|
|
||||||
|
if let source = self.source
|
||||||
{
|
{
|
||||||
fetchRequest.predicate = NSPredicate(format: "%K != %@ AND %K == %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID, #keyPath(StoreApp.source), source)
|
let filterPredicate = NSPredicate(format: "%K == %@", #keyPath(StoreApp._source), source)
|
||||||
|
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [filterPredicate, predicate])
|
||||||
|
}
|
||||||
|
else if let category = self.category
|
||||||
|
{
|
||||||
|
let categoryPredicate = switch category {
|
||||||
|
case .other: StoreApp.otherCategoryPredicate
|
||||||
|
default: NSPredicate(format: "%K == %@", #keyPath(StoreApp._category), category.rawValue)
|
||||||
|
}
|
||||||
|
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [categoryPredicate, predicate])
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
fetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID)
|
fetchRequest.predicate = predicate
|
||||||
}
|
}
|
||||||
|
|
||||||
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
var sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp.name, ascending: true),
|
||||||
dataSource.cellConfigurationHandler = { (cell, app, indexPath) in
|
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true),
|
||||||
let cell = cell as! BrowseCollectionViewCell
|
NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true)]
|
||||||
cell.nameLabel.text = app.name
|
|
||||||
cell.developerLabel.text = app.developerName
|
switch self.preferredAppSorting
|
||||||
cell.subtitleLabel.text = app.subtitle
|
{
|
||||||
cell.imageURLs = Array(app.screenshotURLs.prefix(2))
|
case .default:
|
||||||
cell.appIconImageView.image = nil
|
let descriptor = NSSortDescriptor(keyPath: \StoreApp.sortIndex, ascending: self.preferredAppSorting.isAscending)
|
||||||
cell.appIconImageView.isIndicatingActivity = true
|
sortDescriptors.insert(descriptor, at: 0)
|
||||||
cell.betaBadgeView.isHidden = !app.isBeta
|
|
||||||
|
|
||||||
cell.actionButton.addTarget(self, action: #selector(BrowseViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
case .name:
|
||||||
cell.actionButton.activityIndicatorView.style = .white
|
// Already sorting by name, no need to prepend additional sort descriptor.
|
||||||
|
break
|
||||||
|
|
||||||
// Explicitly set to false to ensure we're starting from a non-activity indicating state.
|
case .developer:
|
||||||
// Otherwise, cell reuse can mess up some cached values.
|
let descriptor = NSSortDescriptor(keyPath: \StoreApp.developerName, ascending: self.preferredAppSorting.isAscending)
|
||||||
cell.actionButton.isIndicatingActivity = false
|
sortDescriptors.insert(descriptor, at: 0)
|
||||||
|
|
||||||
|
case .lastUpdated:
|
||||||
|
let descriptor = NSSortDescriptor(keyPath: \StoreApp.latestSupportedVersion?.date, ascending: self.preferredAppSorting.isAscending)
|
||||||
|
sortDescriptors.insert(descriptor, at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchRequest.sortDescriptors = sortDescriptors
|
||||||
|
|
||||||
|
return fetchRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>
|
||||||
|
{
|
||||||
|
let fetchRequest = self.makeFetchRequest()
|
||||||
|
|
||||||
|
let context = self.source?.managedObjectContext ?? DatabaseManager.shared.viewContext
|
||||||
|
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: context)
|
||||||
|
dataSource.placeholderView = self.placeholderView
|
||||||
|
dataSource.cellConfigurationHandler = { [weak self] (cell, app, indexPath) in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
let cell = cell as! AppCardCollectionViewCell
|
||||||
|
cell.layoutMargins.left = self.view.layoutMargins.left
|
||||||
|
cell.layoutMargins.right = self.view.layoutMargins.right
|
||||||
|
|
||||||
|
let showSourceIcon = (self.source == nil) // Hide source icon if redundant
|
||||||
|
cell.configure(for: app, showSourceIcon: showSourceIcon)
|
||||||
|
|
||||||
|
cell.bannerView.iconImageView.image = nil
|
||||||
|
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||||
|
|
||||||
|
cell.bannerView.button.addTarget(self, action: #selector(BrowseViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
||||||
|
cell.bannerView.button.activityIndicatorView.style = .medium
|
||||||
|
cell.bannerView.button.activityIndicatorView.color = .white
|
||||||
|
|
||||||
let tintColor = app.tintColor ?? .altPrimary
|
let tintColor = app.tintColor ?? .altPrimary
|
||||||
cell.tintColor = tintColor
|
cell.tintColor = tintColor
|
||||||
|
|
||||||
if app.installedApp == nil
|
|
||||||
{
|
|
||||||
cell.actionButton.setTitle(NSLocalizedString("FREE", comment: ""), for: .normal)
|
|
||||||
|
|
||||||
let progress = AppManager.shared.installationProgress(for: app)
|
|
||||||
cell.actionButton.progress = progress
|
|
||||||
cell.actionButton.isInverted = false
|
|
||||||
|
|
||||||
if Date() < app.versionDate
|
|
||||||
{
|
|
||||||
cell.actionButton.countdownDate = app.versionDate
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
cell.actionButton.countdownDate = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
cell.actionButton.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
|
|
||||||
cell.actionButton.progress = nil
|
|
||||||
cell.actionButton.isInverted = true
|
|
||||||
cell.actionButton.countdownDate = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
dataSource.prefetchHandler = { (storeApp, indexPath, completionHandler) -> Foundation.Operation? in
|
dataSource.prefetchHandler = { (storeApp, indexPath, completionHandler) -> Foundation.Operation? in
|
||||||
let iconURL = storeApp.iconURL
|
let iconURL = storeApp.iconURL
|
||||||
|
|
||||||
return RSTAsyncBlockOperation() { (operation) in
|
return RSTAsyncBlockOperation() { (operation) in
|
||||||
ImagePipeline.shared.loadImage(with: iconURL, progress: nil, completion: { (response, error) in
|
ImagePipeline.shared.loadImage(with: iconURL, progress: nil) { result in
|
||||||
guard !operation.isCancelled else { return operation.finish() }
|
guard !operation.isCancelled else { return operation.finish() }
|
||||||
|
|
||||||
if let image = response?.image
|
switch result
|
||||||
{
|
{
|
||||||
completionHandler(image, nil)
|
case .success(let response): completionHandler(response.image, nil)
|
||||||
|
case .failure(let error): completionHandler(nil, error)
|
||||||
}
|
}
|
||||||
else
|
}
|
||||||
{
|
|
||||||
completionHandler(nil, error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
|
dataSource.prefetchCompletionHandler = { [weak dataSource] (cell, image, indexPath, error) in
|
||||||
let cell = cell as! BrowseCollectionViewCell
|
let cell = cell as! AppCardCollectionViewCell
|
||||||
cell.appIconImageView.isIndicatingActivity = false
|
cell.bannerView.iconImageView.isIndicatingActivity = false
|
||||||
cell.appIconImageView.image = image
|
cell.bannerView.iconImageView.image = image
|
||||||
|
|
||||||
if let error = error
|
if let error = error, let dataSource
|
||||||
{
|
{
|
||||||
print("Error loading image:", error)
|
let app = dataSource.item(at: indexPath)
|
||||||
|
Logger.main.debug("Failed to load app icon from \(app.iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,32 +296,236 @@ private extension BrowseViewController
|
|||||||
|
|
||||||
func updateDataSource()
|
func updateDataSource()
|
||||||
{
|
{
|
||||||
if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isPatron
|
let fetchRequest = self.makeFetchRequest()
|
||||||
{
|
|
||||||
self.dataSource.predicate = nil
|
let context = self.source?.managedObjectContext ?? DatabaseManager.shared.viewContext
|
||||||
}
|
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil)
|
||||||
else
|
self.dataSource.fetchedResultsController = fetchedResultsController
|
||||||
{
|
|
||||||
self.dataSource.predicate = NSPredicate(format: "%K == NO", #keyPath(StoreApp.isBeta))
|
self.dataSource.predicate = self.searchPredicate
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSources()
|
||||||
|
{
|
||||||
|
AppManager.shared.updateAllSources { result in
|
||||||
|
self.collectionView.refreshControl?.endRefreshing()
|
||||||
|
|
||||||
|
guard case .failure(let error) = result else { return }
|
||||||
|
|
||||||
|
if self.dataSource.itemCount > 0
|
||||||
|
{
|
||||||
|
let toastView = ToastView(error: error)
|
||||||
|
toastView.addTarget(nil, action: #selector(TabBarController.presentSources), for: .touchUpInside)
|
||||||
|
toastView.show(in: self)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchSource()
|
func update()
|
||||||
{
|
{
|
||||||
AppManager.shared.fetchSource() { (result) in
|
if self.searchPredicate != nil
|
||||||
do
|
{
|
||||||
{
|
self.placeholderView.textLabel.text = NSLocalizedString("No Apps", comment: "")
|
||||||
let source = try result.get()
|
self.placeholderView.textLabel.isHidden = false
|
||||||
try source.managedObjectContext?.save()
|
|
||||||
}
|
self.placeholderView.detailTextLabel.text = NSLocalizedString("Please make sure your spelling is correct, or try searching for another app.", comment: "")
|
||||||
catch
|
self.placeholderView.detailTextLabel.isHidden = false
|
||||||
|
|
||||||
|
self.placeholderView.activityIndicatorView.stopAnimating()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
switch AppManager.shared.updateSourcesResult
|
||||||
{
|
{
|
||||||
DispatchQueue.main.async {
|
case nil:
|
||||||
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
|
self.placeholderView.textLabel.isHidden = true
|
||||||
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
|
self.placeholderView.detailTextLabel.isHidden = false
|
||||||
|
|
||||||
|
self.placeholderView.detailTextLabel.text = NSLocalizedString("Loading...", comment: "")
|
||||||
|
|
||||||
|
self.placeholderView.activityIndicatorView.startAnimating()
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
self.placeholderView.textLabel.isHidden = false
|
||||||
|
self.placeholderView.detailTextLabel.isHidden = false
|
||||||
|
|
||||||
|
self.placeholderView.textLabel.text = NSLocalizedString("Unable to Fetch Apps", comment: "")
|
||||||
|
self.placeholderView.detailTextLabel.text = error.localizedDescription
|
||||||
|
|
||||||
|
self.placeholderView.activityIndicatorView.stopAnimating()
|
||||||
|
|
||||||
|
case .success:
|
||||||
|
self.placeholderView.textLabel.text = NSLocalizedString("No Apps", comment: "")
|
||||||
|
self.placeholderView.textLabel.isHidden = false
|
||||||
|
self.placeholderView.detailTextLabel.isHidden = true
|
||||||
|
|
||||||
|
self.placeholderView.activityIndicatorView.stopAnimating()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tintColor: UIColor
|
||||||
|
|
||||||
|
if let source = self.source
|
||||||
|
{
|
||||||
|
tintColor = source.effectiveTintColor?.adjustedForDisplay ?? .altPrimary
|
||||||
|
|
||||||
|
self.title = source.name
|
||||||
|
|
||||||
|
self.titleSourceIconView.backgroundColor = tintColor
|
||||||
|
self.titleSourceIconView.isHidden = false
|
||||||
|
|
||||||
|
self.titleCategoryIconView.isHidden = true
|
||||||
|
|
||||||
|
if let iconURL = source.effectiveIconURL
|
||||||
|
{
|
||||||
|
Nuke.loadImage(with: iconURL, into: self.titleSourceIconView) { result in
|
||||||
|
switch result
|
||||||
|
{
|
||||||
|
case .failure(let error): Logger.main.error("Failed to fetch source icon at \(iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
||||||
|
case .success: self.titleSourceIconView.backgroundColor = .white
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if let category = self.category
|
||||||
|
{
|
||||||
|
tintColor = category.tintColor
|
||||||
|
|
||||||
|
self.title = category.localizedName
|
||||||
|
|
||||||
|
let image = UIImage(systemName: category.filledSymbolName)?.withTintColor(tintColor, renderingMode: .alwaysOriginal)
|
||||||
|
self.titleCategoryIconView.image = image
|
||||||
|
self.titleCategoryIconView.isHidden = false
|
||||||
|
|
||||||
|
self.titleSourceIconView.isHidden = true
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
tintColor = .altPrimary
|
||||||
|
|
||||||
|
self.title = NSLocalizedString("Browse", comment: "")
|
||||||
|
|
||||||
|
self.titleSourceIconView.isHidden = true
|
||||||
|
self.titleCategoryIconView.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
self.titleLabel.text = self.title
|
||||||
|
self.titleStackView.sizeToFit()
|
||||||
|
self.navigationItem.titleView = self.titleStackView
|
||||||
|
|
||||||
|
self.view.tintColor = tintColor
|
||||||
|
|
||||||
|
let appearance = NavigationBarAppearance()
|
||||||
|
appearance.configureWithTintColor(tintColor)
|
||||||
|
appearance.configureWithDefaultBackground()
|
||||||
|
|
||||||
|
let edgeAppearance = appearance.copy()
|
||||||
|
edgeAppearance.configureWithTransparentBackground()
|
||||||
|
|
||||||
|
self.navigationItem.standardAppearance = appearance
|
||||||
|
self.navigationItem.scrollEdgeAppearance = edgeAppearance
|
||||||
|
|
||||||
|
// Necessary to tint UISearchController's inline bar button.
|
||||||
|
self.navigationController?.navigationBar.tintColor = tintColor
|
||||||
|
|
||||||
|
if let sortButton
|
||||||
|
{
|
||||||
|
sortButton.image = sortButton.image?.withTintColor(tintColor, renderingMode: .alwaysOriginal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCategoryActions() -> [UIAction]
|
||||||
|
{
|
||||||
|
let handler = { [weak self] (category: StoreCategory) in
|
||||||
|
self?.category = category
|
||||||
|
}
|
||||||
|
|
||||||
|
let fetchRequest = NSFetchRequest(entityName: StoreApp.entity().name!) as NSFetchRequest<NSDictionary>
|
||||||
|
fetchRequest.resultType = .dictionaryResultType
|
||||||
|
fetchRequest.returnsDistinctResults = true
|
||||||
|
fetchRequest.propertiesToFetch = [#keyPath(StoreApp._category)]
|
||||||
|
fetchRequest.predicate = StoreApp.visibleAppsPredicate
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
let dictionaries = try DatabaseManager.shared.viewContext.fetch(fetchRequest)
|
||||||
|
|
||||||
|
// Keep nil values
|
||||||
|
let categories = dictionaries.map { $0[#keyPath(StoreApp._category)] as? String? ?? nil }.map { rawCategory -> StoreCategory in
|
||||||
|
guard let rawCategory else { return .other }
|
||||||
|
return StoreCategory(rawValue: rawCategory) ?? .other
|
||||||
|
}
|
||||||
|
|
||||||
|
var sortedCategories = Set(categories).sorted(by: { $0.localizedName.localizedStandardCompare($1.localizedName) == .orderedAscending })
|
||||||
|
if let otherIndex = sortedCategories.firstIndex(of: .other)
|
||||||
|
{
|
||||||
|
// Ensure "Other" is always last
|
||||||
|
sortedCategories.move(fromOffsets: [otherIndex], toOffset: sortedCategories.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
let actions = sortedCategories.map { category in
|
||||||
|
let state: UIAction.State = (category == self.category) ? .on : .off
|
||||||
|
let image = UIImage(systemName: category.filledSymbolName)?.withTintColor(category.tintColor, renderingMode: .alwaysOriginal)
|
||||||
|
return UIAction(title: category.localizedName, image: image, state: state) { _ in
|
||||||
|
handler(category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Logger.main.error("Failed to fetch categories. \(error.localizedDescription, privacy: .public)")
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 15, *)
|
||||||
|
func prepareAppSorting()
|
||||||
|
{
|
||||||
|
if self.preferredAppSorting == .default && self.source == nil
|
||||||
|
{
|
||||||
|
// Only allow `default` sorting if source is non-nil.
|
||||||
|
// Otherwise, fall back to `lastUpdated` sorting.
|
||||||
|
self.preferredAppSorting = .lastUpdated
|
||||||
|
|
||||||
|
// Don't update UserDefaults unless explicitly changed by user.
|
||||||
|
// UserDefaults.shared.preferredAppSorting = .lastUpdated
|
||||||
|
}
|
||||||
|
|
||||||
|
let children = UIDeferredMenuElement.uncached { [weak self] completion in
|
||||||
|
guard let self else { return completion([]) }
|
||||||
|
|
||||||
|
var sortingOptions = AppSorting.allCases
|
||||||
|
if self.source == nil
|
||||||
|
{
|
||||||
|
// Only allow `default` sorting when source is non-nil.
|
||||||
|
sortingOptions = sortingOptions.filter { $0 != .default }
|
||||||
|
}
|
||||||
|
|
||||||
|
let actions = sortingOptions.map { sorting in
|
||||||
|
let state: UIMenuElement.State = (sorting == self.preferredAppSorting) ? .on : .off
|
||||||
|
let action = UIAction(title: sorting.localizedName, image: nil, state: state) { action in
|
||||||
|
self.preferredAppSorting = sorting
|
||||||
|
UserDefaults.shared.preferredAppSorting = sorting // Update separately to save change.
|
||||||
|
|
||||||
|
self.updateDataSource()
|
||||||
|
}
|
||||||
|
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
|
||||||
|
completion(actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortMenu = UIMenu(title: NSLocalizedString("Sort by…", comment: ""), options: [.singleSelection], children: [children])
|
||||||
|
let sortIcon = UIImage(systemName: "arrow.up.arrow.down")
|
||||||
|
|
||||||
|
let sortButton = UIBarButtonItem(title: NSLocalizedString("Sort by…", comment: ""), image: sortIcon, primaryAction: nil, menu: sortMenu)
|
||||||
|
self.sortButton = sortButton
|
||||||
|
|
||||||
|
self.navigationItem.rightBarButtonItems = [sortButton]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +538,8 @@ private extension BrowseViewController
|
|||||||
|
|
||||||
let app = self.dataSource.item(at: indexPath)
|
let app = self.dataSource.item(at: indexPath)
|
||||||
|
|
||||||
if let installedApp = app.installedApp
|
// if let installedApp = app.installedApp, !installedApp.isUpdateAvailable
|
||||||
|
if let installedApp = app.installedApp, !installedApp.hasUpdate
|
||||||
{
|
{
|
||||||
self.open(installedApp)
|
self.open(installedApp)
|
||||||
}
|
}
|
||||||
@@ -195,24 +556,55 @@ private extension BrowseViewController
|
|||||||
previousProgress?.cancel()
|
previousProgress?.cancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !minimuxer.ready() {
|
||||||
|
let toastView = ToastView(error: MinimuxerError.NoConnection)
|
||||||
|
toastView.show(in: self)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
_ = AppManager.shared.install(app, presentingViewController: self) { (result) in
|
Task<Void, Never>(priority: .userInitiated) { @MainActor in
|
||||||
|
// if let installedApp = app.installedApp, installedApp.isUpdateAvailable
|
||||||
|
if let installedApp = app.installedApp, installedApp.hasUpdate
|
||||||
|
{
|
||||||
|
AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:))
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await AppManager.shared.installAsync(app, presentingViewController: self, completionHandler: finish(_:))
|
||||||
|
}
|
||||||
|
|
||||||
|
UIView.performWithoutAnimation {
|
||||||
|
self.collectionView.reloadItems(at: [indexPath])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func finish(_ result: Result<InstalledApp, Error>)
|
||||||
|
{
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
switch result
|
switch result
|
||||||
{
|
{
|
||||||
case .failure(OperationError.cancelled): break // Ignore
|
case .failure(OperationError.cancelled): break // Ignore
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
|
let toastView = ToastView(error: error, opensLog: true)
|
||||||
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2)
|
toastView.show(in: self)
|
||||||
|
|
||||||
case .success: print("Installed app:", app.bundleIdentifier)
|
case .success: print("Installed app:", app.bundleIdentifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.collectionView.reloadItems(at: [indexPath])
|
UIView.performWithoutAnimation {
|
||||||
|
if let indexPath = self.dataSource.fetchedResultsController.indexPath(forObject: app)
|
||||||
|
{
|
||||||
|
self.collectionView.reloadItems(at: [indexPath])
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.collectionView.reloadItems(at: [indexPath])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func open(_ installedApp: InstalledApp)
|
func open(_ installedApp: InstalledApp)
|
||||||
@@ -226,50 +618,44 @@ extension BrowseViewController: UICollectionViewDelegateFlowLayout
|
|||||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
|
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
|
||||||
{
|
{
|
||||||
let item = self.dataSource.item(at: indexPath)
|
let item = self.dataSource.item(at: indexPath)
|
||||||
|
let itemID = item.globallyUniqueID ?? item.bundleIdentifier
|
||||||
|
|
||||||
if let previousSize = self.cachedItemSizes[item.bundleIdentifier]
|
if let previousSize = self.cachedItemSizes[itemID]
|
||||||
{
|
{
|
||||||
return previousSize
|
return previousSize
|
||||||
}
|
}
|
||||||
|
|
||||||
let maxVisibleScreenshots = 2 as CGFloat
|
|
||||||
let aspectRatio: CGFloat = 16.0 / 9.0
|
|
||||||
|
|
||||||
let layout = collectionViewLayout as! UICollectionViewFlowLayout
|
|
||||||
let padding = (layout.minimumInteritemSpacing * (maxVisibleScreenshots - 1))
|
|
||||||
|
|
||||||
self.dataSource.cellConfigurationHandler(self.prototypeCell, item, indexPath)
|
self.dataSource.cellConfigurationHandler(self.prototypeCell, item, indexPath)
|
||||||
|
|
||||||
|
let insets = (self.view.layoutMargins.left + self.view.layoutMargins.right)
|
||||||
|
|
||||||
let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width)
|
let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width - insets)
|
||||||
widthConstraint.isActive = true
|
widthConstraint.isActive = true
|
||||||
defer { widthConstraint.isActive = false }
|
defer { widthConstraint.isActive = false }
|
||||||
|
|
||||||
|
// Manually update cell width & layout so we can accurately calculate screenshot sizes.
|
||||||
|
self.prototypeCell.frame.size.width = widthConstraint.constant
|
||||||
self.prototypeCell.layoutIfNeeded()
|
self.prototypeCell.layoutIfNeeded()
|
||||||
|
|
||||||
let collectionViewWidth = self.prototypeCell.screenshotsCollectionView.bounds.width
|
|
||||||
let screenshotWidth = ((collectionViewWidth - padding) / maxVisibleScreenshots).rounded(.down)
|
|
||||||
let screenshotHeight = screenshotWidth * aspectRatio
|
|
||||||
|
|
||||||
let heightConstraint = self.prototypeCell.screenshotsCollectionView.heightAnchor.constraint(equalToConstant: screenshotHeight)
|
|
||||||
heightConstraint.isActive = true
|
|
||||||
defer { heightConstraint.isActive = false }
|
|
||||||
|
|
||||||
let itemSize = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
let itemSize = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||||
self.cachedItemSizes[item.bundleIdentifier] = itemSize
|
self.cachedItemSizes[itemID] = itemSize
|
||||||
return itemSize
|
return itemSize
|
||||||
}
|
}
|
||||||
|
|
||||||
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
||||||
{
|
{
|
||||||
let app = self.dataSource.item(at: indexPath)
|
let app = self.dataSource.item(at: indexPath)
|
||||||
|
|
||||||
let appViewController = AppViewController.makeAppViewController(app: app)
|
let appViewController = AppViewController.makeAppViewController(app: app)
|
||||||
self.navigationController?.pushViewController(appViewController, animated: true)
|
|
||||||
|
// Fall back to presentingViewController.navigationController in case we're being used for search results.
|
||||||
|
let navigationController = self.navigationController ?? self.presentingViewController?.navigationController
|
||||||
|
navigationController?.pushViewController(appViewController, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension BrowseViewController: UIViewControllerPreviewingDelegate
|
extension BrowseViewController: UIViewControllerPreviewingDelegate
|
||||||
{
|
{
|
||||||
|
@available(iOS, deprecated: 13.0)
|
||||||
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController?
|
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController?
|
||||||
{
|
{
|
||||||
guard
|
guard
|
||||||
@@ -285,8 +671,22 @@ extension BrowseViewController: UIViewControllerPreviewingDelegate
|
|||||||
return appViewController
|
return appViewController
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@available(iOS, deprecated: 13.0)
|
||||||
func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController)
|
func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController)
|
||||||
{
|
{
|
||||||
self.navigationController?.pushViewController(viewControllerToCommit, animated: true)
|
self.navigationController?.pushViewController(viewControllerToCommit, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@available(iOS 17, *)
|
||||||
|
#Preview(traits: .portrait) {
|
||||||
|
DatabaseManager.shared.startForPreview()
|
||||||
|
|
||||||
|
let storyboard = UIStoryboard(name: "Main", bundle: .main)
|
||||||
|
let browseViewController = storyboard.instantiateViewController(identifier: "browseViewController") { coder in
|
||||||
|
BrowseViewController(source: nil, coder: coder)
|
||||||
|
}
|
||||||
|
|
||||||
|
let navigationController = UINavigationController(rootViewController: browseViewController)
|
||||||
|
return navigationController
|
||||||
|
}
|
||||||
|
|||||||
100
AltStore/Browse/FeaturedComponents.swift
Normal file
100
AltStore/Browse/FeaturedComponents.swift
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
//
|
||||||
|
// FeaturedComponents.swift
|
||||||
|
// AltStore
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 12/4/23.
|
||||||
|
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class LargeIconCollectionViewCell: UICollectionViewCell
|
||||||
|
{
|
||||||
|
let textLabel = UILabel(frame: .zero)
|
||||||
|
let imageView = UIImageView(frame: .zero)
|
||||||
|
|
||||||
|
override init(frame: CGRect)
|
||||||
|
{
|
||||||
|
self.textLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
self.textLabel.textColor = .white
|
||||||
|
self.textLabel.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
|
||||||
|
self.imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
self.imageView.contentMode = .center
|
||||||
|
self.imageView.tintColor = .white
|
||||||
|
self.imageView.alpha = 0.4
|
||||||
|
self.imageView.preferredSymbolConfiguration = .init(pointSize: 80)
|
||||||
|
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
self.contentView.clipsToBounds = true
|
||||||
|
self.contentView.layer.cornerRadius = 16
|
||||||
|
self.contentView.layer.cornerCurve = .continuous
|
||||||
|
|
||||||
|
self.contentView.addSubview(self.textLabel)
|
||||||
|
self.contentView.addSubview(self.imageView)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
self.textLabel.leadingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.leadingAnchor, constant: 4),
|
||||||
|
self.textLabel.bottomAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.bottomAnchor, constant: -4),
|
||||||
|
|
||||||
|
self.imageView.centerXAnchor.constraint(equalTo: self.contentView.trailingAnchor, constant: -30),
|
||||||
|
self.imageView.centerYAnchor.constraint(equalTo: self.contentView.centerYAnchor, constant: 0),
|
||||||
|
self.imageView.heightAnchor.constraint(equalTo: self.contentView.heightAnchor, constant: 0),
|
||||||
|
self.imageView.widthAnchor.constraint(equalTo: self.imageView.heightAnchor)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IconButtonCollectionReusableView: UICollectionReusableView
|
||||||
|
{
|
||||||
|
let iconButton: UIButton
|
||||||
|
let titleButton: UIButton
|
||||||
|
|
||||||
|
private let stackView: UIStackView
|
||||||
|
|
||||||
|
override init(frame: CGRect)
|
||||||
|
{
|
||||||
|
let iconHeight = 26.0
|
||||||
|
|
||||||
|
self.iconButton = UIButton(type: .custom)
|
||||||
|
self.iconButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
self.iconButton.clipsToBounds = true
|
||||||
|
self.iconButton.layer.cornerRadius = iconHeight / 2
|
||||||
|
|
||||||
|
let content = UIListContentConfiguration.plainHeader()
|
||||||
|
self.titleButton = UIButton(type: .system)
|
||||||
|
self.titleButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
self.titleButton.titleLabel?.font = content.textProperties.font
|
||||||
|
self.titleButton.setTitleColor(content.textProperties.color, for: .normal)
|
||||||
|
|
||||||
|
self.stackView = UIStackView(arrangedSubviews: [self.iconButton, self.titleButton])
|
||||||
|
self.stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
self.stackView.axis = .horizontal
|
||||||
|
self.stackView.alignment = .center
|
||||||
|
self.stackView.spacing = UIStackView.spacingUseSystem
|
||||||
|
self.stackView.isLayoutMarginsRelativeArrangement = false
|
||||||
|
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
self.addSubview(self.stackView)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
self.iconButton.heightAnchor.constraint(equalToConstant: iconHeight),
|
||||||
|
self.iconButton.widthAnchor.constraint(equalTo: self.iconButton.heightAnchor),
|
||||||
|
|
||||||
|
self.stackView.topAnchor.constraint(equalTo: self.topAnchor),
|
||||||
|
self.stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||||
|
self.stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
||||||
|
self.stackView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
747
AltStore/Browse/FeaturedViewController.swift
Normal file
747
AltStore/Browse/FeaturedViewController.swift
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
//
|
||||||
|
// FeaturedViewController.swift
|
||||||
|
// AltStore
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 11/8/23.
|
||||||
|
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
import AltStoreCore
|
||||||
|
import Roxas
|
||||||
|
|
||||||
|
import Nuke
|
||||||
|
|
||||||
|
extension UIAction.Identifier
|
||||||
|
{
|
||||||
|
fileprivate static let showAllApps = Self("io.sidestore.ShowAllApps")
|
||||||
|
fileprivate static let showSourceDetails = Self("io.sidestore.ShowSourceDetails")
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FeaturedViewController
|
||||||
|
{
|
||||||
|
// Open-ended because each Source is its own section
|
||||||
|
private struct Section: RawRepresentable, Equatable
|
||||||
|
{
|
||||||
|
static let recentlyUpdated = Section(rawValue: 0)
|
||||||
|
static let categories = Section(rawValue: 1)
|
||||||
|
static let featuredHeader = Section(rawValue: 2)
|
||||||
|
|
||||||
|
let rawValue: Int
|
||||||
|
|
||||||
|
var isFeaturedAppsSection: Bool {
|
||||||
|
return self.rawValue > Section.featuredHeader.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
init(rawValue: Int)
|
||||||
|
{
|
||||||
|
self.rawValue = rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ReuseID: String
|
||||||
|
{
|
||||||
|
case recent = "RecentCell"
|
||||||
|
case category = "CategoryCell"
|
||||||
|
case featuredApp = "FeaturedAppCell"
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ElementKind: String
|
||||||
|
{
|
||||||
|
case sectionHeader
|
||||||
|
case sourceHeader
|
||||||
|
case button
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FeaturedViewController: UICollectionViewController
|
||||||
|
{
|
||||||
|
private lazy var dataSource = self.makeDataSource()
|
||||||
|
private lazy var recentlyUpdatedDataSource = self.makeRecentlyUpdatedDataSource()
|
||||||
|
private lazy var categoriesDataSource = self.makeCategoriesDataSource()
|
||||||
|
private lazy var featuredAppsDataSource = self.makeFeaturedAppsDataSource()
|
||||||
|
|
||||||
|
private var searchController: RSTSearchController!
|
||||||
|
private var searchBrowseViewController: BrowseViewController!
|
||||||
|
|
||||||
|
override func viewDidLoad()
|
||||||
|
{
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
self.title = NSLocalizedString("Browse", comment: "")
|
||||||
|
|
||||||
|
let layout = Self.makeLayout()
|
||||||
|
self.collectionView.collectionViewLayout = layout
|
||||||
|
|
||||||
|
self.dataSource.proxy = self
|
||||||
|
self.collectionView.dataSource = self.dataSource
|
||||||
|
self.collectionView.prefetchDataSource = self.dataSource
|
||||||
|
|
||||||
|
self.collectionView.register(AppBannerCollectionViewCell.self, forCellWithReuseIdentifier: ReuseID.recent.rawValue)
|
||||||
|
self.collectionView.register(LargeIconCollectionViewCell.self, forCellWithReuseIdentifier: ReuseID.category.rawValue)
|
||||||
|
self.collectionView.register(AppCardCollectionViewCell.self, forCellWithReuseIdentifier: ReuseID.featuredApp.rawValue)
|
||||||
|
|
||||||
|
self.collectionView.register(UICollectionViewListCell.self, forSupplementaryViewOfKind: ElementKind.sectionHeader.rawValue, withReuseIdentifier: ElementKind.sectionHeader.rawValue)
|
||||||
|
self.collectionView.register(IconButtonCollectionReusableView.self, forSupplementaryViewOfKind: ElementKind.sourceHeader.rawValue, withReuseIdentifier: ElementKind.sourceHeader.rawValue)
|
||||||
|
self.collectionView.register(ButtonCollectionReusableView.self, forSupplementaryViewOfKind: ElementKind.button.rawValue, withReuseIdentifier: ElementKind.button.rawValue)
|
||||||
|
|
||||||
|
self.collectionView.backgroundColor = .altBackground
|
||||||
|
self.collectionView.directionalLayoutMargins.leading = 20
|
||||||
|
self.collectionView.directionalLayoutMargins.trailing = 20
|
||||||
|
|
||||||
|
let storyboard = UIStoryboard(name: "Main", bundle: nil)
|
||||||
|
self.searchBrowseViewController = storyboard.instantiateViewController(identifier: "browseViewController") { coder in
|
||||||
|
let browseViewController = BrowseViewController(coder: coder)
|
||||||
|
return browseViewController
|
||||||
|
}
|
||||||
|
|
||||||
|
self.searchController = RSTSearchController(searchResultsController: self.searchBrowseViewController)
|
||||||
|
self.searchController.searchableKeyPaths = [#keyPath(StoreApp.name),
|
||||||
|
#keyPath(StoreApp.developerName),
|
||||||
|
#keyPath(StoreApp.subtitle),
|
||||||
|
#keyPath(StoreApp.bundleIdentifier)]
|
||||||
|
self.searchController.searchHandler = { [weak searchBrowseViewController] (searchValue, _) in
|
||||||
|
searchBrowseViewController?.searchPredicate = searchValue.predicate
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self.navigationItem.searchController = self.searchController
|
||||||
|
self.navigationItem.hidesSearchBarWhenScrolling = true
|
||||||
|
|
||||||
|
self.navigationItem.largeTitleDisplayMode = .always
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool)
|
||||||
|
{
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
|
||||||
|
self.navigationController?.navigationBar.tintColor = .altPrimary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension FeaturedViewController
|
||||||
|
{
|
||||||
|
class func makeLayout() -> UICollectionViewCompositionalLayout
|
||||||
|
{
|
||||||
|
let config = UICollectionViewCompositionalLayoutConfiguration()
|
||||||
|
config.interSectionSpacing = 0 // Must be 0 for Section.featuredHeader
|
||||||
|
config.contentInsetsReference = .layoutMargins
|
||||||
|
|
||||||
|
let layout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
|
||||||
|
let section = Section(rawValue: sectionIndex)
|
||||||
|
|
||||||
|
let spacing = 10.0
|
||||||
|
let interSectionSpacing = 30.0
|
||||||
|
let titleSize = NSCollectionLayoutSize(widthDimension: .estimated(100), heightDimension: .estimated(30))
|
||||||
|
|
||||||
|
switch section
|
||||||
|
{
|
||||||
|
case .recentlyUpdated:
|
||||||
|
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(AppBannerView.standardHeight))
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||||
|
|
||||||
|
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(AppBannerView.standardHeight * 2 + spacing))
|
||||||
|
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item, item]) // 2 items per group
|
||||||
|
group.interItemSpacing = .fixed(spacing)
|
||||||
|
|
||||||
|
let layoutSection = NSCollectionLayoutSection(group: group)
|
||||||
|
layoutSection.interGroupSpacing = spacing
|
||||||
|
layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
|
||||||
|
layoutSection.contentInsets.bottom = interSectionSpacing
|
||||||
|
layoutSection.boundarySupplementaryItems = [
|
||||||
|
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sectionHeader.rawValue, alignment: .topLeading)
|
||||||
|
]
|
||||||
|
return layoutSection
|
||||||
|
|
||||||
|
case .categories:
|
||||||
|
let itemWidth = (layoutEnvironment.container.effectiveContentSize.width - spacing) / 2
|
||||||
|
let itemHeight = 90.0
|
||||||
|
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(itemWidth), heightDimension: .absolute(itemHeight))
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||||
|
|
||||||
|
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(itemHeight))
|
||||||
|
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item, item]) // 2 items per group
|
||||||
|
group.interItemSpacing = .fixed(spacing)
|
||||||
|
|
||||||
|
let layoutSection = NSCollectionLayoutSection(group: group)
|
||||||
|
layoutSection.interGroupSpacing = spacing
|
||||||
|
layoutSection.orthogonalScrollingBehavior = .none
|
||||||
|
layoutSection.contentInsets.bottom = interSectionSpacing
|
||||||
|
layoutSection.boundarySupplementaryItems = [
|
||||||
|
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sectionHeader.rawValue, alignment: .topLeading)
|
||||||
|
]
|
||||||
|
return layoutSection
|
||||||
|
|
||||||
|
case .featuredHeader:
|
||||||
|
// We don't want to show any items, so set height to 1.0
|
||||||
|
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(1.0))
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||||
|
|
||||||
|
let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item])
|
||||||
|
|
||||||
|
let layoutSection = NSCollectionLayoutSection(group: group)
|
||||||
|
layoutSection.contentInsets.top = 0
|
||||||
|
layoutSection.contentInsets.bottom = 0
|
||||||
|
layoutSection.boundarySupplementaryItems = [
|
||||||
|
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sectionHeader.rawValue, alignment: .topLeading)
|
||||||
|
]
|
||||||
|
return layoutSection
|
||||||
|
|
||||||
|
case _ where section.isFeaturedAppsSection:
|
||||||
|
let itemHeight: NSCollectionLayoutDimension = if #available(iOS 17, *) { .uniformAcrossSiblings(estimate: 350) } else { .estimated(350) }
|
||||||
|
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: itemHeight)
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||||
|
|
||||||
|
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: itemHeight)
|
||||||
|
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
|
||||||
|
group.interItemSpacing = .fixed(spacing)
|
||||||
|
|
||||||
|
let titleHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sourceHeader.rawValue, alignment: .topLeading)
|
||||||
|
|
||||||
|
let buttonSize = NSCollectionLayoutSize(widthDimension: .estimated(44), heightDimension: .estimated(20))
|
||||||
|
let buttonHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: buttonSize, elementKind: ElementKind.button.rawValue, alignment: .topTrailing)
|
||||||
|
|
||||||
|
let layoutSection = NSCollectionLayoutSection(group: group)
|
||||||
|
layoutSection.interGroupSpacing = spacing
|
||||||
|
layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
|
||||||
|
layoutSection.contentInsets.top = 8
|
||||||
|
layoutSection.contentInsets.bottom = interSectionSpacing
|
||||||
|
layoutSection.boundarySupplementaryItems = [titleHeader, buttonHeader]
|
||||||
|
return layoutSection
|
||||||
|
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}, configuration: config)
|
||||||
|
|
||||||
|
return layout
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeDataSource() -> RSTCompositeCollectionViewPrefetchingDataSource<StoreApp, UIImage>
|
||||||
|
{
|
||||||
|
let featuredHeaderDataSource = RSTDynamicCollectionViewDataSource<StoreApp>()
|
||||||
|
featuredHeaderDataSource.numberOfSectionsHandler = { 1 }
|
||||||
|
featuredHeaderDataSource.numberOfItemsHandler = { _ in 0 }
|
||||||
|
|
||||||
|
let dataSource = RSTCompositeCollectionViewPrefetchingDataSource<StoreApp, UIImage>(dataSources: [self.recentlyUpdatedDataSource, self.categoriesDataSource, featuredHeaderDataSource, self.featuredAppsDataSource])
|
||||||
|
dataSource.predicate = StoreApp.visibleAppsPredicate // Ensure we never accidentally show hidden apps
|
||||||
|
return dataSource
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeRecentlyUpdatedDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>
|
||||||
|
{
|
||||||
|
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
|
||||||
|
fetchRequest.returnsObjectsAsFaults = false
|
||||||
|
fetchRequest.sortDescriptors = [
|
||||||
|
NSSortDescriptor(keyPath: \StoreApp.latestSupportedVersion?.date, ascending: false),
|
||||||
|
NSSortDescriptor(keyPath: \StoreApp.name, ascending: true),
|
||||||
|
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true),
|
||||||
|
NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true),
|
||||||
|
]
|
||||||
|
|
||||||
|
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
||||||
|
dataSource.cellIdentifierHandler = { _ in ReuseID.recent.rawValue }
|
||||||
|
dataSource.liveFetchLimit = 10 // Show 10 most recently updated apps
|
||||||
|
dataSource.cellConfigurationHandler = { cell, storeApp, indexPath in
|
||||||
|
let cell = cell as! AppBannerCollectionViewCell
|
||||||
|
cell.tintColor = storeApp.tintColor
|
||||||
|
cell.contentView.preservesSuperviewLayoutMargins = false
|
||||||
|
cell.contentView.layoutMargins = .zero
|
||||||
|
|
||||||
|
cell.bannerView.button.isIndicatingActivity = false
|
||||||
|
cell.bannerView.configure(for: storeApp)
|
||||||
|
|
||||||
|
if let versionDate = storeApp.latestSupportedVersion?.date
|
||||||
|
{
|
||||||
|
cell.bannerView.subtitleLabel.text = Date().relativeDateString(since: versionDate, dateFormatter: Date.mediumDateFormatter)
|
||||||
|
}
|
||||||
|
|
||||||
|
cell.bannerView.button.addTarget(self, action: #selector(FeaturedViewController.performAppAction), for: .primaryActionTriggered)
|
||||||
|
|
||||||
|
cell.bannerView.iconImageView.image = nil
|
||||||
|
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||||
|
}
|
||||||
|
dataSource.prefetchHandler = { (storeApp, indexPath, completion) -> Foundation.Operation? in
|
||||||
|
return RSTAsyncBlockOperation { (operation) in
|
||||||
|
storeApp.managedObjectContext?.perform {
|
||||||
|
ImagePipeline.shared.loadImage(with: storeApp.iconURL, progress: nil) { result in
|
||||||
|
guard !operation.isCancelled else { return operation.finish() }
|
||||||
|
|
||||||
|
switch result
|
||||||
|
{
|
||||||
|
case .success(let response): completion(response.image, nil)
|
||||||
|
case .failure(let error): completion(nil, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dataSource.prefetchCompletionHandler = { [weak dataSource] (cell, image, indexPath, error) in
|
||||||
|
let cell = cell as! AppBannerCollectionViewCell
|
||||||
|
cell.bannerView.iconImageView.image = image
|
||||||
|
cell.bannerView.iconImageView.isIndicatingActivity = false
|
||||||
|
|
||||||
|
if let error, let dataSource
|
||||||
|
{
|
||||||
|
let app = dataSource.item(at: indexPath)
|
||||||
|
Logger.main.debug("Failed to app icon from \(app.iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataSource
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCategoriesDataSource() -> RSTCompositeCollectionViewDataSource<StoreApp>
|
||||||
|
{
|
||||||
|
let knownCategories = StoreCategory.allCases.filter { $0 != .other }.map { $0.rawValue }
|
||||||
|
|
||||||
|
let knownFetchRequest = StoreApp.fetchRequest()
|
||||||
|
knownFetchRequest.predicate = NSPredicate(format: "%K IN %@", #keyPath(StoreApp._category), knownCategories)
|
||||||
|
knownFetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp._category, ascending: true),
|
||||||
|
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true),
|
||||||
|
NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true)]
|
||||||
|
|
||||||
|
let unknownFetchRequest = StoreApp.fetchRequest()
|
||||||
|
unknownFetchRequest.predicate = StoreApp.otherCategoryPredicate
|
||||||
|
unknownFetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp._category, ascending: true),
|
||||||
|
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true),
|
||||||
|
NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true)]
|
||||||
|
|
||||||
|
let knownController = NSFetchedResultsController(fetchRequest: knownFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(StoreApp._category), cacheName: nil)
|
||||||
|
let knownDataSource = RSTFetchedResultsCollectionViewDataSource<StoreApp>(fetchedResultsController: knownController)
|
||||||
|
knownDataSource.liveFetchLimit = 1 // One app per category
|
||||||
|
|
||||||
|
let unknownController = NSFetchedResultsController(fetchRequest: unknownFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: nil, cacheName: nil)
|
||||||
|
let unknownDataSource = RSTFetchedResultsCollectionViewDataSource<StoreApp>(fetchedResultsController: unknownController)
|
||||||
|
unknownDataSource.liveFetchLimit = 1
|
||||||
|
|
||||||
|
// Use composite data source to ensure "Other" category is always last.
|
||||||
|
let dataSource = RSTCompositeCollectionViewDataSource<StoreApp>(dataSources: [knownDataSource, unknownDataSource])
|
||||||
|
dataSource.shouldFlattenSections = true // Combine into single section, with one StoreApp per category.
|
||||||
|
dataSource.cellIdentifierHandler = { _ in ReuseID.category.rawValue }
|
||||||
|
dataSource.cellConfigurationHandler = { cell, storeApp, indexPath in
|
||||||
|
let category = storeApp.category ?? .other
|
||||||
|
|
||||||
|
let cell = cell as! LargeIconCollectionViewCell
|
||||||
|
cell.textLabel.text = category.localizedName
|
||||||
|
cell.imageView.image = UIImage(systemName: category.symbolName)
|
||||||
|
|
||||||
|
var background = UIBackgroundConfiguration.clear()
|
||||||
|
background.backgroundColor = category.tintColor
|
||||||
|
background.cornerRadius = 16
|
||||||
|
cell.backgroundConfiguration = background
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataSource
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeFeaturedAppsDataSource() -> RSTCompositeCollectionViewPrefetchingDataSource<StoreApp, UIImage>
|
||||||
|
{
|
||||||
|
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
|
||||||
|
fetchRequest.returnsObjectsAsFaults = false
|
||||||
|
fetchRequest.sortDescriptors = [
|
||||||
|
// Sort by Source first to group into sections.
|
||||||
|
NSSortDescriptor(keyPath: \StoreApp._source?.featuredSortID, ascending: true),
|
||||||
|
|
||||||
|
// Show uninstalled apps first.
|
||||||
|
// Sorting by StoreApp.installedApp crashes because InstalledApp does not respond to compare:
|
||||||
|
// Instead, sort by StoreApp.installedApp.storeApp.source.sourceIdentifier, which will be either nil OR source ID.
|
||||||
|
NSSortDescriptor(keyPath: \StoreApp.installedApp?.storeApp?.sourceIdentifier, ascending: true),
|
||||||
|
|
||||||
|
// Show featured apps first.
|
||||||
|
// Sorting by StoreApp.featuringSource crashes because Source does not respond to compare:
|
||||||
|
// Instead, sort by StoreApp.featuringSource.identifier, which will be either nil OR source ID.
|
||||||
|
NSSortDescriptor(keyPath: \StoreApp.featuringSource?.identifier, ascending: false),
|
||||||
|
|
||||||
|
// Randomize order within sections.
|
||||||
|
NSSortDescriptor(keyPath: \StoreApp.featuredSortID, ascending: true),
|
||||||
|
|
||||||
|
// Sanity check to ensure stable ordering
|
||||||
|
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true)
|
||||||
|
]
|
||||||
|
|
||||||
|
let sourceHasRemainingAppsPredicate = NSPredicate(format:
|
||||||
|
"""
|
||||||
|
SUBQUERY(%K, $app,
|
||||||
|
($app.%K != %@) AND ($app.%K == nil) AND (($app.%K == NO) OR ($app.%K == NO) OR ($app.%K == YES))
|
||||||
|
).@count > 0
|
||||||
|
""",
|
||||||
|
#keyPath(StoreApp._source._apps),
|
||||||
|
#keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID,
|
||||||
|
#keyPath(StoreApp.installedApp),
|
||||||
|
#keyPath(StoreApp.isPledgeRequired), #keyPath(StoreApp.isHiddenWithoutPledge), #keyPath(StoreApp.isPledged)
|
||||||
|
)
|
||||||
|
|
||||||
|
let primaryFetchRequest = fetchRequest.copy() as! NSFetchRequest<StoreApp>
|
||||||
|
primaryFetchRequest.predicate = sourceHasRemainingAppsPredicate
|
||||||
|
|
||||||
|
let primaryController = NSFetchedResultsController(fetchRequest: primaryFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(StoreApp._source.featuredSortID), cacheName: nil)
|
||||||
|
let primaryDataSource = RSTFetchedResultsCollectionViewDataSource<StoreApp>(fetchedResultsController: primaryController)
|
||||||
|
primaryDataSource.liveFetchLimit = 5
|
||||||
|
|
||||||
|
let secondaryFetchRequest = fetchRequest.copy() as! NSFetchRequest<StoreApp>
|
||||||
|
secondaryFetchRequest.predicate = NSCompoundPredicate(notPredicateWithSubpredicate: sourceHasRemainingAppsPredicate)
|
||||||
|
|
||||||
|
let secondaryController = NSFetchedResultsController(fetchRequest: secondaryFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(StoreApp._source.featuredSortID), cacheName: nil)
|
||||||
|
let secondaryDataSource = RSTFetchedResultsCollectionViewDataSource<StoreApp>(fetchedResultsController: secondaryController)
|
||||||
|
secondaryDataSource.liveFetchLimit = 5
|
||||||
|
|
||||||
|
// Ensure sources with no remaining apps always come last.
|
||||||
|
let dataSource = RSTCompositeCollectionViewPrefetchingDataSource<StoreApp, UIImage>(dataSources: [primaryDataSource, secondaryDataSource])
|
||||||
|
dataSource.cellIdentifierHandler = { _ in ReuseID.featuredApp.rawValue }
|
||||||
|
dataSource.cellConfigurationHandler = { cell, storeApp, indexPath in
|
||||||
|
let cell = cell as! AppCardCollectionViewCell
|
||||||
|
cell.configure(for: storeApp)
|
||||||
|
cell.prefersPagingScreenshots = false
|
||||||
|
|
||||||
|
cell.bannerView.button.addTarget(self, action: #selector(FeaturedViewController.performAppAction), for: .primaryActionTriggered)
|
||||||
|
cell.bannerView.sourceIconImageView.isHidden = true
|
||||||
|
|
||||||
|
cell.bannerView.iconImageView.image = nil
|
||||||
|
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||||
|
}
|
||||||
|
dataSource.prefetchHandler = { (storeApp, indexPath, completion) -> Foundation.Operation? in
|
||||||
|
return RSTAsyncBlockOperation { (operation) in
|
||||||
|
storeApp.managedObjectContext?.perform {
|
||||||
|
ImagePipeline.shared.loadImage(with: storeApp.iconURL, progress: nil) { result in
|
||||||
|
guard !operation.isCancelled else { return operation.finish() }
|
||||||
|
|
||||||
|
switch result
|
||||||
|
{
|
||||||
|
case .success(let response): completion(response.image, nil)
|
||||||
|
case .failure(let error): completion(nil, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dataSource.prefetchCompletionHandler = { [weak dataSource] (cell, image, indexPath, error) in
|
||||||
|
let cell = cell as! AppCardCollectionViewCell
|
||||||
|
cell.bannerView.iconImageView.image = image
|
||||||
|
cell.bannerView.iconImageView.isIndicatingActivity = false
|
||||||
|
|
||||||
|
if let error = error, let dataSource
|
||||||
|
{
|
||||||
|
let app = dataSource.item(at: indexPath)
|
||||||
|
Logger.main.debug("Failed to app icon from \(app.iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension FeaturedViewController
|
||||||
|
{
|
||||||
|
@IBSegueAction
|
||||||
|
func makeBrowseViewController(_ coder: NSCoder, sender: Any) -> UIViewController?
|
||||||
|
{
|
||||||
|
if let category = sender as? StoreCategory
|
||||||
|
{
|
||||||
|
let browseViewController = BrowseViewController(category: category, coder: coder)
|
||||||
|
return browseViewController
|
||||||
|
}
|
||||||
|
else if let source = sender as? Source
|
||||||
|
{
|
||||||
|
let browseViewController = BrowseViewController(source: source, coder: coder)
|
||||||
|
return browseViewController
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
let browseViewController = BrowseViewController(coder: coder)
|
||||||
|
return browseViewController
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@IBSegueAction
|
||||||
|
func makeSourceDetailViewController(_ coder: NSCoder, sender: Any?) -> UIViewController?
|
||||||
|
{
|
||||||
|
guard let source = sender as? Source else { return nil }
|
||||||
|
|
||||||
|
let sourceDetailViewController = SourceDetailViewController(source: source, coder: coder)
|
||||||
|
return sourceDetailViewController
|
||||||
|
}
|
||||||
|
|
||||||
|
func showAllApps(for source: Source)
|
||||||
|
{
|
||||||
|
self.performSegue(withIdentifier: "showBrowseViewController", sender: source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func showSourceDetails(for source: Source)
|
||||||
|
{
|
||||||
|
self.performSegue(withIdentifier: "showSourceDetails", sender: source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension FeaturedViewController
|
||||||
|
{
|
||||||
|
@objc func performAppAction(_ sender: PillButton)
|
||||||
|
{
|
||||||
|
let point = self.collectionView.convert(sender.center, from: sender.superview)
|
||||||
|
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
|
||||||
|
|
||||||
|
let storeApp = self.dataSource.item(at: indexPath)
|
||||||
|
|
||||||
|
// if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable
|
||||||
|
if let installedApp = storeApp.installedApp, !installedApp.hasUpdate
|
||||||
|
{
|
||||||
|
self.open(installedApp)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.install(storeApp, at: indexPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func install(_ storeApp: StoreApp, at indexPath: IndexPath)
|
||||||
|
{
|
||||||
|
let previousProgress = AppManager.shared.installationProgress(for: storeApp)
|
||||||
|
guard previousProgress == nil else {
|
||||||
|
previousProgress?.cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable
|
||||||
|
if let installedApp = storeApp.installedApp, installedApp.hasUpdate
|
||||||
|
{
|
||||||
|
AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:))
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AppManager.shared.install(storeApp, presentingViewController: self, completionHandler: finish(_:))
|
||||||
|
}
|
||||||
|
|
||||||
|
UIView.performWithoutAnimation {
|
||||||
|
self.collectionView.reloadItems(at: [indexPath])
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish(_ result: Result<InstalledApp, Error>)
|
||||||
|
{
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
switch result
|
||||||
|
{
|
||||||
|
case .failure(OperationError.cancelled): break // Ignore
|
||||||
|
case .failure(let error):
|
||||||
|
let toastView = ToastView(error: error)
|
||||||
|
toastView.opensErrorLog = true
|
||||||
|
toastView.show(in: self)
|
||||||
|
|
||||||
|
case .success:
|
||||||
|
Logger.main.info("Installed app \(storeApp.bundleIdentifier, privacy: .public) from FeaturedViewController.")
|
||||||
|
}
|
||||||
|
|
||||||
|
for indexPath in self.collectionView.indexPathsForVisibleItems
|
||||||
|
{
|
||||||
|
// Only need to reload if it's still visible.
|
||||||
|
|
||||||
|
let item = self.dataSource.item(at: indexPath)
|
||||||
|
guard item == storeApp else { continue }
|
||||||
|
|
||||||
|
UIView.performWithoutAnimation {
|
||||||
|
self.collectionView.reloadItems(at: [indexPath])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func open(_ installedApp: InstalledApp)
|
||||||
|
{
|
||||||
|
UIApplication.shared.open(installedApp.openAppURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FeaturedViewController
|
||||||
|
{
|
||||||
|
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
|
||||||
|
{
|
||||||
|
let section = Section(rawValue: indexPath.section)
|
||||||
|
|
||||||
|
switch kind
|
||||||
|
{
|
||||||
|
case ElementKind.sourceHeader.rawValue:
|
||||||
|
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! IconButtonCollectionReusableView
|
||||||
|
|
||||||
|
let indexPath = IndexPath(item: 0, section: indexPath.section)
|
||||||
|
let storeApp = self.dataSource.item(at: indexPath)
|
||||||
|
|
||||||
|
var content = UIListContentConfiguration.plainHeader()
|
||||||
|
content.text = storeApp.source?.name ?? NSLocalizedString("Unknown Source", comment: "")
|
||||||
|
content.textProperties.numberOfLines = 1
|
||||||
|
|
||||||
|
content.directionalLayoutMargins.leading = 0
|
||||||
|
content.imageToTextPadding = 8
|
||||||
|
content.imageProperties.reservedLayoutSize = CGSize(width: 26, height: 26)
|
||||||
|
content.imageProperties.maximumSize = CGSize(width: 26, height: 26)
|
||||||
|
content.imageProperties.cornerRadius = 13
|
||||||
|
|
||||||
|
UIView.performWithoutAnimation {
|
||||||
|
headerView.titleButton.setTitle(content.text, for: .normal)
|
||||||
|
headerView.titleButton.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
headerView.iconButton.backgroundColor = storeApp.source?.effectiveTintColor?.adjustedForDisplay
|
||||||
|
headerView.iconButton.setImage(nil, for: .normal)
|
||||||
|
|
||||||
|
if let iconURL = storeApp.source?.effectiveIconURL
|
||||||
|
{
|
||||||
|
ImagePipeline.shared.loadImage(with: iconURL) { result in
|
||||||
|
guard case .success(let image) = result else { return }
|
||||||
|
|
||||||
|
headerView.iconButton.backgroundColor = .white
|
||||||
|
headerView.iconButton.setImage(image.image, for: .normal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let buttons = [headerView.iconButton, headerView.titleButton]
|
||||||
|
for button in buttons
|
||||||
|
{
|
||||||
|
button.removeAction(identifiedBy: .showSourceDetails, for: .primaryActionTriggered)
|
||||||
|
|
||||||
|
if let source = storeApp.source
|
||||||
|
{
|
||||||
|
let action = UIAction(identifier: .showSourceDetails) { [weak self] _ in
|
||||||
|
self?.showSourceDetails(for: source)
|
||||||
|
}
|
||||||
|
button.addAction(action, for: .primaryActionTriggered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return headerView
|
||||||
|
|
||||||
|
case ElementKind.sectionHeader.rawValue:
|
||||||
|
// Regular section header
|
||||||
|
|
||||||
|
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! UICollectionViewListCell
|
||||||
|
|
||||||
|
var content: UIListContentConfiguration = if #available(iOS 15, *) {
|
||||||
|
.prominentInsetGroupedHeader()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
.groupedHeader()
|
||||||
|
}
|
||||||
|
|
||||||
|
switch section
|
||||||
|
{
|
||||||
|
case .recentlyUpdated: content.text = NSLocalizedString("New & Updated", comment: "")
|
||||||
|
case .categories: content.text = NSLocalizedString("Categories", comment: "")
|
||||||
|
case .featuredHeader: content.text = NSLocalizedString("Featured", comment: "")
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
|
||||||
|
content.directionalLayoutMargins.leading = .zero
|
||||||
|
content.directionalLayoutMargins.trailing = .zero
|
||||||
|
|
||||||
|
headerView.contentConfiguration = content
|
||||||
|
return headerView
|
||||||
|
|
||||||
|
case ElementKind.button.rawValue where section.isFeaturedAppsSection:
|
||||||
|
let buttonView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! ButtonCollectionReusableView
|
||||||
|
|
||||||
|
let indexPath = IndexPath(item: 0, section: indexPath.section)
|
||||||
|
let storeApp = self.dataSource.item(at: indexPath)
|
||||||
|
|
||||||
|
buttonView.tintColor = storeApp.source?.effectiveTintColor?.adjustedForDisplay ?? .altPrimary
|
||||||
|
|
||||||
|
buttonView.button.setTitle(NSLocalizedString("See All", comment: ""), for: .normal)
|
||||||
|
buttonView.button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
|
||||||
|
buttonView.button.contentEdgeInsets.bottom = 8
|
||||||
|
|
||||||
|
buttonView.button.removeAction(identifiedBy: .showAllApps, for: .primaryActionTriggered)
|
||||||
|
|
||||||
|
if let source = storeApp.source
|
||||||
|
{
|
||||||
|
let action = UIAction(identifier: .showAllApps) { [weak self] _ in
|
||||||
|
self?.showAllApps(for: source)
|
||||||
|
}
|
||||||
|
buttonView.button.addAction(action, for: .primaryActionTriggered)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buttonView
|
||||||
|
|
||||||
|
default: return UICollectionReusableView(frame: .zero)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
||||||
|
{
|
||||||
|
let storeApp = self.dataSource.item(at: indexPath)
|
||||||
|
|
||||||
|
let section = Section(rawValue: indexPath.section)
|
||||||
|
switch section
|
||||||
|
{
|
||||||
|
case _ where section.isFeaturedAppsSection: fallthrough
|
||||||
|
case .recentlyUpdated:
|
||||||
|
let appViewController = AppViewController.makeAppViewController(app: storeApp)
|
||||||
|
self.navigationController?.pushViewController(appViewController, animated: true)
|
||||||
|
|
||||||
|
case .categories:
|
||||||
|
let category = storeApp.category ?? .other
|
||||||
|
self.performSegue(withIdentifier: "showBrowseViewController", sender: category)
|
||||||
|
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 17, *)
|
||||||
|
#Preview(traits: .portrait) {
|
||||||
|
DatabaseManager.shared.startForPreview()
|
||||||
|
|
||||||
|
let storyboard = UIStoryboard(name: "Main", bundle: nil)
|
||||||
|
let featuredViewController = storyboard.instantiateViewController(identifier: "featuredViewController")
|
||||||
|
|
||||||
|
let navigationController = UINavigationController(rootViewController: featuredViewController)
|
||||||
|
navigationController.navigationBar.prefersLargeTitles = true
|
||||||
|
navigationController.modalPresentationStyle = .fullScreen
|
||||||
|
|
||||||
|
let viewController = UIViewController()
|
||||||
|
|
||||||
|
AppManager.shared.fetchSources() { (result) in
|
||||||
|
do
|
||||||
|
{
|
||||||
|
let (_, context) = try result.get()
|
||||||
|
try context.save()
|
||||||
|
}
|
||||||
|
catch let error as NSError
|
||||||
|
{
|
||||||
|
Logger.main.error("Failed to fetch sources for preview. \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AppManager.shared.updateKnownSources { result in
|
||||||
|
Task {
|
||||||
|
do
|
||||||
|
{
|
||||||
|
let knownSources = try result.get()
|
||||||
|
|
||||||
|
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
||||||
|
|
||||||
|
await withThrowingTaskGroup(of: Void.self) { taskGroup in
|
||||||
|
for source in knownSources.0
|
||||||
|
{
|
||||||
|
guard let sourceURL = source.sourceURL else { continue }
|
||||||
|
|
||||||
|
taskGroup.addTask {
|
||||||
|
_ = try await AppManager.shared.fetchSource(sourceURL: sourceURL, managedObjectContext: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.performAsync {
|
||||||
|
try! context.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
viewController.present(navigationController, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Logger.main.error("Failed to fetch known sources for preview. \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return viewController
|
||||||
|
}
|
||||||
52
AltStore/Components/AppBannerCollectionViewCell.swift
Normal file
52
AltStore/Components/AppBannerCollectionViewCell.swift
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
//
|
||||||
|
// AppBannerCollectionViewCell.swift
|
||||||
|
// AltStore
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 3/23/20.
|
||||||
|
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class AppBannerCollectionViewCell: UICollectionViewListCell
|
||||||
|
{
|
||||||
|
let bannerView = AppBannerView(frame: .zero)
|
||||||
|
|
||||||
|
override init(frame: CGRect)
|
||||||
|
{
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
self.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder)
|
||||||
|
{
|
||||||
|
super.init(coder: coder)
|
||||||
|
|
||||||
|
self.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initialize()
|
||||||
|
{
|
||||||
|
// Prevent content "squishing" when scrolling offscreen.
|
||||||
|
self.insetsLayoutMarginsFromSafeArea = false
|
||||||
|
self.contentView.insetsLayoutMarginsFromSafeArea = false
|
||||||
|
self.bannerView.insetsLayoutMarginsFromSafeArea = false
|
||||||
|
|
||||||
|
self.backgroundView = UIView() // Clear background
|
||||||
|
self.selectedBackgroundView = UIView() // Disable selection highlighting.
|
||||||
|
|
||||||
|
self.contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
self.contentView.preservesSuperviewLayoutMargins = true
|
||||||
|
|
||||||
|
self.bannerView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
self.contentView.addSubview(self.bannerView)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
self.bannerView.topAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.topAnchor),
|
||||||
|
self.bannerView.bottomAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.bottomAnchor),
|
||||||
|
self.bannerView.leadingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.leadingAnchor),
|
||||||
|
self.bannerView.trailingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.trailingAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,24 +7,373 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
import AltStoreCore
|
||||||
import Roxas
|
import Roxas
|
||||||
|
|
||||||
|
import Nuke
|
||||||
|
|
||||||
|
extension AppBannerView
|
||||||
|
{
|
||||||
|
static let standardHeight = 88.0
|
||||||
|
|
||||||
|
enum Style
|
||||||
|
{
|
||||||
|
case app
|
||||||
|
case source
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AppAction
|
||||||
|
{
|
||||||
|
case install
|
||||||
|
case open
|
||||||
|
case update
|
||||||
|
case custom(String)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class AppBannerView: RSTNibView
|
class AppBannerView: RSTNibView
|
||||||
{
|
{
|
||||||
|
override var accessibilityLabel: String? {
|
||||||
|
get { return self.accessibilityView?.accessibilityLabel }
|
||||||
|
set { self.accessibilityView?.accessibilityLabel = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
override open var accessibilityAttributedLabel: NSAttributedString? {
|
||||||
|
get { return self.accessibilityView?.accessibilityAttributedLabel }
|
||||||
|
set { self.accessibilityView?.accessibilityAttributedLabel = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
override var accessibilityValue: String? {
|
||||||
|
get { return self.accessibilityView?.accessibilityValue }
|
||||||
|
set { self.accessibilityView?.accessibilityValue = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
override open var accessibilityAttributedValue: NSAttributedString? {
|
||||||
|
get { return self.accessibilityView?.accessibilityAttributedValue }
|
||||||
|
set { self.accessibilityView?.accessibilityAttributedValue = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
override open var accessibilityTraits: UIAccessibilityTraits {
|
||||||
|
get { return self.accessibilityView?.accessibilityTraits ?? [] }
|
||||||
|
set { self.accessibilityView?.accessibilityTraits = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
var style: Style = .app
|
||||||
|
|
||||||
|
private var originalTintColor: UIColor?
|
||||||
|
|
||||||
@IBOutlet var titleLabel: UILabel!
|
@IBOutlet var titleLabel: UILabel!
|
||||||
@IBOutlet var subtitleLabel: UILabel!
|
@IBOutlet var subtitleLabel: UILabel!
|
||||||
@IBOutlet var iconImageView: AppIconImageView!
|
@IBOutlet var iconImageView: AppIconImageView!
|
||||||
@IBOutlet var button: PillButton!
|
@IBOutlet var button: PillButton!
|
||||||
|
@IBOutlet var buttonLabel: UILabel!
|
||||||
@IBOutlet var betaBadgeView: UIView!
|
@IBOutlet var betaBadgeView: UIView!
|
||||||
|
@IBOutlet var sourceIconImageView: AppIconImageView!
|
||||||
|
|
||||||
|
@IBOutlet var backgroundEffectView: UIVisualEffectView!
|
||||||
|
|
||||||
|
@IBOutlet private var vibrancyView: UIVisualEffectView!
|
||||||
|
@IBOutlet private var stackView: UIStackView!
|
||||||
|
@IBOutlet private var accessibilityView: UIView!
|
||||||
|
|
||||||
|
@IBOutlet private var iconImageViewHeightConstraint: NSLayoutConstraint!
|
||||||
|
|
||||||
|
override init(frame: CGRect)
|
||||||
|
{
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
self.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder)
|
||||||
|
{
|
||||||
|
super.init(coder: coder)
|
||||||
|
|
||||||
|
self.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initialize()
|
||||||
|
{
|
||||||
|
self.accessibilityView.accessibilityTraits.formUnion(.button)
|
||||||
|
|
||||||
|
self.isAccessibilityElement = false
|
||||||
|
self.accessibilityElements = [self.accessibilityView, self.button].compactMap { $0 }
|
||||||
|
|
||||||
|
self.betaBadgeView.isHidden = true
|
||||||
|
|
||||||
|
self.sourceIconImageView.style = .circular
|
||||||
|
self.sourceIconImageView.isHidden = true
|
||||||
|
|
||||||
|
self.layoutMargins = self.stackView.layoutMargins
|
||||||
|
self.insetsLayoutMarginsFromSafeArea = false
|
||||||
|
|
||||||
|
self.stackView.isLayoutMarginsRelativeArrangement = true
|
||||||
|
self.stackView.preservesSuperviewLayoutMargins = true
|
||||||
|
}
|
||||||
|
|
||||||
override func tintColorDidChange()
|
override func tintColorDidChange()
|
||||||
{
|
{
|
||||||
super.tintColorDidChange()
|
super.tintColorDidChange()
|
||||||
|
|
||||||
|
if self.tintAdjustmentMode != .dimmed
|
||||||
|
{
|
||||||
|
self.originalTintColor = self.tintColor
|
||||||
|
}
|
||||||
|
|
||||||
self.update()
|
self.update()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension AppBannerView
|
||||||
|
{
|
||||||
|
func configure(for app: AppProtocol, action: AppAction? = nil, showSourceIcon: Bool = true)
|
||||||
|
{
|
||||||
|
struct AppValues
|
||||||
|
{
|
||||||
|
var name: String
|
||||||
|
var developerName: String? = nil
|
||||||
|
var isBeta: Bool = false
|
||||||
|
|
||||||
|
init(app: AppProtocol)
|
||||||
|
{
|
||||||
|
self.name = app.name
|
||||||
|
|
||||||
|
guard let storeApp = (app as? StoreApp) ?? (app as? InstalledApp)?.storeApp else { return }
|
||||||
|
self.developerName = storeApp.developerName
|
||||||
|
|
||||||
|
if let track = storeApp.latestSupportedVersion?.channel,
|
||||||
|
ReleaseTracks.betaTracks.contains(track)
|
||||||
|
{
|
||||||
|
self.name = String(format: NSLocalizedString("%@ beta", comment: ""), app.name)
|
||||||
|
self.isBeta = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.style = .app
|
||||||
|
|
||||||
|
let values = AppValues(app: app)
|
||||||
|
self.titleLabel.text = app.name // Don't use values.name since that already includes "beta".
|
||||||
|
self.betaBadgeView.isHidden = !values.isBeta
|
||||||
|
|
||||||
|
if let developerName = values.developerName
|
||||||
|
{
|
||||||
|
self.subtitleLabel.text = developerName
|
||||||
|
self.accessibilityLabel = String(format: NSLocalizedString("%@ by %@", comment: ""), values.name, developerName)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.subtitleLabel.text = NSLocalizedString("Sideloaded", comment: "")
|
||||||
|
self.accessibilityLabel = values.name
|
||||||
|
}
|
||||||
|
|
||||||
|
if let storeApp = app.storeApp, storeApp.isPledgeRequired
|
||||||
|
{
|
||||||
|
// Always show button label for Patreon apps.
|
||||||
|
self.buttonLabel.isHidden = false
|
||||||
|
|
||||||
|
if storeApp.isPledged
|
||||||
|
{
|
||||||
|
self.buttonLabel.text = NSLocalizedString("Pledged", comment: "")
|
||||||
|
}
|
||||||
|
else if storeApp.installedApp != nil
|
||||||
|
{
|
||||||
|
self.buttonLabel.text = NSLocalizedString("Pledge Expired", comment: "")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.buttonLabel.text = NSLocalizedString("Join Patreon", comment: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.buttonLabel.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if let source = app.storeApp?.source, showSourceIcon
|
||||||
|
{
|
||||||
|
self.sourceIconImageView.isHidden = false
|
||||||
|
self.sourceIconImageView.backgroundColor = source.effectiveTintColor?.adjustedForDisplay ?? .altPrimary
|
||||||
|
|
||||||
|
if let iconURL = source.effectiveIconURL
|
||||||
|
{
|
||||||
|
if let image = ImageCache.shared[iconURL]
|
||||||
|
{
|
||||||
|
self.sourceIconImageView.backgroundColor = .white
|
||||||
|
self.sourceIconImageView.image = image.image
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.sourceIconImageView.image = nil
|
||||||
|
|
||||||
|
Nuke.loadImage(with: iconURL, into: self.sourceIconImageView) { result in
|
||||||
|
switch result
|
||||||
|
{
|
||||||
|
case .failure(let error): Logger.main.error("Failed to fetch source icon from \(iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
||||||
|
case .success: self.sourceIconImageView.backgroundColor = .white // In case icon has transparent background.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.sourceIconImageView.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let buttonAction: AppAction
|
||||||
|
|
||||||
|
if let action
|
||||||
|
{
|
||||||
|
buttonAction = action
|
||||||
|
}
|
||||||
|
else if let storeApp = app.storeApp
|
||||||
|
{
|
||||||
|
if let installedApp = storeApp.installedApp
|
||||||
|
{
|
||||||
|
// App is installed
|
||||||
|
|
||||||
|
// if installedApp.isUpdateAvailable
|
||||||
|
if installedApp.hasUpdate
|
||||||
|
{
|
||||||
|
buttonAction = .update
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
buttonAction = .open
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// App is not installed
|
||||||
|
buttonAction = .install
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// App is not from a source, fall back to .open
|
||||||
|
buttonAction = .open
|
||||||
|
}
|
||||||
|
|
||||||
|
UIView.performWithoutAnimation {
|
||||||
|
switch buttonAction
|
||||||
|
{
|
||||||
|
case .open:
|
||||||
|
let buttonTitle = NSLocalizedString("Open", comment: "")
|
||||||
|
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||||
|
self.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), values.name)
|
||||||
|
self.button.accessibilityValue = buttonTitle
|
||||||
|
|
||||||
|
self.button.countdownDate = nil
|
||||||
|
|
||||||
|
case .update:
|
||||||
|
let buttonTitle = NSLocalizedString("Update", comment: "")
|
||||||
|
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||||
|
self.button.accessibilityLabel = String(format: NSLocalizedString("Update %@", comment: ""), values.name)
|
||||||
|
self.button.accessibilityValue = buttonTitle
|
||||||
|
|
||||||
|
self.button.countdownDate = nil
|
||||||
|
|
||||||
|
case .custom(let buttonTitle):
|
||||||
|
self.button.setTitle(buttonTitle, for: .normal)
|
||||||
|
self.button.accessibilityLabel = buttonTitle
|
||||||
|
self.button.accessibilityValue = buttonTitle
|
||||||
|
|
||||||
|
self.button.countdownDate = nil
|
||||||
|
|
||||||
|
case .install:
|
||||||
|
if let storeApp = app.storeApp, storeApp.isPledgeRequired
|
||||||
|
{
|
||||||
|
// Pledge required
|
||||||
|
|
||||||
|
if storeApp.isPledged
|
||||||
|
{
|
||||||
|
let buttonTitle = NSLocalizedString("Install", comment: "")
|
||||||
|
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||||
|
self.button.accessibilityLabel = String(format: NSLocalizedString("Install %@", comment: ""), app.name)
|
||||||
|
self.button.accessibilityValue = buttonTitle
|
||||||
|
}
|
||||||
|
else if let amount = storeApp.pledgeAmount, let currencyCode = storeApp.pledgeCurrency, !storeApp.prefersCustomPledge, #available(iOS 15, *)
|
||||||
|
{
|
||||||
|
let price = amount.formatted(.currency(code: currencyCode).presentation(.narrow).precision(.fractionLength(0...2)))
|
||||||
|
|
||||||
|
let buttonTitle = String(format: NSLocalizedString("%@/mo", comment: ""), price)
|
||||||
|
self.button.setTitle(buttonTitle, for: .normal)
|
||||||
|
self.button.accessibilityLabel = String(format: NSLocalizedString("Pledge %@ a month", comment: ""), price)
|
||||||
|
self.button.accessibilityValue = String(format: NSLocalizedString("%@ a month", comment: ""), price)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
let buttonTitle = NSLocalizedString("Pledge", comment: "")
|
||||||
|
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||||
|
self.button.accessibilityLabel = buttonTitle
|
||||||
|
self.button.accessibilityValue = buttonTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Free app
|
||||||
|
|
||||||
|
let buttonTitle = NSLocalizedString("Free", comment: "")
|
||||||
|
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||||
|
self.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), app.name)
|
||||||
|
self.button.accessibilityValue = buttonTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
if let versionDate = app.storeApp?.latestSupportedVersion?.date, versionDate > Date()
|
||||||
|
{
|
||||||
|
self.button.countdownDate = versionDate
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.button.countdownDate = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure PillButton is correct size before assigning progress.
|
||||||
|
self.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let progress = AppManager.shared.installationProgress(for: app), progress.fractionCompleted < 1.0
|
||||||
|
{
|
||||||
|
self.button.progress = progress
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.button.progress = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(for source: Source)
|
||||||
|
{
|
||||||
|
self.style = .source
|
||||||
|
|
||||||
|
let subtitle: String
|
||||||
|
if let text = source.subtitle
|
||||||
|
{
|
||||||
|
subtitle = text
|
||||||
|
}
|
||||||
|
else if let scheme = source.sourceURL.scheme
|
||||||
|
{
|
||||||
|
subtitle = source.sourceURL.absoluteString.replacingOccurrences(of: scheme + "://", with: "")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
subtitle = source.sourceURL.absoluteString
|
||||||
|
}
|
||||||
|
|
||||||
|
self.titleLabel.text = source.name
|
||||||
|
self.subtitleLabel.text = subtitle
|
||||||
|
|
||||||
|
let tintColor = source.effectiveTintColor ?? .altPrimary
|
||||||
|
self.tintColor = tintColor
|
||||||
|
|
||||||
|
let accessibilityLabel = source.name + "\n" + subtitle
|
||||||
|
self.accessibilityLabel = accessibilityLabel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private extension AppBannerView
|
private extension AppBannerView
|
||||||
{
|
{
|
||||||
func update()
|
func update()
|
||||||
@@ -32,9 +381,48 @@ private extension AppBannerView
|
|||||||
self.clipsToBounds = true
|
self.clipsToBounds = true
|
||||||
self.layer.cornerRadius = 22
|
self.layer.cornerRadius = 22
|
||||||
|
|
||||||
self.subtitleLabel.textColor = self.tintColor
|
let tintColor = self.originalTintColor ?? self.tintColor
|
||||||
self.button.tintColor = self.tintColor
|
self.subtitleLabel.textColor = tintColor
|
||||||
|
|
||||||
self.backgroundColor = self.tintColor.withAlphaComponent(0.1)
|
switch self.style
|
||||||
|
{
|
||||||
|
case .app:
|
||||||
|
self.directionalLayoutMargins.trailing = self.stackView.directionalLayoutMargins.trailing
|
||||||
|
|
||||||
|
self.iconImageViewHeightConstraint.constant = 60
|
||||||
|
self.iconImageView.style = .icon
|
||||||
|
|
||||||
|
self.titleLabel.textColor = .label
|
||||||
|
|
||||||
|
self.button.style = .pill
|
||||||
|
|
||||||
|
self.backgroundEffectView.contentView.backgroundColor = UIColor(resource: .blurTint)
|
||||||
|
self.backgroundEffectView.backgroundColor = tintColor
|
||||||
|
|
||||||
|
case .source:
|
||||||
|
self.directionalLayoutMargins.trailing = 20
|
||||||
|
|
||||||
|
self.iconImageViewHeightConstraint.constant = 44
|
||||||
|
self.iconImageView.style = .circular
|
||||||
|
|
||||||
|
self.titleLabel.textColor = .white
|
||||||
|
|
||||||
|
self.button.style = .custom
|
||||||
|
|
||||||
|
self.backgroundEffectView.contentView.backgroundColor = tintColor?.adjustedForDisplay
|
||||||
|
self.backgroundEffectView.backgroundColor = nil
|
||||||
|
|
||||||
|
if let tintColor, tintColor.isTooBright
|
||||||
|
{
|
||||||
|
let textVibrancyEffect = UIVibrancyEffect(blurEffect: .init(style: .systemChromeMaterialLight), style: .fill)
|
||||||
|
self.vibrancyView.effect = textVibrancyEffect
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Thinner == more dull
|
||||||
|
let textVibrancyEffect = UIVibrancyEffect(blurEffect: .init(style: .systemThinMaterialDark), style: .secondaryLabel)
|
||||||
|
self.vibrancyView.effect = textVibrancyEffect
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="22155" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||||
<device id="retina6_1" orientation="portrait">
|
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||||
<adaptation id="fullscreen"/>
|
|
||||||
</device>
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<deployment identifier="iOS"/>
|
<deployment identifier="iOS"/>
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14490.49"/>
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22131"/>
|
||||||
|
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||||
|
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<objects>
|
<objects>
|
||||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="AppBannerView" customModule="AltStore" customModuleProvider="target">
|
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="AppBannerView" customModule="AltStore" customModuleProvider="target">
|
||||||
<connections>
|
<connections>
|
||||||
|
<outlet property="accessibilityView" destination="bJL-Yw-i4u" id="PWe-tw-jDA"/>
|
||||||
|
<outlet property="backgroundEffectView" destination="rZk-be-tiI" id="fzU-VT-JeW"/>
|
||||||
<outlet property="betaBadgeView" destination="qQl-Ez-zC5" id="6O1-Cx-7qz"/>
|
<outlet property="betaBadgeView" destination="qQl-Ez-zC5" id="6O1-Cx-7qz"/>
|
||||||
<outlet property="button" destination="tVx-3G-dcu" id="joa-AH-syX"/>
|
<outlet property="button" destination="tVx-3G-dcu" id="joa-AH-syX"/>
|
||||||
|
<outlet property="buttonLabel" destination="Yd9-jw-faD" id="o7g-Gb-CIt"/>
|
||||||
<outlet property="iconImageView" destination="avS-dx-4iy" id="TQs-Ej-gin"/>
|
<outlet property="iconImageView" destination="avS-dx-4iy" id="TQs-Ej-gin"/>
|
||||||
|
<outlet property="iconImageViewHeightConstraint" destination="6lU-H8-nEw" id="PSt-Xa-lQT"/>
|
||||||
|
<outlet property="sourceIconImageView" destination="dku-SJ-aay" id="rA0-y1-dIb"/>
|
||||||
|
<outlet property="stackView" destination="d1T-UD-gWG" id="E7N-Zb-lm1"/>
|
||||||
<outlet property="subtitleLabel" destination="oN5-vu-Dnw" id="gA4-iJ-Tix"/>
|
<outlet property="subtitleLabel" destination="oN5-vu-Dnw" id="gA4-iJ-Tix"/>
|
||||||
<outlet property="titleLabel" destination="mFe-zJ-eva" id="2OH-f8-cid"/>
|
<outlet property="titleLabel" destination="mFe-zJ-eva" id="2OH-f8-cid"/>
|
||||||
|
<outlet property="vibrancyView" destination="fC4-1V-iMn" id="PXE-2B-A7w"/>
|
||||||
</connections>
|
</connections>
|
||||||
</placeholder>
|
</placeholder>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||||
@@ -23,47 +30,93 @@
|
|||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="88"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="88"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" alignment="center" spacing="11" translatesAutoresizingMaskIntoConstraints="NO" id="d1T-UD-gWG" userLabel="App Info">
|
<view userInteractionEnabled="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="bJL-Yw-i4u">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="375" height="88"/>
|
||||||
|
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
<accessibility key="accessibilityConfiguration">
|
||||||
|
<bool key="isElement" value="YES"/>
|
||||||
|
</accessibility>
|
||||||
|
</view>
|
||||||
|
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="rZk-be-tiI">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="375" height="88"/>
|
||||||
|
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="b8k-up-HtI">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="375" height="88"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<color key="backgroundColor" name="BlurTint"/>
|
||||||
|
</view>
|
||||||
|
<blurEffect style="systemChromeMaterial"/>
|
||||||
|
</visualEffectView>
|
||||||
|
<stackView opaque="NO" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" alignment="center" spacing="11" translatesAutoresizingMaskIntoConstraints="NO" id="d1T-UD-gWG" userLabel="App Info">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="88"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="88"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="avS-dx-4iy" customClass="AppIconImageView" customModule="AltStore" customModuleProvider="target">
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="avS-dx-4iy" customClass="AppIconImageView" customModule="AltStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="14" y="14" width="60" height="60"/>
|
<rect key="frame" x="16" y="14" width="60" height="60"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="height" constant="60" id="6lU-H8-nEw"/>
|
<constraint firstAttribute="height" constant="60" id="6lU-H8-nEw"/>
|
||||||
<constraint firstAttribute="width" secondItem="avS-dx-4iy" secondAttribute="height" multiplier="1:1" id="AYT-3g-wcV"/>
|
<constraint firstAttribute="width" secondItem="avS-dx-4iy" secondAttribute="height" multiplier="1:1" id="AYT-3g-wcV"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
</imageView>
|
</imageView>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="100" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" alignment="top" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="caL-vN-Svn">
|
<stackView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="100" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" alignment="top" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="caL-vN-Svn">
|
||||||
<rect key="frame" x="85" y="24" width="195" height="40.5"/>
|
<rect key="frame" x="87" y="25.5" width="184" height="37.5"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" spacing="6" translatesAutoresizingMaskIntoConstraints="NO" id="1Es-pv-zwd">
|
<stackView opaque="NO" contentMode="scaleToFill" horizontalCompressionResistancePriority="500" alignment="center" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="1Es-pv-zwd">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="135" height="21.5"/>
|
<rect key="frame" x="0.0" y="0.0" width="147" height="19.5"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="251" horizontalCompressionResistancePriority="500" text="App Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="mFe-zJ-eva">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="400" verticalHuggingPriority="251" horizontalCompressionResistancePriority="500" text="App Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="mFe-zJ-eva">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="88" height="21.5"/>
|
<rect key="frame" x="0.0" y="0.0" width="79" height="19.5"/>
|
||||||
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="18"/>
|
<accessibility key="accessibilityConfiguration" identifier="NameLabel"/>
|
||||||
|
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="16"/>
|
||||||
<nil key="textColor"/>
|
<nil key="textColor"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="dku-SJ-aay" customClass="AppIconImageView" customModule="AltStore" customModuleProvider="target">
|
||||||
|
<rect key="frame" x="84" y="1" width="17" height="17"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstAttribute="width" secondItem="dku-SJ-aay" secondAttribute="height" id="VKw-lc-8NQ"/>
|
||||||
|
<constraint firstAttribute="width" constant="17" id="hAe-gc-Ehh"/>
|
||||||
|
</constraints>
|
||||||
|
</imageView>
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="BetaBadge" translatesAutoresizingMaskIntoConstraints="NO" id="qQl-Ez-zC5">
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="BetaBadge" translatesAutoresizingMaskIntoConstraints="NO" id="qQl-Ez-zC5">
|
||||||
<rect key="frame" x="94" y="0.0" width="41" height="21.5"/>
|
<rect key="frame" x="106" y="1" width="41" height="17"/>
|
||||||
|
<accessibility key="accessibilityConfiguration" identifier="Beta Badge">
|
||||||
|
<accessibilityTraits key="traits" image="YES" notEnabled="YES"/>
|
||||||
|
<bool key="isElement" value="YES"/>
|
||||||
|
</accessibility>
|
||||||
</imageView>
|
</imageView>
|
||||||
</subviews>
|
</subviews>
|
||||||
</stackView>
|
</stackView>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="251" text="Developer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="oN5-vu-Dnw">
|
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="fC4-1V-iMn">
|
||||||
<rect key="frame" x="0.0" y="23.5" width="66" height="17"/>
|
<rect key="frame" x="0.0" y="21.5" width="62" height="16"/>
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="LQh-pN-ePC">
|
||||||
<color key="textColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<rect key="frame" x="0.0" y="0.0" width="62" height="16"/>
|
||||||
<nil key="highlightedColor"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
</label>
|
<subviews>
|
||||||
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="750" verticalHuggingPriority="750" text="Developer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="oN5-vu-Dnw">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="62" height="16"/>
|
||||||
|
<fontDescription key="fontDescription" type="system" pointSize="13"/>
|
||||||
|
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
</subviews>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="oN5-vu-Dnw" firstAttribute="top" secondItem="LQh-pN-ePC" secondAttribute="top" id="7RH-WP-LzL"/>
|
||||||
|
<constraint firstItem="oN5-vu-Dnw" firstAttribute="leading" secondItem="LQh-pN-ePC" secondAttribute="leading" id="By8-cR-kTu"/>
|
||||||
|
<constraint firstAttribute="trailing" secondItem="oN5-vu-Dnw" secondAttribute="trailing" id="Hiv-6y-XrH"/>
|
||||||
|
<constraint firstAttribute="bottom" secondItem="oN5-vu-Dnw" secondAttribute="bottom" id="yc2-Dr-Qnv"/>
|
||||||
|
</constraints>
|
||||||
|
</view>
|
||||||
|
<vibrancyEffect style="secondaryLabel">
|
||||||
|
<blurEffect style="systemChromeMaterial"/>
|
||||||
|
</vibrancyEffect>
|
||||||
|
</visualEffectView>
|
||||||
</subviews>
|
</subviews>
|
||||||
</stackView>
|
</stackView>
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="tVx-3G-dcu" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
|
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="900" horizontalCompressionResistancePriority="900" placeholderIntrinsicWidth="77" placeholderIntrinsicHeight="31" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="tVx-3G-dcu" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="291" y="28.5" width="72" height="31"/>
|
<rect key="frame" x="282" y="28.5" width="77" height="31"/>
|
||||||
<color key="backgroundColor" red="0.22352941179999999" green="0.4941176471" blue="0.39607843139999999" alpha="0.10000000000000001" colorSpace="custom" customColorSpace="sRGB"/>
|
<color key="backgroundColor" red="0.22352941179999999" green="0.4941176471" blue="0.39607843139999999" alpha="0.10000000000000001" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
|
<constraint firstAttribute="width" relation="greaterThanOrEqual" secondItem="tVx-3G-dcu" secondAttribute="height" priority="999" id="Vbk-VH-5eU"/>
|
||||||
<constraint firstAttribute="height" constant="31" id="Zwh-yQ-GTu"/>
|
<constraint firstAttribute="height" constant="31" id="Zwh-yQ-GTu"/>
|
||||||
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="72" id="eGc-Dk-QbL"/>
|
|
||||||
</constraints>
|
</constraints>
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
|
||||||
<state key="normal" title="FREE"/>
|
<state key="normal" title="FREE"/>
|
||||||
@@ -71,17 +124,40 @@
|
|||||||
</subviews>
|
</subviews>
|
||||||
<edgeInsets key="layoutMargins" top="14" left="14" bottom="14" right="12"/>
|
<edgeInsets key="layoutMargins" top="14" left="14" bottom="14" right="12"/>
|
||||||
</stackView>
|
</stackView>
|
||||||
|
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Yd9-jw-faD">
|
||||||
|
<rect key="frame" x="307" y="12.5" width="27" height="12"/>
|
||||||
|
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="10"/>
|
||||||
|
<color key="textColor" systemColor="secondaryLabelColor"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
</subviews>
|
</subviews>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="bottom" secondItem="d1T-UD-gWG" secondAttribute="bottom" id="B9e-Mf-cy5"/>
|
<constraint firstItem="d1T-UD-gWG" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="5tv-QN-ZWU"/>
|
||||||
<constraint firstAttribute="trailing" secondItem="d1T-UD-gWG" secondAttribute="trailing" id="HcT-2k-z0H"/>
|
<constraint firstAttribute="bottom" secondItem="bJL-Yw-i4u" secondAttribute="bottom" id="FRq-ZD-2rE"/>
|
||||||
<constraint firstItem="d1T-UD-gWG" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="PIM-W5-dkh"/>
|
<constraint firstItem="rZk-be-tiI" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="N6R-B2-Rie"/>
|
||||||
<constraint firstItem="d1T-UD-gWG" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="RHn-ZK-jgl"/>
|
<constraint firstItem="Yd9-jw-faD" firstAttribute="centerX" secondItem="tVx-3G-dcu" secondAttribute="centerX" id="acx-pf-8hH"/>
|
||||||
|
<constraint firstItem="bJL-Yw-i4u" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="h6T-q1-YV9"/>
|
||||||
|
<constraint firstItem="tVx-3G-dcu" firstAttribute="top" secondItem="Yd9-jw-faD" secondAttribute="bottom" constant="4" id="hTD-wh-KV8"/>
|
||||||
|
<constraint firstAttribute="trailing" secondItem="d1T-UD-gWG" secondAttribute="trailing" id="mlG-w3-Ly6"/>
|
||||||
|
<constraint firstAttribute="trailing" secondItem="rZk-be-tiI" secondAttribute="trailing" id="nEy-pm-Fcs"/>
|
||||||
|
<constraint firstAttribute="bottom" secondItem="d1T-UD-gWG" secondAttribute="bottom" priority="999" id="nJo-To-LmX"/>
|
||||||
|
<constraint firstItem="bJL-Yw-i4u" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="oLt-2z-QoJ"/>
|
||||||
|
<constraint firstItem="rZk-be-tiI" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="pGD-Tl-U4c"/>
|
||||||
|
<constraint firstItem="d1T-UD-gWG" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="q2p-0S-Nv5"/>
|
||||||
|
<constraint firstAttribute="trailing" secondItem="bJL-Yw-i4u" secondAttribute="trailing" id="vwx-P9-dlB"/>
|
||||||
|
<constraint firstAttribute="bottom" secondItem="rZk-be-tiI" secondAttribute="bottom" id="yk0-pw-joP"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
|
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
|
||||||
|
<point key="canvasLocation" x="139.85507246376812" y="152.67857142857142"/>
|
||||||
</view>
|
</view>
|
||||||
</objects>
|
</objects>
|
||||||
<resources>
|
<resources>
|
||||||
<image name="BetaBadge" width="41" height="17"/>
|
<image name="BetaBadge" width="41" height="17"/>
|
||||||
|
<namedColor name="BlurTint">
|
||||||
|
<color red="1" green="1" blue="1" alpha="0.30000001192092896" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
</namedColor>
|
||||||
|
<systemColor name="secondaryLabelColor">
|
||||||
|
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
</systemColor>
|
||||||
</resources>
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
|
|||||||
388
AltStore/Components/AppCardCollectionViewCell.swift
Normal file
388
AltStore/Components/AppCardCollectionViewCell.swift
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
//
|
||||||
|
// AppCardCollectionViewCell.swift
|
||||||
|
// AltStore
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 10/13/23.
|
||||||
|
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
import AltStoreCore
|
||||||
|
import Roxas
|
||||||
|
|
||||||
|
import Nuke
|
||||||
|
|
||||||
|
private let minimumItemSpacing = 8.0
|
||||||
|
|
||||||
|
class AppCardCollectionViewCell: UICollectionViewCell
|
||||||
|
{
|
||||||
|
let bannerView: AppBannerView
|
||||||
|
let captionLabel: UILabel
|
||||||
|
|
||||||
|
var prefersPagingScreenshots = true
|
||||||
|
|
||||||
|
private let screenshotsCollectionView: UICollectionView
|
||||||
|
private let stackView: UIStackView
|
||||||
|
|
||||||
|
private let topAreaPanGestureRecognizer: UIPanGestureRecognizer
|
||||||
|
|
||||||
|
private lazy var dataSource = self.makeDataSource()
|
||||||
|
|
||||||
|
private var screenshots: [AppScreenshot] = [] {
|
||||||
|
didSet {
|
||||||
|
self.dataSource.items = self.screenshots
|
||||||
|
|
||||||
|
if self.screenshots.isEmpty
|
||||||
|
{
|
||||||
|
// No screenshots, so hide collection view.
|
||||||
|
self.collectionViewAspectRatioConstraint.isActive = false
|
||||||
|
self.stackView.layoutMargins.bottom = 0
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// At least one screenshot, so show collection view.
|
||||||
|
self.collectionViewAspectRatioConstraint.isActive = true
|
||||||
|
self.stackView.layoutMargins.bottom = self.screenshotsCollectionView.directionalLayoutMargins.leading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let collectionViewAspectRatioConstraint: NSLayoutConstraint
|
||||||
|
|
||||||
|
override init(frame: CGRect)
|
||||||
|
{
|
||||||
|
self.bannerView = AppBannerView(frame: .zero)
|
||||||
|
self.bannerView.layoutMargins.bottom = 0
|
||||||
|
|
||||||
|
let vibrancyEffect = UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemChromeMaterial), style: .secondaryLabel)
|
||||||
|
let captionVibrancyView = UIVisualEffectView(effect: vibrancyEffect)
|
||||||
|
|
||||||
|
self.captionLabel = UILabel(frame: .zero)
|
||||||
|
self.captionLabel.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .footnote).bolded(), size: 0)
|
||||||
|
self.captionLabel.textAlignment = .center
|
||||||
|
self.captionLabel.numberOfLines = 2
|
||||||
|
self.captionLabel.minimumScaleFactor = 0.8
|
||||||
|
captionVibrancyView.contentView.addSubview(self.captionLabel, pinningEdgesWith: .zero)
|
||||||
|
|
||||||
|
self.screenshotsCollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
|
||||||
|
self.screenshotsCollectionView.backgroundColor = nil
|
||||||
|
self.screenshotsCollectionView.alwaysBounceVertical = false
|
||||||
|
self.screenshotsCollectionView.alwaysBounceHorizontal = true
|
||||||
|
self.screenshotsCollectionView.showsHorizontalScrollIndicator = false
|
||||||
|
self.screenshotsCollectionView.showsVerticalScrollIndicator = false
|
||||||
|
|
||||||
|
self.stackView = UIStackView(arrangedSubviews: [self.bannerView, captionVibrancyView, self.screenshotsCollectionView])
|
||||||
|
self.stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
self.stackView.spacing = 12
|
||||||
|
self.stackView.axis = .vertical
|
||||||
|
self.stackView.alignment = .fill
|
||||||
|
self.stackView.distribution = .equalSpacing
|
||||||
|
|
||||||
|
// Aspect ratio constraint to fit exactly 3 modern portrait iPhone screenshots side-by-side (with spacing).
|
||||||
|
let inset = self.bannerView.layoutMargins.left
|
||||||
|
let multiplier = (AppScreenshot.defaultAspectRatio.width * 3) / AppScreenshot.defaultAspectRatio.height
|
||||||
|
let spacing = (inset * 2) + (minimumItemSpacing * 2)
|
||||||
|
self.collectionViewAspectRatioConstraint = self.screenshotsCollectionView.widthAnchor.constraint(equalTo: self.screenshotsCollectionView.heightAnchor, multiplier: multiplier, constant: spacing)
|
||||||
|
|
||||||
|
// Allows us to ignore swipes in top portion of screenshotsCollectionView.
|
||||||
|
self.topAreaPanGestureRecognizer = UIPanGestureRecognizer(target: nil, action: nil)
|
||||||
|
self.topAreaPanGestureRecognizer.cancelsTouchesInView = false
|
||||||
|
self.topAreaPanGestureRecognizer.delaysTouchesBegan = false
|
||||||
|
self.topAreaPanGestureRecognizer.delaysTouchesEnded = false
|
||||||
|
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
self.contentView.clipsToBounds = true
|
||||||
|
self.contentView.layer.cornerCurve = .continuous
|
||||||
|
|
||||||
|
self.contentView.addSubview(self.bannerView.backgroundEffectView, pinningEdgesWith: .zero)
|
||||||
|
self.contentView.addSubview(self.stackView, pinningEdgesWith: .zero)
|
||||||
|
|
||||||
|
self.screenshotsCollectionView.collectionViewLayout = self.makeLayout()
|
||||||
|
self.screenshotsCollectionView.dataSource = self.dataSource
|
||||||
|
self.screenshotsCollectionView.prefetchDataSource = self.dataSource
|
||||||
|
|
||||||
|
// Adding screenshotsCollectionView's gesture recognizers to self.contentView breaks paging,
|
||||||
|
// so instead we intercept taps and pass them onto delegate.
|
||||||
|
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(AppCardCollectionViewCell.handleTapGesture(_:)))
|
||||||
|
tapGestureRecognizer.cancelsTouchesInView = false
|
||||||
|
tapGestureRecognizer.delaysTouchesBegan = false
|
||||||
|
tapGestureRecognizer.delaysTouchesEnded = false
|
||||||
|
self.screenshotsCollectionView.addGestureRecognizer(tapGestureRecognizer)
|
||||||
|
|
||||||
|
self.topAreaPanGestureRecognizer.delegate = self
|
||||||
|
self.screenshotsCollectionView.panGestureRecognizer.require(toFail: self.topAreaPanGestureRecognizer)
|
||||||
|
self.screenshotsCollectionView.addGestureRecognizer(self.topAreaPanGestureRecognizer)
|
||||||
|
|
||||||
|
self.screenshotsCollectionView.register(AppScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||||
|
|
||||||
|
self.stackView.isLayoutMarginsRelativeArrangement = true
|
||||||
|
self.stackView.layoutMargins.bottom = inset
|
||||||
|
|
||||||
|
self.contentView.preservesSuperviewLayoutMargins = true
|
||||||
|
self.screenshotsCollectionView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 0, leading: inset, bottom: 0, trailing: inset)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
self.bannerView.heightAnchor.constraint(equalToConstant: AppBannerView.standardHeight - inset)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews()
|
||||||
|
{
|
||||||
|
super.layoutSubviews()
|
||||||
|
|
||||||
|
self.contentView.layer.cornerRadius = self.bannerView.layer.cornerRadius
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension AppCardCollectionViewCell
|
||||||
|
{
|
||||||
|
func makeLayout() -> UICollectionViewCompositionalLayout
|
||||||
|
{
|
||||||
|
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
|
||||||
|
layoutConfig.contentInsetsReference = .layoutMargins
|
||||||
|
|
||||||
|
let layout = UICollectionViewCompositionalLayout(sectionProvider: { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
|
||||||
|
guard let self else { return nil }
|
||||||
|
|
||||||
|
var contentWidth = 0.0
|
||||||
|
var numberOfVisibleScreenshots = 0
|
||||||
|
|
||||||
|
for screenshot in self.screenshots
|
||||||
|
{
|
||||||
|
var aspectRatio = screenshot.aspectRatio
|
||||||
|
if aspectRatio.width > aspectRatio.height
|
||||||
|
{
|
||||||
|
switch screenshot.deviceType
|
||||||
|
{
|
||||||
|
case .iphone:
|
||||||
|
// Always rotate landscape iPhone screenshots
|
||||||
|
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
||||||
|
|
||||||
|
case .ipad:
|
||||||
|
// Never rotate iPad screenshots
|
||||||
|
break
|
||||||
|
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let screenshotWidth = (layoutEnvironment.container.effectiveContentSize.height * (aspectRatio.width / aspectRatio.height)).rounded(.up) // Round to ensure we over-estimate contentWidth.
|
||||||
|
|
||||||
|
let totalContentWidth = contentWidth + (screenshotWidth + minimumItemSpacing)
|
||||||
|
if totalContentWidth > layoutEnvironment.container.effectiveContentSize.width
|
||||||
|
{
|
||||||
|
// totalContentWidth is larger than visible width.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
contentWidth = totalContentWidth
|
||||||
|
numberOfVisibleScreenshots += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use .estimated(1) to ensure we don't over-estimate widths, which can cause incorrect layouts for the last group.
|
||||||
|
let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(1), heightDimension: .fractionalHeight(1.0))
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||||
|
|
||||||
|
if numberOfVisibleScreenshots == 1
|
||||||
|
{
|
||||||
|
// If there's only one screenshot visible initially, we'll (reluctantly) opt-in to flexible spacing on both sides.
|
||||||
|
// This ensures the items are always centered, but may result in larger spacings between items than we'd prefer.
|
||||||
|
item.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .flexible(0), top: nil, trailing: .flexible(0), bottom: nil)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Otherwise, only have flexible spacing on the leading edge, which will be balanced by trailingGroup's flexible trailing spacing.
|
||||||
|
item.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .flexible(0), top: nil, trailing: nil, bottom: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
let groupItem = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||||
|
let trailingGroup = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [groupItem])
|
||||||
|
trailingGroup.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: nil, top: nil, trailing: .flexible(0), bottom: nil)
|
||||||
|
|
||||||
|
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
|
||||||
|
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item, trailingGroup])
|
||||||
|
group.interItemSpacing = .fixed(minimumItemSpacing)
|
||||||
|
|
||||||
|
if numberOfVisibleScreenshots < self.screenshots.count
|
||||||
|
{
|
||||||
|
// There are more screenshots than what is displayed, so no need to manually center them.
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// We're showing all screenshots initially, so make sure they're centered.
|
||||||
|
|
||||||
|
let insetWidth = (layoutEnvironment.container.effectiveContentSize.width - contentWidth) / 2.0
|
||||||
|
group.contentInsets.leading = (insetWidth - 1).rounded(.down) // Subtract 1 to avoid overflowing/clipping
|
||||||
|
}
|
||||||
|
|
||||||
|
let layoutSection = NSCollectionLayoutSection(group: group)
|
||||||
|
layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
|
||||||
|
layoutSection.interGroupSpacing = self.screenshotsCollectionView.directionalLayoutMargins.leading + self.screenshotsCollectionView.directionalLayoutMargins.trailing
|
||||||
|
return layoutSection
|
||||||
|
}, configuration: layoutConfig)
|
||||||
|
|
||||||
|
return layout
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>
|
||||||
|
{
|
||||||
|
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>(items: [])
|
||||||
|
dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in
|
||||||
|
let cell = cell as! AppScreenshotCollectionViewCell
|
||||||
|
cell.imageView.image = nil
|
||||||
|
cell.imageView.isIndicatingActivity = true
|
||||||
|
|
||||||
|
var aspectRatio = screenshot.aspectRatio
|
||||||
|
if aspectRatio.width > aspectRatio.height
|
||||||
|
{
|
||||||
|
switch screenshot.deviceType
|
||||||
|
{
|
||||||
|
case .iphone:
|
||||||
|
// Always rotate landscape iPhone screenshots
|
||||||
|
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
||||||
|
|
||||||
|
case .ipad:
|
||||||
|
// Never rotate iPad screenshots
|
||||||
|
break
|
||||||
|
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cell.aspectRatio = aspectRatio
|
||||||
|
}
|
||||||
|
dataSource.prefetchHandler = { (screenshot, indexPath, completionHandler) in
|
||||||
|
let imageURL = screenshot.imageURL
|
||||||
|
return RSTAsyncBlockOperation() { (operation) in
|
||||||
|
let request = ImageRequest(url: imageURL)
|
||||||
|
ImagePipeline.shared.loadImage(with: request, progress: nil) { result in
|
||||||
|
guard !operation.isCancelled else { return operation.finish() }
|
||||||
|
|
||||||
|
switch result
|
||||||
|
{
|
||||||
|
case .success(let response): completionHandler(response.image, nil)
|
||||||
|
case .failure(let error): completionHandler(nil, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
|
||||||
|
let cell = cell as! AppScreenshotCollectionViewCell
|
||||||
|
cell.imageView.isIndicatingActivity = false
|
||||||
|
cell.setImage(image)
|
||||||
|
|
||||||
|
if let error = error
|
||||||
|
{
|
||||||
|
print("Error loading image:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataSource
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func handleTapGesture(_ tapGesture: UITapGestureRecognizer)
|
||||||
|
{
|
||||||
|
var superview: UIView? = self.superview
|
||||||
|
var collectionView: UICollectionView? = nil
|
||||||
|
|
||||||
|
while case let view? = superview
|
||||||
|
{
|
||||||
|
if let cv = view as? UICollectionView
|
||||||
|
{
|
||||||
|
collectionView = cv
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
superview = view.superview
|
||||||
|
}
|
||||||
|
|
||||||
|
if let collectionView, let indexPath = collectionView.indexPath(for: self)
|
||||||
|
{
|
||||||
|
collectionView.delegate?.collectionView?(collectionView, didSelectItemAt: indexPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppCardCollectionViewCell
|
||||||
|
{
|
||||||
|
func configure(for storeApp: StoreApp, showSourceIcon: Bool = true)
|
||||||
|
{
|
||||||
|
self.screenshots = storeApp.preferredScreenshots()
|
||||||
|
|
||||||
|
// Explicitly set to false to ensure we're starting from a non-activity indicating state.
|
||||||
|
// Otherwise, cell reuse can mess up some cached values.
|
||||||
|
self.bannerView.button.isIndicatingActivity = false
|
||||||
|
|
||||||
|
self.bannerView.tintColor = storeApp.tintColor
|
||||||
|
self.bannerView.configure(for: storeApp, showSourceIcon: showSourceIcon)
|
||||||
|
|
||||||
|
self.bannerView.subtitleLabel.numberOfLines = 1
|
||||||
|
self.bannerView.subtitleLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
self.bannerView.subtitleLabel.minimumScaleFactor = 0.8
|
||||||
|
self.bannerView.subtitleLabel.text = storeApp.developerName
|
||||||
|
|
||||||
|
if let subtitle = storeApp.subtitle, !subtitle.isEmpty
|
||||||
|
{
|
||||||
|
self.captionLabel.text = subtitle
|
||||||
|
self.captionLabel.isHidden = false
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.captionLabel.isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppCardCollectionViewCell: UIGestureRecognizerDelegate
|
||||||
|
{
|
||||||
|
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool
|
||||||
|
{
|
||||||
|
// Never recognize topAreaPanGestureRecognizer unless prefersPagingScreenshots is false.
|
||||||
|
guard !self.prefersPagingScreenshots else { return false }
|
||||||
|
|
||||||
|
let point = gestureRecognizer.location(in: self.screenshotsCollectionView)
|
||||||
|
|
||||||
|
// Top area = Top 3/4
|
||||||
|
let isTopArea = point.y < (self.screenshotsCollectionView.bounds.height / 4) * 3
|
||||||
|
return isTopArea
|
||||||
|
}
|
||||||
|
|
||||||
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool
|
||||||
|
{
|
||||||
|
guard let panGestureRecognizer = otherGestureRecognizer as? UIPanGestureRecognizer, let view = panGestureRecognizer.view else { return false }
|
||||||
|
|
||||||
|
if view.isDescendant(of: self.screenshotsCollectionView)
|
||||||
|
{
|
||||||
|
// Only allow nested gesture recognizers if topAreaPanGestureRecognizer fails.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Always allow parent gesture recognizers.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool
|
||||||
|
{
|
||||||
|
guard let panGestureRecognizer = otherGestureRecognizer as? UIPanGestureRecognizer, let view = panGestureRecognizer.view else { return true }
|
||||||
|
|
||||||
|
if view.isDescendant(of: self.screenshotsCollectionView)
|
||||||
|
{
|
||||||
|
// Don't recognize topAreaPanGestureRecognizer alongside nested gesture recognizers.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Allow recognizing simultaneously with parent gesture recognizers.
|
||||||
|
// This fixes accidentally breaking scrolling in parent.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,40 +8,62 @@
|
|||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
extension AppIconImageView
|
||||||
|
{
|
||||||
|
enum Style
|
||||||
|
{
|
||||||
|
case icon
|
||||||
|
case circular
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class AppIconImageView: UIImageView
|
class AppIconImageView: UIImageView
|
||||||
{
|
{
|
||||||
override func awakeFromNib()
|
var style: Style = .icon {
|
||||||
|
didSet {
|
||||||
|
self.setNeedsLayout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(style: Style)
|
||||||
{
|
{
|
||||||
super.awakeFromNib()
|
self.style = style
|
||||||
|
|
||||||
|
super.init(image: nil)
|
||||||
|
|
||||||
|
self.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder)
|
||||||
|
{
|
||||||
|
super.init(coder: coder)
|
||||||
|
|
||||||
|
self.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initialize()
|
||||||
|
{
|
||||||
self.contentMode = .scaleAspectFill
|
self.contentMode = .scaleAspectFill
|
||||||
self.clipsToBounds = true
|
self.clipsToBounds = true
|
||||||
|
|
||||||
self.backgroundColor = .white
|
self.backgroundColor = .white
|
||||||
|
|
||||||
self.layer.borderWidth = 0.5
|
|
||||||
self.layer.borderColor = self.tintColor.cgColor
|
|
||||||
|
|
||||||
// Allows us to match system look for app icons.
|
self.layer.cornerCurve = .continuous
|
||||||
if self.layer.responds(to: Selector(("continuousCorners")))
|
|
||||||
{
|
|
||||||
self.layer.setValue(true, forKey: "continuousCorners")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func layoutSubviews()
|
override func layoutSubviews()
|
||||||
{
|
{
|
||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
|
|
||||||
// Based off of 60pt icon having 12pt radius.
|
switch self.style
|
||||||
let radius = self.bounds.height / 5
|
{
|
||||||
self.layer.cornerRadius = radius
|
case .icon:
|
||||||
}
|
// Based off of 60pt icon having 12pt radius.
|
||||||
|
let radius = self.bounds.height / 5
|
||||||
override func tintColorDidChange()
|
self.layer.cornerRadius = radius
|
||||||
{
|
|
||||||
super.tintColorDidChange()
|
case .circular:
|
||||||
|
let radius = self.bounds.height / 2
|
||||||
self.layer.borderColor = self.tintColor.cgColor
|
self.layer.cornerRadius = radius
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
||||||
class BackgroundTaskManager
|
final class BackgroundTaskManager
|
||||||
{
|
{
|
||||||
static let shared = BackgroundTaskManager()
|
static let shared = BackgroundTaskManager()
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class Button: UIButton
|
final class Button: UIButton
|
||||||
{
|
{
|
||||||
override var intrinsicContentSize: CGSize {
|
override var intrinsicContentSize: CGSize {
|
||||||
var size = super.intrinsicContentSize
|
var size = super.intrinsicContentSize
|
||||||
|
|||||||
@@ -8,10 +8,11 @@
|
|||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class CollapsingTextView: UITextView
|
final class CollapsingTextView: UITextView
|
||||||
{
|
{
|
||||||
var isCollapsed = true {
|
var isCollapsed = true {
|
||||||
didSet {
|
didSet {
|
||||||
|
guard self.isCollapsed != oldValue else { return }
|
||||||
self.setNeedsLayout()
|
self.setNeedsLayout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,19 +23,59 @@ class CollapsingTextView: UITextView
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var lineSpacing: CGFloat = 2 {
|
var lineSpacing: Double = 2 {
|
||||||
didSet {
|
didSet {
|
||||||
self.setNeedsLayout()
|
|
||||||
|
if #available(iOS 16, *)
|
||||||
|
{
|
||||||
|
self.updateText()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.setNeedsLayout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override var text: String! {
|
||||||
|
didSet {
|
||||||
|
|
||||||
|
guard #available(iOS 16, *) else { return }
|
||||||
|
self.updateText()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let moreButton = UIButton(type: .system)
|
let moreButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
override init(frame: CGRect, textContainer: NSTextContainer?)
|
||||||
|
{
|
||||||
|
super.init(frame: frame, textContainer: textContainer)
|
||||||
|
|
||||||
|
self.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder)
|
||||||
|
{
|
||||||
|
super.init(coder: coder)
|
||||||
|
}
|
||||||
|
|
||||||
override func awakeFromNib()
|
override func awakeFromNib()
|
||||||
{
|
{
|
||||||
super.awakeFromNib()
|
super.awakeFromNib()
|
||||||
|
|
||||||
self.layoutManager.delegate = self
|
self.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initialize()
|
||||||
|
{
|
||||||
|
if #available(iOS 16, *)
|
||||||
|
{
|
||||||
|
self.updateText()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.layoutManager.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
self.textContainerInset = .zero
|
self.textContainerInset = .zero
|
||||||
self.textContainer.lineFragmentPadding = 0
|
self.textContainer.lineFragmentPadding = 0
|
||||||
@@ -69,11 +110,13 @@ class CollapsingTextView: UITextView
|
|||||||
|
|
||||||
if self.isCollapsed
|
if self.isCollapsed
|
||||||
{
|
{
|
||||||
self.textContainer.maximumNumberOfLines = self.maximumNumberOfLines
|
let boundingSize = self.attributedText.boundingRect(with: CGSize(width: self.textContainer.size.width, height: .infinity), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
|
||||||
|
let maximumCollapsedHeight = font.lineHeight * Double(self.maximumNumberOfLines) + self.lineSpacing * Double(self.maximumNumberOfLines - 1)
|
||||||
|
|
||||||
let maximumCollapsedHeight = font.lineHeight * CGFloat(self.maximumNumberOfLines)
|
if boundingSize.height.rounded() > maximumCollapsedHeight.rounded()
|
||||||
if self.intrinsicContentSize.height > maximumCollapsedHeight
|
|
||||||
{
|
{
|
||||||
|
self.textContainer.maximumNumberOfLines = self.maximumNumberOfLines
|
||||||
|
|
||||||
var exclusionFrame = moreButtonFrame
|
var exclusionFrame = moreButtonFrame
|
||||||
exclusionFrame.origin.y += self.moreButton.bounds.midY
|
exclusionFrame.origin.y += self.moreButton.bounds.midY
|
||||||
exclusionFrame.size.width = self.bounds.width // Extra wide to make sure it wraps to next line.
|
exclusionFrame.size.width = self.bounds.width // Extra wide to make sure it wraps to next line.
|
||||||
@@ -83,6 +126,7 @@ class CollapsingTextView: UITextView
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
self.textContainer.maximumNumberOfLines = 0 // Fixes last line having slightly smaller line spacing.
|
||||||
self.textContainer.exclusionPaths = []
|
self.textContainer.exclusionPaths = []
|
||||||
|
|
||||||
self.moreButton.isHidden = true
|
self.moreButton.isHidden = true
|
||||||
@@ -106,6 +150,25 @@ private extension CollapsingTextView
|
|||||||
{
|
{
|
||||||
self.isCollapsed.toggle()
|
self.isCollapsed.toggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@available(iOS 16, *)
|
||||||
|
func updateText()
|
||||||
|
{
|
||||||
|
do
|
||||||
|
{
|
||||||
|
let style = NSMutableParagraphStyle()
|
||||||
|
style.lineSpacing = self.lineSpacing
|
||||||
|
|
||||||
|
var attributedText = try AttributedString(self.attributedText, including: \.uiKit)
|
||||||
|
attributedText[AttributeScopes.UIKitAttributes.ParagraphStyleAttribute.self] = style
|
||||||
|
|
||||||
|
self.attributedText = NSAttributedString(attributedText)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
print("[ALTLog] Failed to update CollapsingTextView line spacing:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension CollapsingTextView: NSLayoutManagerDelegate
|
extension CollapsingTextView: NSLayoutManagerDelegate
|
||||||
|
|||||||
20
AltStore/Components/ForwardingNavigationController.swift
Normal file
20
AltStore/Components/ForwardingNavigationController.swift
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//
|
||||||
|
// ForwardingNavigationController.swift
|
||||||
|
// AltStore
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 10/24/19.
|
||||||
|
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class ForwardingNavigationController: UINavigationController
|
||||||
|
{
|
||||||
|
override var childForStatusBarStyle: UIViewController? {
|
||||||
|
return self.topViewController
|
||||||
|
}
|
||||||
|
|
||||||
|
override var childForStatusBarHidden: UIViewController? {
|
||||||
|
return self.topViewController
|
||||||
|
}
|
||||||
|
}
|
||||||
649
AltStore/Components/HeaderContentViewController.swift
Normal file
649
AltStore/Components/HeaderContentViewController.swift
Normal file
@@ -0,0 +1,649 @@
|
|||||||
|
//
|
||||||
|
// HeaderContentViewController.swift
|
||||||
|
// AltStore
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 3/10/23.
|
||||||
|
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
import AltStoreCore
|
||||||
|
import Roxas
|
||||||
|
|
||||||
|
import Nuke
|
||||||
|
|
||||||
|
protocol ScrollableContentViewController: UIViewController
|
||||||
|
{
|
||||||
|
var scrollView: UIScrollView { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
class HeaderContentViewController<Header: UIView, Content: ScrollableContentViewController> : UIViewController,
|
||||||
|
UIAdaptivePresentationControllerDelegate,
|
||||||
|
UIScrollViewDelegate,
|
||||||
|
UIGestureRecognizerDelegate
|
||||||
|
{
|
||||||
|
var tintColor: UIColor? {
|
||||||
|
didSet {
|
||||||
|
guard self.isViewLoaded else { return }
|
||||||
|
|
||||||
|
self.view.tintColor = self.tintColor?.adjustedForDisplay
|
||||||
|
self.update()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var headerView: Header!
|
||||||
|
private(set) var contentViewController: Content!
|
||||||
|
|
||||||
|
private(set) var backButton: VibrantButton!
|
||||||
|
private(set) var backgroundImageView: UIImageView!
|
||||||
|
|
||||||
|
private(set) var navigationBarNameLabel: UILabel!
|
||||||
|
private(set) var navigationBarIconView: UIImageView!
|
||||||
|
private(set) var navigationBarTitleView: UIStackView!
|
||||||
|
private(set) var navigationBarButton: PillButton!
|
||||||
|
|
||||||
|
private var scrollView: UIScrollView!
|
||||||
|
private var headerScrollView: UIScrollView!
|
||||||
|
private var headerContainerView: UIView!
|
||||||
|
private var backgroundBlurView: UIVisualEffectView!
|
||||||
|
private var contentViewControllerShadowView: UIView!
|
||||||
|
|
||||||
|
private var ignoreBackGestureRecognizer: UIPanGestureRecognizer!
|
||||||
|
|
||||||
|
private var blurAnimator: UIViewPropertyAnimator?
|
||||||
|
private var navigationBarAnimator: UIViewPropertyAnimator?
|
||||||
|
private var contentSizeObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
|
private var _shouldResetLayout = false
|
||||||
|
private var _backgroundBlurEffect: UIBlurEffect?
|
||||||
|
private var _backgroundBlurTintColor: UIColor?
|
||||||
|
|
||||||
|
private var isViewingHeader: Bool {
|
||||||
|
let isViewingHeader = (self.headerScrollView.contentOffset.x != self.headerScrollView.contentInset.left)
|
||||||
|
return isViewingHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||||
|
if #available(iOS 17, *)
|
||||||
|
{
|
||||||
|
// On iOS 17+, .default will update the status bar automatically.
|
||||||
|
return .default
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return _preferredStatusBarStyle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private var _preferredStatusBarStyle: UIStatusBarStyle = .default
|
||||||
|
|
||||||
|
init()
|
||||||
|
{
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit
|
||||||
|
{
|
||||||
|
self.blurAnimator?.stopAnimation(true)
|
||||||
|
self.navigationBarAnimator?.stopAnimation(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder)
|
||||||
|
{
|
||||||
|
super.init(coder: coder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeContentViewController() -> Content
|
||||||
|
{
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeHeaderView() -> Header
|
||||||
|
{
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad()
|
||||||
|
{
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
self.view.backgroundColor = .white
|
||||||
|
self.view.clipsToBounds = true
|
||||||
|
|
||||||
|
self.navigationItem.largeTitleDisplayMode = .never
|
||||||
|
self.navigationController?.presentationController?.delegate = self
|
||||||
|
|
||||||
|
|
||||||
|
// Background
|
||||||
|
self.backgroundImageView = UIImageView(frame: .zero)
|
||||||
|
self.backgroundImageView.contentMode = .scaleAspectFill
|
||||||
|
self.view.addSubview(self.backgroundImageView)
|
||||||
|
|
||||||
|
let blurEffect = UIBlurEffect(style: .regular)
|
||||||
|
self.backgroundBlurView = UIVisualEffectView(effect: blurEffect)
|
||||||
|
self.view.addSubview(self.backgroundBlurView, pinningEdgesWith: .zero)
|
||||||
|
|
||||||
|
|
||||||
|
// Header View
|
||||||
|
self.headerContainerView = UIView(frame: .zero)
|
||||||
|
self.view.addSubview(self.headerContainerView, pinningEdgesWith: .zero)
|
||||||
|
|
||||||
|
self.ignoreBackGestureRecognizer = UIPanGestureRecognizer(target: self, action: nil)
|
||||||
|
self.ignoreBackGestureRecognizer.delegate = self
|
||||||
|
self.headerContainerView.addGestureRecognizer(self.ignoreBackGestureRecognizer)
|
||||||
|
self.navigationController?.interactivePopGestureRecognizer?.require(toFail: self.ignoreBackGestureRecognizer) // So we can disable back gesture when viewing header.
|
||||||
|
|
||||||
|
self.headerScrollView = UIScrollView(frame: .zero)
|
||||||
|
self.headerScrollView.delegate = self
|
||||||
|
self.headerScrollView.isPagingEnabled = true
|
||||||
|
self.headerScrollView.clipsToBounds = false
|
||||||
|
self.headerScrollView.indicatorStyle = .white
|
||||||
|
self.headerScrollView.showsVerticalScrollIndicator = false
|
||||||
|
self.headerContainerView.addSubview(self.headerScrollView)
|
||||||
|
self.headerContainerView.addGestureRecognizer(self.headerScrollView.panGestureRecognizer) // Allow panning outside headerScrollView bounds.
|
||||||
|
|
||||||
|
self.headerView = self.makeHeaderView()
|
||||||
|
self.headerScrollView.addSubview(self.headerView)
|
||||||
|
|
||||||
|
let imageConfiguration = UIImage.SymbolConfiguration(weight: .semibold)
|
||||||
|
let image = UIImage(systemName: "chevron.backward", withConfiguration: imageConfiguration)
|
||||||
|
|
||||||
|
self.backButton = VibrantButton(type: .system)
|
||||||
|
self.backButton.image = image
|
||||||
|
self.backButton.tintColor = self.tintColor
|
||||||
|
self.backButton.sizeToFit()
|
||||||
|
self.backButton.addTarget(self.navigationController, action: #selector(UINavigationController.popViewController(animated:)), for: .primaryActionTriggered)
|
||||||
|
self.view.addSubview(self.backButton)
|
||||||
|
|
||||||
|
|
||||||
|
// Content View Controller
|
||||||
|
self.contentViewController = self.makeContentViewController()
|
||||||
|
self.contentViewController.view.frame = self.view.bounds
|
||||||
|
self.contentViewController.view.layer.cornerRadius = 38
|
||||||
|
self.contentViewController.view.layer.masksToBounds = true
|
||||||
|
|
||||||
|
self.addChild(self.contentViewController)
|
||||||
|
self.view.addSubview(self.contentViewController.view)
|
||||||
|
self.contentViewController.didMove(toParent: self)
|
||||||
|
|
||||||
|
self.contentViewControllerShadowView = UIView()
|
||||||
|
self.contentViewControllerShadowView.backgroundColor = .white
|
||||||
|
self.contentViewControllerShadowView.layer.cornerRadius = 38
|
||||||
|
self.contentViewControllerShadowView.layer.shadowColor = UIColor.black.cgColor
|
||||||
|
self.contentViewControllerShadowView.layer.shadowOffset = CGSize(width: 0, height: -1)
|
||||||
|
self.contentViewControllerShadowView.layer.shadowRadius = 10
|
||||||
|
self.contentViewControllerShadowView.layer.shadowOpacity = 0.3
|
||||||
|
self.view.insertSubview(self.contentViewControllerShadowView, belowSubview: self.contentViewController.view)
|
||||||
|
|
||||||
|
// Add scrollView to front so the scroll indicators are visible, but disable user interaction.
|
||||||
|
self.scrollView = UIScrollView(frame: CGRect(origin: .zero, size: self.view.bounds.size))
|
||||||
|
self.scrollView.delegate = self
|
||||||
|
self.scrollView.isUserInteractionEnabled = false
|
||||||
|
self.scrollView.contentInsetAdjustmentBehavior = .never
|
||||||
|
self.view.addSubview(self.scrollView, pinningEdgesWith: .zero)
|
||||||
|
self.view.addGestureRecognizer(self.scrollView.panGestureRecognizer)
|
||||||
|
|
||||||
|
self.contentViewController.scrollView.panGestureRecognizer.require(toFail: self.scrollView.panGestureRecognizer)
|
||||||
|
self.contentViewController.scrollView.showsVerticalScrollIndicator = false
|
||||||
|
self.contentViewController.scrollView.contentInsetAdjustmentBehavior = .never
|
||||||
|
|
||||||
|
|
||||||
|
// Navigation Bar Title View
|
||||||
|
self.navigationBarNameLabel = UILabel(frame: .zero)
|
||||||
|
self.navigationBarNameLabel.font = UIFont.boldSystemFont(ofSize: 17) // We want semibold, which this (apparently) returns.
|
||||||
|
self.navigationBarNameLabel.text = self.title
|
||||||
|
self.navigationBarNameLabel.sizeToFit()
|
||||||
|
|
||||||
|
self.navigationBarIconView = UIImageView(frame: .zero)
|
||||||
|
self.navigationBarIconView.clipsToBounds = true
|
||||||
|
|
||||||
|
self.navigationBarTitleView = UIStackView(arrangedSubviews: [self.navigationBarIconView, self.navigationBarNameLabel])
|
||||||
|
self.navigationBarTitleView.axis = .horizontal
|
||||||
|
self.navigationBarTitleView.spacing = 8
|
||||||
|
|
||||||
|
self.navigationBarButton = PillButton(type: .system)
|
||||||
|
self.navigationBarButton.setContentCompressionResistancePriority(UILayoutPriority(rawValue: 9000), for: .horizontal) // Prioritize over title length.
|
||||||
|
|
||||||
|
// Embed navigationBarButton in container view with Auto Layout to ensure it can automatically update its size.
|
||||||
|
let buttonContainerView = UIView()
|
||||||
|
buttonContainerView.addSubview(self.navigationBarButton, pinningEdgesWith: .zero)
|
||||||
|
self.navigationItem.rightBarButtonItem = UIBarButtonItem(customView: buttonContainerView)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
self.navigationBarIconView.widthAnchor.constraint(equalToConstant: 35),
|
||||||
|
self.navigationBarIconView.heightAnchor.constraint(equalTo: self.navigationBarIconView.widthAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
let size = self.navigationBarTitleView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||||
|
self.navigationBarTitleView.bounds.size = size
|
||||||
|
self.navigationItem.titleView = self.navigationBarTitleView
|
||||||
|
|
||||||
|
self._backgroundBlurEffect = self.backgroundBlurView.effect as? UIBlurEffect
|
||||||
|
self._backgroundBlurTintColor = self.backgroundBlurView.contentView.backgroundColor
|
||||||
|
|
||||||
|
self.contentSizeObservation = self.contentViewController.scrollView.observe(\.contentSize, options: [.new, .old]) { [weak self] (scrollView, change) in
|
||||||
|
guard let size = change.newValue, let previousSize = change.oldValue, size != previousSize else { return }
|
||||||
|
self?.view.setNeedsLayout()
|
||||||
|
self?.view.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't call update() before subclasses have finished viewDidLoad().
|
||||||
|
// self.update()
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(HeaderContentViewController.willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(HeaderContentViewController.didBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||||
|
|
||||||
|
if #available(iOS 15, *)
|
||||||
|
{
|
||||||
|
// Fix navigation bar + tab bar appearance on iOS 15.
|
||||||
|
self.setContentScrollView(self.scrollView)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start with navigation bar hidden.
|
||||||
|
self.hideNavigationBar()
|
||||||
|
|
||||||
|
self.view.tintColor = self.tintColor?.adjustedForDisplay
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool)
|
||||||
|
{
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
|
self.prepareBlur()
|
||||||
|
|
||||||
|
// Update blur immediately.
|
||||||
|
self.view.setNeedsLayout()
|
||||||
|
self.view.layoutIfNeeded()
|
||||||
|
|
||||||
|
self.headerScrollView.flashScrollIndicators()
|
||||||
|
|
||||||
|
self.update()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewIsAppearing(_ animated: Bool)
|
||||||
|
{
|
||||||
|
super.viewIsAppearing(animated)
|
||||||
|
|
||||||
|
// Ensure header view has correct layout dimensions.
|
||||||
|
self.headerView.setNeedsLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool)
|
||||||
|
{
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
|
||||||
|
self._shouldResetLayout = true
|
||||||
|
self.view.setNeedsLayout()
|
||||||
|
self.view.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews()
|
||||||
|
{
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
|
||||||
|
if self._shouldResetLayout
|
||||||
|
{
|
||||||
|
// Various events can cause UI to mess up, so reset affected components now.
|
||||||
|
|
||||||
|
self.prepareBlur()
|
||||||
|
|
||||||
|
// Reset navigation bar animation, and create a new one later in this method if necessary.
|
||||||
|
self.resetNavigationBarAnimation()
|
||||||
|
|
||||||
|
self._shouldResetLayout = false
|
||||||
|
}
|
||||||
|
|
||||||
|
let statusBarHeight: Double
|
||||||
|
|
||||||
|
if let navigationController, navigationController.presentingViewController != nil, navigationController.modalPresentationStyle != .fullScreen
|
||||||
|
{
|
||||||
|
statusBarHeight = 20
|
||||||
|
}
|
||||||
|
else if let statusBarManager = (self.view.window ?? self.presentedViewController?.view.window)?.windowScene?.statusBarManager
|
||||||
|
{
|
||||||
|
statusBarHeight = statusBarManager.statusBarFrame.height
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
statusBarHeight = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let cornerRadius = self.contentViewControllerShadowView.layer.cornerRadius
|
||||||
|
|
||||||
|
let inset = 15 as CGFloat
|
||||||
|
let padding = 20 as CGFloat
|
||||||
|
|
||||||
|
let backButtonSize = self.backButton.sizeThatFits(CGSize(width: Double.infinity, height: .infinity))
|
||||||
|
let largestBackButtonDimension = max(backButtonSize.width, backButtonSize.height) // Enforce 1:1 aspect ratio.
|
||||||
|
var backButtonFrame = CGRect(x: inset, y: statusBarHeight, width: largestBackButtonDimension, height: largestBackButtonDimension)
|
||||||
|
|
||||||
|
var headerFrame = CGRect(x: inset, y: 0, width: self.view.bounds.width - inset * 2, height: self.headerView.bounds.height)
|
||||||
|
var contentFrame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: self.view.bounds.height)
|
||||||
|
var backgroundIconFrame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: self.view.bounds.width)
|
||||||
|
|
||||||
|
let backButtonPadding = 8.0
|
||||||
|
let minimumHeaderY = backButtonFrame.maxY + backButtonPadding
|
||||||
|
|
||||||
|
let minimumContentHeight = minimumHeaderY + headerFrame.height + padding // Minimum height for header + back button + spacing.
|
||||||
|
let maximumContentY = max(self.view.bounds.width * 0.667, minimumContentHeight) // Initial Y-value of content view.
|
||||||
|
|
||||||
|
contentFrame.origin.y = maximumContentY - self.scrollView.contentOffset.y
|
||||||
|
headerFrame.origin.y = contentFrame.origin.y - padding - headerFrame.height
|
||||||
|
|
||||||
|
// Stretch the app icon image to fill additional vertical space if necessary.
|
||||||
|
let height = max(contentFrame.origin.y + cornerRadius * 2, backgroundIconFrame.height)
|
||||||
|
backgroundIconFrame.size.height = height
|
||||||
|
|
||||||
|
// Update blur.
|
||||||
|
self.updateBlur()
|
||||||
|
|
||||||
|
// Animate navigation bar.
|
||||||
|
let showNavigationBarThreshold = (maximumContentY - minimumContentHeight) + backButtonFrame.origin.y
|
||||||
|
if self.scrollView.contentOffset.y > showNavigationBarThreshold
|
||||||
|
{
|
||||||
|
if self.navigationBarAnimator == nil
|
||||||
|
{
|
||||||
|
self.prepareNavigationBarAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
let difference = self.scrollView.contentOffset.y - showNavigationBarThreshold
|
||||||
|
|
||||||
|
let range: Double
|
||||||
|
if self.presentingViewController == nil && self.parent?.presentingViewController == nil
|
||||||
|
{
|
||||||
|
// Not presented modally, so rely on safe area + navigation bar height.
|
||||||
|
range = (headerFrame.height + padding) - (self.navigationController?.navigationBar.bounds.height ?? self.view.safeAreaInsets.top)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Presented modally, so rely on maximumContentY.
|
||||||
|
range = maximumContentY - (maximumContentY - padding - headerFrame.height) - inset
|
||||||
|
}
|
||||||
|
|
||||||
|
let fractionComplete = min(difference, range) / range
|
||||||
|
self.navigationBarAnimator?.fractionComplete = fractionComplete
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.navigationBarAnimator?.fractionComplete = 0.0
|
||||||
|
self.resetNavigationBarAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
let beginMovingBackButtonThreshold = (maximumContentY - minimumContentHeight)
|
||||||
|
if self.scrollView.contentOffset.y > beginMovingBackButtonThreshold
|
||||||
|
{
|
||||||
|
let difference = self.scrollView.contentOffset.y - beginMovingBackButtonThreshold
|
||||||
|
backButtonFrame.origin.y -= difference
|
||||||
|
}
|
||||||
|
|
||||||
|
let pinContentToTopThreshold = maximumContentY
|
||||||
|
if self.scrollView.contentOffset.y > pinContentToTopThreshold
|
||||||
|
{
|
||||||
|
contentFrame.origin.y = 0
|
||||||
|
backgroundIconFrame.origin.y = 0
|
||||||
|
|
||||||
|
let difference = self.scrollView.contentOffset.y - pinContentToTopThreshold
|
||||||
|
self.contentViewController.scrollView.contentOffset.y = -self.contentViewController.scrollView.contentInset.top + difference
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Keep content table view's content offset at the top.
|
||||||
|
self.contentViewController.scrollView.contentOffset.y = -self.contentViewController.scrollView.contentInset.top
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep background app icon centered in gap between top of content and top of screen.
|
||||||
|
backgroundIconFrame.origin.y = (contentFrame.origin.y / 2) - backgroundIconFrame.height / 2
|
||||||
|
|
||||||
|
// Set frames.
|
||||||
|
self.contentViewController.view.frame = contentFrame
|
||||||
|
self.contentViewControllerShadowView.frame = contentFrame
|
||||||
|
self.backgroundImageView.frame = backgroundIconFrame
|
||||||
|
|
||||||
|
self.backButton.frame = backButtonFrame
|
||||||
|
self.backButton.layer.cornerRadius = backButtonFrame.height / 2
|
||||||
|
|
||||||
|
// Adjust header scroll view content size for paging
|
||||||
|
self.headerView.frame = CGRect(origin: .zero, size: headerFrame.size)
|
||||||
|
self.headerScrollView.frame = headerFrame
|
||||||
|
self.headerScrollView.contentSize = CGSize(width: headerFrame.width * 2, height: headerFrame.height)
|
||||||
|
|
||||||
|
self.scrollView.verticalScrollIndicatorInsets.top = statusBarHeight
|
||||||
|
self.headerScrollView.horizontalScrollIndicatorInsets.bottom = -12
|
||||||
|
|
||||||
|
// Adjust content offset + size.
|
||||||
|
let contentOffset = self.scrollView.contentOffset
|
||||||
|
|
||||||
|
var contentSize = self.contentViewController.scrollView.contentSize
|
||||||
|
contentSize.height += self.contentViewController.scrollView.contentInset.top + self.contentViewController.scrollView.contentInset.bottom
|
||||||
|
contentSize.height += maximumContentY
|
||||||
|
contentSize.height = max(contentSize.height, self.view.bounds.height + maximumContentY - (self.navigationController?.navigationBar.bounds.height ?? 0))
|
||||||
|
self.scrollView.contentSize = contentSize
|
||||||
|
|
||||||
|
self.scrollView.contentOffset = contentOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
func update()
|
||||||
|
{
|
||||||
|
// Overridden by subclasses.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cannot add @objc functions in extensions of generic types, so include them in main definition instead.
|
||||||
|
|
||||||
|
//MARK: Notifications
|
||||||
|
|
||||||
|
@objc private func willEnterForeground(_ notification: Notification)
|
||||||
|
{
|
||||||
|
guard let navigationController = self.navigationController, navigationController.topViewController == self else { return }
|
||||||
|
|
||||||
|
self._shouldResetLayout = true
|
||||||
|
self.view.setNeedsLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func didBecomeActive(_ notification: Notification)
|
||||||
|
{
|
||||||
|
guard let navigationController = self.navigationController, navigationController.topViewController == self else { return }
|
||||||
|
|
||||||
|
// Fixes incorrect blur after app becomes inactive -> active again.
|
||||||
|
self._shouldResetLayout = true
|
||||||
|
self.view.setNeedsLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
//MARK: UIAdaptivePresentationControllerDelegate
|
||||||
|
|
||||||
|
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool
|
||||||
|
{
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
//MARK: UIScrollViewDelegate
|
||||||
|
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView)
|
||||||
|
{
|
||||||
|
switch scrollView
|
||||||
|
{
|
||||||
|
case self.scrollView: self.view.setNeedsLayout()
|
||||||
|
case self.headerScrollView:
|
||||||
|
// Do NOT call setNeedsLayout(), or else it will mess with scrolling.
|
||||||
|
self.headerScrollView.showsHorizontalScrollIndicator = false
|
||||||
|
self.updateBlur()
|
||||||
|
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//MARK: UIGestureRecognizerDelegate
|
||||||
|
|
||||||
|
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool
|
||||||
|
{
|
||||||
|
// Ignore interactive back gesture when viewing header, which means returning `true` to enable ignoreBackGestureRecognizer.
|
||||||
|
let disableBackGesture = self.isViewingHeader
|
||||||
|
return disableBackGesture
|
||||||
|
}
|
||||||
|
|
||||||
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool
|
||||||
|
{
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension HeaderContentViewController
|
||||||
|
{
|
||||||
|
func showNavigationBar()
|
||||||
|
{
|
||||||
|
self.navigationBarIconView.alpha = 1.0
|
||||||
|
self.navigationBarNameLabel.alpha = 1.0
|
||||||
|
self.navigationBarButton.alpha = 1.0
|
||||||
|
|
||||||
|
self.updateNavigationBarAppearance(isHidden: false)
|
||||||
|
|
||||||
|
if self.traitCollection.userInterfaceStyle == .dark
|
||||||
|
{
|
||||||
|
self._preferredStatusBarStyle = .lightContent
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self._preferredStatusBarStyle = .default
|
||||||
|
}
|
||||||
|
|
||||||
|
if #unavailable(iOS 17)
|
||||||
|
{
|
||||||
|
self.navigationController?.setNeedsStatusBarAppearanceUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hideNavigationBar()
|
||||||
|
{
|
||||||
|
self.navigationBarIconView.alpha = 0.0
|
||||||
|
self.navigationBarNameLabel.alpha = 0.0
|
||||||
|
self.navigationBarButton.alpha = 0.0
|
||||||
|
|
||||||
|
self.updateNavigationBarAppearance(isHidden: true)
|
||||||
|
|
||||||
|
self._preferredStatusBarStyle = .lightContent
|
||||||
|
|
||||||
|
if #unavailable(iOS 17)
|
||||||
|
{
|
||||||
|
self.navigationController?.setNeedsStatusBarAppearanceUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNavigationBarAppearance(isHidden: Bool)
|
||||||
|
{
|
||||||
|
let barAppearance = self.navigationItem.standardAppearance as? NavigationBarAppearance ?? NavigationBarAppearance()
|
||||||
|
|
||||||
|
if isHidden
|
||||||
|
{
|
||||||
|
barAppearance.configureWithTransparentBackground()
|
||||||
|
barAppearance.ignoresUserInteraction = true
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
barAppearance.configureWithDefaultBackground()
|
||||||
|
barAppearance.ignoresUserInteraction = false
|
||||||
|
}
|
||||||
|
|
||||||
|
barAppearance.titleTextAttributes = [.foregroundColor: UIColor.clear]
|
||||||
|
|
||||||
|
let dynamicColor = UIColor { traitCollection in
|
||||||
|
var tintColor = self.tintColor ?? .altPrimary
|
||||||
|
|
||||||
|
if traitCollection.userInterfaceStyle == .dark && tintColor.isTooDark
|
||||||
|
{
|
||||||
|
tintColor = .white
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
tintColor = tintColor.adjustedForDisplay
|
||||||
|
}
|
||||||
|
|
||||||
|
return tintColor
|
||||||
|
}
|
||||||
|
|
||||||
|
let tintColor = isHidden ? UIColor.clear : dynamicColor
|
||||||
|
barAppearance.configureWithTintColor(tintColor)
|
||||||
|
|
||||||
|
self.navigationItem.standardAppearance = barAppearance
|
||||||
|
self.navigationItem.scrollEdgeAppearance = barAppearance
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareBlur()
|
||||||
|
{
|
||||||
|
if let animator = self.blurAnimator
|
||||||
|
{
|
||||||
|
animator.stopAnimation(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.backgroundBlurView.effect = self._backgroundBlurEffect
|
||||||
|
self.backgroundBlurView.contentView.backgroundColor = self._backgroundBlurTintColor
|
||||||
|
|
||||||
|
self.blurAnimator = UIViewPropertyAnimator(duration: 1.0, curve: .linear) { [weak self] in
|
||||||
|
self?.backgroundBlurView.effect = nil
|
||||||
|
self?.backgroundBlurView.contentView.backgroundColor = .clear
|
||||||
|
}
|
||||||
|
|
||||||
|
self.blurAnimator?.startAnimation()
|
||||||
|
self.blurAnimator?.pauseAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateBlur()
|
||||||
|
{
|
||||||
|
// A full blur is too much for header, so we reduce the visible blur by 0.3, resulting in 70% blur.
|
||||||
|
let minimumBlurFraction = 0.3 as CGFloat
|
||||||
|
|
||||||
|
if self.isViewingHeader
|
||||||
|
{
|
||||||
|
let maximumX = self.headerScrollView.bounds.width
|
||||||
|
let fraction = self.headerScrollView.contentOffset.x / maximumX
|
||||||
|
|
||||||
|
let fractionComplete = (fraction * (1.0 - minimumBlurFraction)) + minimumBlurFraction
|
||||||
|
self.blurAnimator?.fractionComplete = fractionComplete
|
||||||
|
}
|
||||||
|
else if self.scrollView.contentOffset.y < 0
|
||||||
|
{
|
||||||
|
// Determine how much to lessen blur by.
|
||||||
|
|
||||||
|
let range = 75 as CGFloat
|
||||||
|
let difference = -self.scrollView.contentOffset.y
|
||||||
|
|
||||||
|
let fraction = min(difference, range) / range
|
||||||
|
|
||||||
|
let fractionComplete = (fraction * (1.0 - minimumBlurFraction)) + minimumBlurFraction
|
||||||
|
self.blurAnimator?.fractionComplete = fractionComplete
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Set blur to default.
|
||||||
|
self.blurAnimator?.fractionComplete = minimumBlurFraction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareNavigationBarAnimation()
|
||||||
|
{
|
||||||
|
self.resetNavigationBarAnimation()
|
||||||
|
|
||||||
|
self.navigationBarAnimator = UIViewPropertyAnimator(duration: 1.0, curve: .linear) { [weak self] in
|
||||||
|
self?.showNavigationBar()
|
||||||
|
|
||||||
|
// Must call layoutIfNeeded() to animate appearance change.
|
||||||
|
self?.navigationController?.navigationBar.layoutIfNeeded()
|
||||||
|
|
||||||
|
self?.contentViewController.view.layer.cornerRadius = 0
|
||||||
|
}
|
||||||
|
self.navigationBarAnimator?.startAnimation()
|
||||||
|
self.navigationBarAnimator?.pauseAnimation()
|
||||||
|
|
||||||
|
self.update()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetNavigationBarAnimation()
|
||||||
|
{
|
||||||
|
guard self.navigationBarAnimator != nil else { return }
|
||||||
|
|
||||||
|
self.navigationBarAnimator?.stopAnimation(true)
|
||||||
|
self.navigationBarAnimator = nil
|
||||||
|
|
||||||
|
self.hideNavigationBar()
|
||||||
|
|
||||||
|
self.contentViewController.view.layer.cornerRadius = self.contentViewControllerShadowView.layer.cornerRadius
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
//
|
|
||||||
// Keychain.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 6/4/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import KeychainAccess
|
|
||||||
|
|
||||||
import AltSign
|
|
||||||
|
|
||||||
class Keychain
|
|
||||||
{
|
|
||||||
static let shared = Keychain()
|
|
||||||
|
|
||||||
private let keychain = KeychainAccess.Keychain(service: "com.rileytestut.AltStore").accessibility(.afterFirstUnlock).synchronizable(true)
|
|
||||||
|
|
||||||
private init()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset()
|
|
||||||
{
|
|
||||||
self.appleIDEmailAddress = nil
|
|
||||||
self.appleIDPassword = nil
|
|
||||||
self.signingCertificatePrivateKey = nil
|
|
||||||
self.signingCertificateSerialNumber = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Keychain
|
|
||||||
{
|
|
||||||
var appleIDEmailAddress: String? {
|
|
||||||
get {
|
|
||||||
let emailAddress = try? self.keychain.get("appleIDEmailAddress")
|
|
||||||
return emailAddress
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
self.keychain["appleIDEmailAddress"] = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var appleIDPassword: String? {
|
|
||||||
get {
|
|
||||||
let password = try? self.keychain.get("appleIDPassword")
|
|
||||||
return password
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
self.keychain["appleIDPassword"] = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var signingCertificatePrivateKey: Data? {
|
|
||||||
get {
|
|
||||||
let privateKey = try? self.keychain.getData("signingCertificatePrivateKey")
|
|
||||||
return privateKey
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
self.keychain[data: "signingCertificatePrivateKey"] = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var signingCertificateSerialNumber: String? {
|
|
||||||
get {
|
|
||||||
let serialNumber = try? self.keychain.get("signingCertificateSerialNumber")
|
|
||||||
return serialNumber
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
self.keychain["signingCertificateSerialNumber"] = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var patreonAccessToken: String? {
|
|
||||||
get {
|
|
||||||
let accessToken = try? self.keychain.get("patreonAccessToken")
|
|
||||||
return accessToken
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
self.keychain["patreonAccessToken"] = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var patreonRefreshToken: String? {
|
|
||||||
get {
|
|
||||||
let refreshToken = try? self.keychain.get("patreonRefreshToken")
|
|
||||||
return refreshToken
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
self.keychain["patreonRefreshToken"] = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,11 +10,23 @@ import UIKit
|
|||||||
|
|
||||||
import Roxas
|
import Roxas
|
||||||
|
|
||||||
class NavigationBar: UINavigationBar
|
class NavigationBarAppearance: UINavigationBarAppearance
|
||||||
{
|
{
|
||||||
@IBInspectable var automaticallyAdjustsItemPositions: Bool = true
|
// We sometimes need to ignore user interaction so
|
||||||
|
// we can tap items underneath the navigation bar.
|
||||||
|
var ignoresUserInteraction: Bool = false
|
||||||
|
|
||||||
private let backgroundColorView = UIView()
|
override func copy(with zone: NSZone? = nil) -> Any
|
||||||
|
{
|
||||||
|
let copy = super.copy(with: zone) as! NavigationBarAppearance
|
||||||
|
copy.ignoresUserInteraction = self.ignoresUserInteraction
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NavigationBar: UINavigationBar
|
||||||
|
{
|
||||||
|
@IBInspectable var automaticallyAdjustsItemPositions: Bool = true
|
||||||
|
|
||||||
override init(frame: CGRect)
|
override init(frame: CGRect)
|
||||||
{
|
{
|
||||||
@@ -32,31 +44,39 @@ class NavigationBar: UINavigationBar
|
|||||||
|
|
||||||
private func initialize()
|
private func initialize()
|
||||||
{
|
{
|
||||||
self.shadowImage = UIImage()
|
let standardAppearance = UINavigationBarAppearance()
|
||||||
|
standardAppearance.configureWithDefaultBackground()
|
||||||
|
standardAppearance.shadowColor = nil
|
||||||
|
|
||||||
|
let edgeAppearance = UINavigationBarAppearance()
|
||||||
|
edgeAppearance.configureWithOpaqueBackground()
|
||||||
|
edgeAppearance.backgroundColor = self.barTintColor
|
||||||
|
edgeAppearance.shadowColor = nil
|
||||||
|
|
||||||
if let tintColor = self.barTintColor
|
if let tintColor = self.barTintColor
|
||||||
{
|
{
|
||||||
self.backgroundColorView.backgroundColor = tintColor
|
let textAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white]
|
||||||
|
|
||||||
// Top = -50 to cover status bar area above navigation bar on any device.
|
standardAppearance.backgroundColor = tintColor
|
||||||
// Bottom = -1 to prevent a flickering gray line from appearing.
|
standardAppearance.titleTextAttributes = textAttributes
|
||||||
self.addSubview(self.backgroundColorView, pinningEdgesWith: UIEdgeInsets(top: -50, left: 0, bottom: -1, right: 0))
|
standardAppearance.largeTitleTextAttributes = textAttributes
|
||||||
|
|
||||||
|
edgeAppearance.titleTextAttributes = textAttributes
|
||||||
|
edgeAppearance.largeTitleTextAttributes = textAttributes
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
self.barTintColor = .white
|
standardAppearance.backgroundColor = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.scrollEdgeAppearance = edgeAppearance
|
||||||
|
self.standardAppearance = standardAppearance
|
||||||
}
|
}
|
||||||
|
|
||||||
override func layoutSubviews()
|
override func layoutSubviews()
|
||||||
{
|
{
|
||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
|
|
||||||
if self.backgroundColorView.superview != nil
|
|
||||||
{
|
|
||||||
self.insertSubview(self.backgroundColorView, at: 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.automaticallyAdjustsItemPositions
|
if self.automaticallyAdjustsItemPositions
|
||||||
{
|
{
|
||||||
// We can't easily shift just the back button up, so we shift the entire content view slightly.
|
// We can't easily shift just the back button up, so we shift the entire content view slightly.
|
||||||
@@ -67,4 +87,15 @@ class NavigationBar: UINavigationBar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?
|
||||||
|
{
|
||||||
|
if let appearance = self.topItem?.standardAppearance as? NavigationBarAppearance, appearance.ignoresUserInteraction
|
||||||
|
{
|
||||||
|
// Ignore touches.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.hitTest(point, with: event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,29 +8,45 @@
|
|||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
extension PillButton
|
||||||
|
{
|
||||||
|
static let minimumSize = CGSize(width: 77, height: 31)
|
||||||
|
static let contentInsets = NSDirectionalEdgeInsets(top: 7, leading: 13, bottom: 7, trailing: 13)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PillButton
|
||||||
|
{
|
||||||
|
enum Style
|
||||||
|
{
|
||||||
|
case pill
|
||||||
|
case custom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class PillButton: UIButton
|
class PillButton: UIButton
|
||||||
{
|
{
|
||||||
|
override var accessibilityValue: String? {
|
||||||
|
get {
|
||||||
|
guard self.progress != nil else { return super.accessibilityValue }
|
||||||
|
return self.progressView.accessibilityValue
|
||||||
|
}
|
||||||
|
set { super.accessibilityValue = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
var progress: Progress? {
|
var progress: Progress? {
|
||||||
didSet {
|
didSet {
|
||||||
self.progressView.progress = Float(self.progress?.fractionCompleted ?? 0)
|
self.progressView.progress = Float(self.progress?.fractionCompleted ?? 0)
|
||||||
self.progressView.observedProgress = self.progress
|
self.progressView.observedProgress = self.progress
|
||||||
|
|
||||||
let isUserInteractionEnabled = self.isUserInteractionEnabled
|
let isUserInteractionEnabled = self.isUserInteractionEnabled
|
||||||
self.isIndicatingActivity = (self.progress != nil)
|
self.isIndicatingActivity = (self.progress != nil)
|
||||||
self.isUserInteractionEnabled = isUserInteractionEnabled
|
self.isUserInteractionEnabled = isUserInteractionEnabled
|
||||||
|
|
||||||
|
self.update()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var progressTintColor: UIColor? {
|
var progressTintColor: UIColor? {
|
||||||
get {
|
|
||||||
return self.progressView.progressTintColor
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
self.progressView.progressTintColor = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isInverted: Bool = false {
|
|
||||||
didSet {
|
didSet {
|
||||||
self.update()
|
self.update()
|
||||||
}
|
}
|
||||||
@@ -48,6 +64,20 @@ class PillButton: UIButton
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var style: Style = .pill {
|
||||||
|
didSet {
|
||||||
|
guard self.style != oldValue else { return }
|
||||||
|
|
||||||
|
if self.style == .custom
|
||||||
|
{
|
||||||
|
// Reset insets for custom style.
|
||||||
|
self.contentEdgeInsets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.update()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private let progressView = UIProgressView(progressViewStyle: .default)
|
private let progressView = UIProgressView(progressViewStyle: .default)
|
||||||
|
|
||||||
private lazy var displayLink: CADisplayLink = {
|
private lazy var displayLink: CADisplayLink = {
|
||||||
@@ -66,9 +96,7 @@ class PillButton: UIButton
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
override var intrinsicContentSize: CGSize {
|
override var intrinsicContentSize: CGSize {
|
||||||
var size = super.intrinsicContentSize
|
let size = self.sizeThatFits(CGSize(width: Double.infinity, height: .infinity))
|
||||||
size.width += 26
|
|
||||||
size.height += 3
|
|
||||||
return size
|
return size
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,13 +105,32 @@ class PillButton: UIButton
|
|||||||
self.displayLink.remove(from: .main, forMode: RunLoop.Mode.default)
|
self.displayLink.remove(from: .main, forMode: RunLoop.Mode.default)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override init(frame: CGRect)
|
||||||
|
{
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
self.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder)
|
||||||
|
{
|
||||||
|
super.init(coder: coder)
|
||||||
|
}
|
||||||
|
|
||||||
override func awakeFromNib()
|
override func awakeFromNib()
|
||||||
{
|
{
|
||||||
super.awakeFromNib()
|
super.awakeFromNib()
|
||||||
|
|
||||||
|
self.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initialize()
|
||||||
|
{
|
||||||
self.layer.masksToBounds = true
|
self.layer.masksToBounds = true
|
||||||
|
self.accessibilityTraits.formUnion([.updatesFrequently, .button])
|
||||||
|
|
||||||
self.activityIndicatorView.style = .white
|
self.activityIndicatorView.style = .medium
|
||||||
|
self.activityIndicatorView.color = .white
|
||||||
self.activityIndicatorView.isUserInteractionEnabled = false
|
self.activityIndicatorView.isUserInteractionEnabled = false
|
||||||
|
|
||||||
self.progressView.progress = 0
|
self.progressView.progress = 0
|
||||||
@@ -114,23 +161,50 @@ class PillButton: UIButton
|
|||||||
|
|
||||||
self.update()
|
self.update()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func sizeThatFits(_ size: CGSize) -> CGSize
|
||||||
|
{
|
||||||
|
var size = super.sizeThatFits(size)
|
||||||
|
|
||||||
|
switch self.style
|
||||||
|
{
|
||||||
|
case .pill:
|
||||||
|
// Enforce minimum size for pill style.
|
||||||
|
size.width = max(size.width, PillButton.minimumSize.width)
|
||||||
|
size.height = max(size.height, PillButton.minimumSize.height)
|
||||||
|
|
||||||
|
case .custom: break
|
||||||
|
}
|
||||||
|
|
||||||
|
return size
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension PillButton
|
private extension PillButton
|
||||||
{
|
{
|
||||||
func update()
|
func update()
|
||||||
{
|
{
|
||||||
if self.isInverted
|
if self.progress == nil
|
||||||
{
|
{
|
||||||
self.setTitleColor(.white, for: .normal)
|
self.setTitleColor(.white, for: .normal)
|
||||||
self.backgroundColor = self.tintColor
|
self.backgroundColor = self.tintColor
|
||||||
self.progressView.progressTintColor = self.tintColor.withAlphaComponent(0.15)
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
self.setTitleColor(self.tintColor, for: .normal)
|
self.setTitleColor(self.tintColor, for: .normal)
|
||||||
self.backgroundColor = self.tintColor.withAlphaComponent(0.15)
|
self.backgroundColor = self.tintColor.withAlphaComponent(0.15)
|
||||||
self.progressView.progressTintColor = self.tintColor
|
}
|
||||||
|
|
||||||
|
self.progressView.progressTintColor = self.progressTintColor ?? self.tintColor
|
||||||
|
|
||||||
|
// Update font after init because the original titleLabel is replaced.
|
||||||
|
self.titleLabel?.font = UIFont.boldSystemFont(ofSize: 14)
|
||||||
|
|
||||||
|
switch self.style
|
||||||
|
{
|
||||||
|
case .custom: break // Don't update insets in case client has updated them.
|
||||||
|
case .pill:
|
||||||
|
self.contentEdgeInsets = UIEdgeInsets(top: Self.contentInsets.top, left: Self.contentInsets.leading, bottom: Self.contentInsets.bottom, right: Self.contentInsets.trailing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
19
AltStore/Components/TextCollectionReusableView.swift
Normal file
19
AltStore/Components/TextCollectionReusableView.swift
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//
|
||||||
|
// TextCollectionReusableView.swift
|
||||||
|
// AltStore
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 3/23/20.
|
||||||
|
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class TextCollectionReusableView: UICollectionReusableView
|
||||||
|
{
|
||||||
|
@IBOutlet var textLabel: UILabel!
|
||||||
|
|
||||||
|
@IBOutlet var topLayoutConstraint: NSLayoutConstraint!
|
||||||
|
@IBOutlet var bottomLayoutConstraint: NSLayoutConstraint!
|
||||||
|
@IBOutlet var leadingLayoutConstraint: NSLayoutConstraint!
|
||||||
|
@IBOutlet var trailingLayoutConstraint: NSLayoutConstraint!
|
||||||
|
}
|
||||||
@@ -8,13 +8,81 @@
|
|||||||
|
|
||||||
import Roxas
|
import Roxas
|
||||||
|
|
||||||
|
import AltStoreCore
|
||||||
|
|
||||||
|
extension TimeInterval
|
||||||
|
{
|
||||||
|
static let shortToastViewDuration = 4.0
|
||||||
|
static let longToastViewDuration = 8.0
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ToastView
|
||||||
|
{
|
||||||
|
static let openErrorLogNotification = Notification.Name("ALTOpenErrorLogNotification")
|
||||||
|
}
|
||||||
|
|
||||||
class ToastView: RSTToastView
|
class ToastView: RSTToastView
|
||||||
{
|
{
|
||||||
|
var preferredDuration: TimeInterval
|
||||||
|
|
||||||
|
var opensErrorLog: Bool = false
|
||||||
|
|
||||||
|
convenience init(text: String, detailText: String?, opensLog: Bool = false) {
|
||||||
|
self.init(text: text, detailText: detailText)
|
||||||
|
self.opensErrorLog = opensLog
|
||||||
|
}
|
||||||
|
|
||||||
override init(text: String, detailText detailedText: String?)
|
override init(text: String, detailText detailedText: String?)
|
||||||
{
|
{
|
||||||
|
if detailedText == nil
|
||||||
|
{
|
||||||
|
self.preferredDuration = .shortToastViewDuration
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.preferredDuration = .longToastViewDuration
|
||||||
|
}
|
||||||
|
|
||||||
super.init(text: text, detailText: detailedText)
|
super.init(text: text, detailText: detailedText)
|
||||||
|
|
||||||
self.layoutMargins = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)
|
self.isAccessibilityElement = true
|
||||||
|
|
||||||
|
self.layoutMargins = UIEdgeInsets(top: 8, left: 16, bottom: 10, right: 16)
|
||||||
|
self.setNeedsLayout()
|
||||||
|
|
||||||
|
if let stackView = self.textLabel.superview as? UIStackView
|
||||||
|
{
|
||||||
|
// RSTToastView does not expose stack view containing labels,
|
||||||
|
// so we access it indirectly as the labels' superview.
|
||||||
|
stackView.spacing = (detailedText != nil) ? 4.0 : 0.0
|
||||||
|
stackView.alignment = .leading
|
||||||
|
}
|
||||||
|
self.addTarget(self, action: #selector(ToastView.showErrorLog), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
convenience init(error: Error, opensLog: Bool = false) {
|
||||||
|
self.init(error: error)
|
||||||
|
self.opensErrorLog = opensLog
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InfoMode: String {
|
||||||
|
case fullError
|
||||||
|
case localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
convenience init(error: Error){
|
||||||
|
self.init(error: error, mode: .localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
convenience init(error: Error, mode: InfoMode)
|
||||||
|
{
|
||||||
|
let error = error as NSError
|
||||||
|
let mode = mode == .fullError ? ErrorProcessing.InfoMode.fullError : ErrorProcessing.InfoMode.localizedDescription
|
||||||
|
|
||||||
|
let text = error.localizedTitle ?? NSLocalizedString("Operation Failed", comment: "")
|
||||||
|
let detailText = ErrorProcessing(mode).getDescription(error: error)
|
||||||
|
|
||||||
|
self.init(text: text, detailText: detailText)
|
||||||
}
|
}
|
||||||
|
|
||||||
required init(coder aDecoder: NSCoder) {
|
required init(coder aDecoder: NSCoder) {
|
||||||
@@ -25,6 +93,53 @@ class ToastView: RSTToastView
|
|||||||
{
|
{
|
||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
|
|
||||||
self.layer.cornerRadius = 16
|
// Rough calculation to determine height of ToastView with one-line textLabel.
|
||||||
|
let minimumHeight = self.textLabel.font.lineHeight.rounded() + 18
|
||||||
|
self.layer.cornerRadius = minimumHeight/2
|
||||||
|
}
|
||||||
|
|
||||||
|
func show(in viewController: UIViewController)
|
||||||
|
{
|
||||||
|
self.show(in: viewController.navigationController?.view ?? viewController.view, duration: self.preferredDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func show(in view: UIView, duration: TimeInterval)
|
||||||
|
{
|
||||||
|
if opensErrorLog, #available(iOS 13.0, *), case let configuration = UIImage.SymbolConfiguration(font: self.textLabel.font),
|
||||||
|
let icon = UIImage(systemName: "chevron.right.circle", withConfiguration: configuration) {
|
||||||
|
let tintedIcon = icon.withTintColor(.white, renderingMode: .alwaysOriginal)
|
||||||
|
let moreIconImageView = UIImageView(image: tintedIcon)
|
||||||
|
moreIconImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
self.addSubview(moreIconImageView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
moreIconImageView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -self.layoutMargins.right),
|
||||||
|
moreIconImageView.centerYAnchor.constraint(equalTo: self.textLabel.centerYAnchor),
|
||||||
|
moreIconImageView.leadingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: self.textLabel.trailingAnchor, multiplier: 1.0)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
super.show(in: view, duration: duration)
|
||||||
|
|
||||||
|
let announcement = (self.textLabel.text ?? "") + ". " + (self.detailTextLabel.text ?? "")
|
||||||
|
self.accessibilityLabel = announcement
|
||||||
|
|
||||||
|
// Minimum 0.75 delay to prevent announcement being cut off by VoiceOver.
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: announcement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func show(in view: UIView)
|
||||||
|
{
|
||||||
|
self.show(in: view, duration: self.preferredDuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ToastView
|
||||||
|
{
|
||||||
|
@objc func showErrorLog()
|
||||||
|
{
|
||||||
|
guard self.opensErrorLog else { return }
|
||||||
|
|
||||||
|
NotificationCenter.default.post(name: ToastView.openErrorLogNotification, object: self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
150
AltStore/Components/VibrantButton.swift
Normal file
150
AltStore/Components/VibrantButton.swift
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
//
|
||||||
|
// VibrantButton.swift
|
||||||
|
// AltStore
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 3/22/23.
|
||||||
|
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
private let preferredFont = UIFont.boldSystemFont(ofSize: 14)
|
||||||
|
|
||||||
|
class VibrantButton: UIButton
|
||||||
|
{
|
||||||
|
var title: String? {
|
||||||
|
didSet {
|
||||||
|
if #available(iOS 15, *)
|
||||||
|
{
|
||||||
|
self.configuration?.title = self.title
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.setTitle(self.title, for: .normal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var image: UIImage? {
|
||||||
|
didSet {
|
||||||
|
if #available(iOS 15, *)
|
||||||
|
{
|
||||||
|
self.configuration?.image = self.image
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.setImage(self.image, for: .normal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentInsets: NSDirectionalEdgeInsets = .zero {
|
||||||
|
didSet {
|
||||||
|
if #available(iOS 15, *)
|
||||||
|
{
|
||||||
|
self.configuration?.contentInsets = self.contentInsets
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.contentEdgeInsets = UIEdgeInsets(top: self.contentInsets.top, left: self.contentInsets.leading, bottom: self.contentInsets.bottom, right: self.contentInsets.trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override var isIndicatingActivity: Bool {
|
||||||
|
didSet {
|
||||||
|
guard #available(iOS 15, *) else { return }
|
||||||
|
self.updateConfiguration()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let vibrancyView = UIVisualEffectView(effect: nil)
|
||||||
|
|
||||||
|
override init(frame: CGRect)
|
||||||
|
{
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
self.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder)
|
||||||
|
{
|
||||||
|
super.init(coder: coder)
|
||||||
|
|
||||||
|
self.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initialize()
|
||||||
|
{
|
||||||
|
let blurEffect = UIBlurEffect(style: .systemThinMaterial)
|
||||||
|
let vibrancyEffect = UIVibrancyEffect(blurEffect: blurEffect, style: .fill) // .fill is more vibrant than .secondaryLabel
|
||||||
|
|
||||||
|
if #available(iOS 15, *)
|
||||||
|
{
|
||||||
|
var backgroundConfig = UIBackgroundConfiguration.clear()
|
||||||
|
backgroundConfig.visualEffect = blurEffect
|
||||||
|
|
||||||
|
var config = UIButton.Configuration.plain()
|
||||||
|
config.cornerStyle = .capsule
|
||||||
|
config.background = backgroundConfig
|
||||||
|
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { [weak self] (attributes) in
|
||||||
|
var attributes = attributes
|
||||||
|
attributes.font = preferredFont
|
||||||
|
|
||||||
|
if let self, self.isIndicatingActivity
|
||||||
|
{
|
||||||
|
// Hide title when indicating activity, but without changing intrinsicContentSize.
|
||||||
|
attributes.foregroundColor = UIColor.clear
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributes
|
||||||
|
}
|
||||||
|
|
||||||
|
self.configuration = config
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.clipsToBounds = true
|
||||||
|
self.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8) // Add padding.
|
||||||
|
|
||||||
|
let blurView = UIVisualEffectView(effect: blurEffect)
|
||||||
|
blurView.isUserInteractionEnabled = false
|
||||||
|
self.addSubview(blurView, pinningEdgesWith: .zero)
|
||||||
|
self.insertSubview(blurView, at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.vibrancyView.effect = vibrancyEffect
|
||||||
|
self.vibrancyView.isUserInteractionEnabled = false
|
||||||
|
self.addSubview(self.vibrancyView, pinningEdgesWith: .zero)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews()
|
||||||
|
{
|
||||||
|
super.layoutSubviews()
|
||||||
|
|
||||||
|
self.layer.cornerRadius = self.bounds.midY
|
||||||
|
|
||||||
|
// Make sure content subviews are inside self.vibrancyView.contentView.
|
||||||
|
|
||||||
|
if let titleLabel = self.titleLabel, titleLabel.superview != self.vibrancyView.contentView
|
||||||
|
{
|
||||||
|
self.vibrancyView.contentView.addSubview(titleLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let imageView = self.imageView, imageView.superview != self.vibrancyView.contentView
|
||||||
|
{
|
||||||
|
self.vibrancyView.contentView.addSubview(imageView)
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.activityIndicatorView.superview != self.vibrancyView.contentView
|
||||||
|
{
|
||||||
|
self.vibrancyView.contentView.addSubview(self.activityIndicatorView)
|
||||||
|
}
|
||||||
|
|
||||||
|
if #unavailable(iOS 15)
|
||||||
|
{
|
||||||
|
// Update font after init because the original titleLabel is replaced.
|
||||||
|
self.titleLabel?.font = preferredFont
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user