Compare commits
921 Commits
1.0
...
feature/Ro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4c2d17ffc | ||
|
|
2c829895c9 | ||
|
|
5463f2b935 | ||
|
|
d644ee7ab0 | ||
|
|
351d4fd631 | ||
|
|
128b180c1f | ||
|
|
1f2693bea6 | ||
|
|
452cf89c95 | ||
|
|
90ac0fb025 | ||
|
|
10f5ee1548 | ||
|
|
478b30c8fd | ||
|
|
207f6aac32 | ||
|
|
e1ed6f5ba3 | ||
|
|
444aac1210 | ||
|
|
f49fa24743 | ||
|
|
4c9c5b1a56 | ||
|
|
365cadbb31 | ||
|
|
36e03a52a7 | ||
|
|
19cf1722fa | ||
|
|
c28a45f100 | ||
|
|
df5b0c3af1 | ||
|
|
8b1e87d2dd | ||
|
|
e036f07875 | ||
|
|
2d232fa702 | ||
|
|
686d1ab42a | ||
|
|
d22d12c234 | ||
|
|
364b11ec9d | ||
|
|
f3a70e1e47 | ||
|
|
493b3783f0 | ||
|
|
4669227567 | ||
|
|
dfcc6e714e | ||
|
|
3b824eac96 | ||
|
|
a6559d8bb9 | ||
|
|
f270ecc537 | ||
|
|
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 |
21
.codecov.yml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# https://docs.codecov.io/docs/codecov-yaml
|
||||||
|
|
||||||
|
codecov:
|
||||||
|
require_ci_to_pass: true
|
||||||
|
|
||||||
|
coverage:
|
||||||
|
precision: 2
|
||||||
|
round: down
|
||||||
|
range: "70...100"
|
||||||
|
ignore:
|
||||||
|
- Dependencies
|
||||||
|
status:
|
||||||
|
patch:
|
||||||
|
default:
|
||||||
|
if_no_uploads: error
|
||||||
|
changes: true
|
||||||
|
project:
|
||||||
|
default:
|
||||||
|
target: auto
|
||||||
|
if_no_uploads: error
|
||||||
|
comment: false
|
||||||
35
.editorconfig
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# http://editorconfig.org
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
charset = utf-8
|
||||||
|
indent_size = 4
|
||||||
|
end_of_line = lf
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.{md,markdown}]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{c,h,m,mm}]
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.js]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.{swift}]
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 8
|
||||||
|
|
||||||
|
[*.{yaml|yml}]
|
||||||
|
indent_size = 2
|
||||||
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* @JoeMatt @lonkelle
|
||||||
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:
|
||||||
|
- naturecodevoid
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
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/RgpFBX3Q3k) 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
@@ -0,0 +1,10 @@
|
|||||||
|
# force issue template usage
|
||||||
|
blank_issues_enabled: false
|
||||||
|
|
||||||
|
contact_links:
|
||||||
|
- name: Discord
|
||||||
|
url: https://discord.gg/RgpFBX3Q3k
|
||||||
|
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.
|
||||||
33
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest a feature
|
||||||
|
title: "[FEATURE REQUEST] "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
assignees:
|
||||||
|
- naturecodevoid
|
||||||
|
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/RgpFBX3Q3k) 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.
|
||||||
15
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
### 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
|
||||||
|
|
||||||
|
<!-- If your PR doesn't close an issue, you can remove the next line. -->
|
||||||
|
Closes #1234
|
||||||
20
.github/workflows/.disabled/sidestore-project.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# .github/workflows/sidestore-project.yml
|
||||||
|
name: SideStore Project
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: tuist/tuist-action@0.13.0
|
||||||
|
with:
|
||||||
|
command: 'build'
|
||||||
|
arguments: ''
|
||||||
|
|
||||||
22
.github/workflows/attach_build_products.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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
|
||||||
90
.github/workflows/beta.yml
vendored
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
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-12'
|
||||||
|
version: '14.2'
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
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: Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@v1.4.1
|
||||||
|
with:
|
||||||
|
xcode-version: ${{ matrix.version }}
|
||||||
|
|
||||||
|
- name: Build SideStore
|
||||||
|
run: make build | xcpretty && exit ${PIPESTATUS[0]}
|
||||||
|
|
||||||
|
- name: Fakesign app
|
||||||
|
run: make fakesign
|
||||||
|
|
||||||
|
- name: Convert to IPA
|
||||||
|
run: make ipa
|
||||||
|
|
||||||
|
- name: Upload SideStore.ipa Artifact
|
||||||
|
uses: actions/upload-artifact@v3.1.0
|
||||||
|
with:
|
||||||
|
name: SideStore.ipa
|
||||||
|
path: SideStore.ipa
|
||||||
|
|
||||||
|
- name: Upload *.dSYM Artifact
|
||||||
|
uses: actions/upload-artifact@v3.1.0
|
||||||
|
with:
|
||||||
|
name: SideStore-dSYM
|
||||||
|
path: ./*.dSYM/
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: version
|
||||||
|
run: echo "version=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Get current date
|
||||||
|
id: date
|
||||||
|
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Get current date in SideStore date form
|
||||||
|
id: date_sidestore
|
||||||
|
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_sidestore.outputs.date }}`
|
||||||
|
Commit SHA: `${{ github.sha }}`
|
||||||
|
Version: `${{ steps.version.outputs.version }}`
|
||||||
28
.github/workflows/increase-nightly-build-num.sh
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Ensure we are in root directory
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
DATE=`date -u +'%Y.%m.%d'`
|
||||||
|
BUILD_NUM=1
|
||||||
|
|
||||||
|
write() {
|
||||||
|
sed -e "/MARKETING_VERSION = .*/s/$/-nightly.$DATE.$BUILD_NUM/" -i '' Build.xcconfig
|
||||||
|
echo "$DATE,$BUILD_NUM" > .nightly-build-num
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ ! -f ".nightly-build-num" ]; then
|
||||||
|
write
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
LAST_DATE=`cat .nightly-build-num | perl -n -e '/([^,]*),([^ ]*)$/ && print $1'`
|
||||||
|
LAST_BUILD_NUM=`cat .nightly-build-num | perl -n -e '/([^,]*),([^ ]*)$/ && print $2'`
|
||||||
|
|
||||||
|
if [[ "$DATE" != "$LAST_DATE" ]]; then
|
||||||
|
write
|
||||||
|
else
|
||||||
|
BUILD_NUM=`expr $LAST_BUILD_NUM + 1`
|
||||||
|
write
|
||||||
|
fi
|
||||||
|
|
||||||
100
.github/workflows/nightly.yml
vendored
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
name: Nightly SideStore build
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build and upload SideStore Nightly
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: 'macos-12'
|
||||||
|
version: '14.2'
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: brew install ldid
|
||||||
|
|
||||||
|
- name: Cache .nightly-build-num
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: .nightly-build-num
|
||||||
|
key: nightly-build-num
|
||||||
|
|
||||||
|
- name: Increase nightly build number and set as version
|
||||||
|
run: bash .github/workflows/increase-nightly-build-num.sh
|
||||||
|
|
||||||
|
- name: Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@v1.4.1
|
||||||
|
with:
|
||||||
|
xcode-version: ${{ matrix.version }}
|
||||||
|
|
||||||
|
- name: Build SideStore
|
||||||
|
run: make build | xcpretty && exit ${PIPESTATUS[0]}
|
||||||
|
|
||||||
|
- name: Fakesign app
|
||||||
|
run: make fakesign
|
||||||
|
|
||||||
|
- name: Convert to IPA
|
||||||
|
run: make ipa
|
||||||
|
|
||||||
|
- name: Upload SideStore.ipa Artifact
|
||||||
|
uses: actions/upload-artifact@v3.1.0
|
||||||
|
with:
|
||||||
|
name: SideStore.ipa
|
||||||
|
path: SideStore.ipa
|
||||||
|
|
||||||
|
- name: Upload *.dSYM Artifact
|
||||||
|
uses: actions/upload-artifact@v3.1.0
|
||||||
|
with:
|
||||||
|
name: SideStore-dSYM
|
||||||
|
path: ./*.dSYM/
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: version
|
||||||
|
run: echo "version=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Get current date
|
||||||
|
id: date
|
||||||
|
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Get current date in SideStore date form
|
||||||
|
id: date_sidestore
|
||||||
|
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Upload to nightly release
|
||||||
|
uses: IsaacShelton/update-existing-release@v1.3.1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
release: "Nightly"
|
||||||
|
tag: "nightly"
|
||||||
|
prerelease: true
|
||||||
|
files: SideStore.ipa
|
||||||
|
body: |
|
||||||
|
This is an ⚠️ **EXPERIMENTAL** ⚠️ nightly build for commit [${{ github.sha }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }}).
|
||||||
|
|
||||||
|
Nightly builds are **extremely experimental builds only meant to be used by developers and alpha 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 Beta](https://github.com/${{ github.repository }}/releases?q=beta).
|
||||||
|
|
||||||
|
## Build Info
|
||||||
|
|
||||||
|
Built at (UTC): `${{ steps.date.outputs.date }}`
|
||||||
|
Built at (UTC date): `${{ steps.date_sidestore.outputs.date }}`
|
||||||
|
Commit SHA: `${{ github.sha }}`
|
||||||
|
Version: `${{ steps.version.outputs.version }}`
|
||||||
|
|
||||||
|
- name: Reset cache for apps.sidestore.io/nightly
|
||||||
|
run: sleep 10 && curl https://apps.sidestore.io/reset-cache/nightly/${{ secrets.SIDESOURCE_KEY }}
|
||||||
52
.github/workflows/pr.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
name: Pull Request SideStore build
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build and upload SideStore
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: 'macos-12'
|
||||||
|
version: '14.2'
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: brew install ldid
|
||||||
|
|
||||||
|
- name: Add PR suffix to version
|
||||||
|
run: sed -e '/MARKETING_VERSION = .*/s/$/-pr.${{ github.event.pull_request.number }}/' -i '' Build.xcconfig
|
||||||
|
|
||||||
|
- name: Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@v1.4.1
|
||||||
|
with:
|
||||||
|
xcode-version: ${{ matrix.version }}
|
||||||
|
|
||||||
|
- name: Build SideStore
|
||||||
|
run: make build | xcpretty && exit ${PIPESTATUS[0]}
|
||||||
|
|
||||||
|
- name: Fakesign app
|
||||||
|
run: make fakesign
|
||||||
|
|
||||||
|
- name: Convert to IPA
|
||||||
|
run: make ipa
|
||||||
|
|
||||||
|
- name: Upload SideStore.ipa Artifact
|
||||||
|
uses: actions/upload-artifact@v3.1.0
|
||||||
|
with:
|
||||||
|
name: SideStore.ipa
|
||||||
|
path: SideStore.ipa
|
||||||
|
|
||||||
|
- name: Upload *.dSYM Artifact
|
||||||
|
uses: actions/upload-artifact@v3.1.0
|
||||||
|
with:
|
||||||
|
name: SideStore-dSYM
|
||||||
|
path: ./*.dSYM/
|
||||||
87
.github/workflows/stable.yml
vendored
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
name: Stable SideStore build
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '[0-9]+.[0-9]+.[0-9]+' # example: 1.0.0
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build and upload SideStore
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: 'macos-12'
|
||||||
|
version: '14.2'
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
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: Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@v1.4.1
|
||||||
|
with:
|
||||||
|
xcode-version: ${{ matrix.version }}
|
||||||
|
|
||||||
|
- name: Build SideStore
|
||||||
|
run: make build | xcpretty && exit ${PIPESTATUS[0]}
|
||||||
|
|
||||||
|
- name: Fakesign app
|
||||||
|
run: make fakesign
|
||||||
|
|
||||||
|
- name: Convert to IPA
|
||||||
|
run: make ipa
|
||||||
|
|
||||||
|
- name: Upload SideStore.ipa Artifact
|
||||||
|
uses: actions/upload-artifact@v3.1.0
|
||||||
|
with:
|
||||||
|
name: SideStore.ipa
|
||||||
|
path: SideStore.ipa
|
||||||
|
|
||||||
|
- name: Upload *.dSYM Artifact
|
||||||
|
uses: actions/upload-artifact@v3.1.0
|
||||||
|
with:
|
||||||
|
name: SideStore-dSYM
|
||||||
|
path: ./*.dSYM/
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: version
|
||||||
|
run: echo "version=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Get current date
|
||||||
|
id: date
|
||||||
|
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Get current date in SideStore date form
|
||||||
|
id: date_sidestore
|
||||||
|
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Upload to new stable release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
name: ${{ steps.version.outputs.version }}
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
draft: 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. -->
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
- TODO
|
||||||
|
|
||||||
|
## Build Info
|
||||||
|
|
||||||
|
Built at (UTC): `${{ steps.date.outputs.date }}`
|
||||||
|
Built at (UTC date): `${{ steps.date_sidestore.outputs.date }}`
|
||||||
|
Commit SHA: `${{ github.sha }}`
|
||||||
|
Version: `${{ steps.version.outputs.version }}`
|
||||||
18
.gitignore
vendored
@@ -8,7 +8,7 @@
|
|||||||
## Build generated
|
## Build generated
|
||||||
build/
|
build/
|
||||||
DerivedData
|
DerivedData
|
||||||
|
archive.xcarchive
|
||||||
## Various settings
|
## Various settings
|
||||||
*.pbxuser
|
*.pbxuser
|
||||||
!default.pbxuser
|
!default.pbxuser
|
||||||
@@ -28,3 +28,19 @@ xcuserdata
|
|||||||
|
|
||||||
## Obj-C/Swift specific
|
## Obj-C/Swift specific
|
||||||
*.hmap
|
*.hmap
|
||||||
|
/newrelic_agent.log
|
||||||
|
/CodeSigning.xcconfig
|
||||||
|
/.vscode
|
||||||
|
|
||||||
|
## AppCode specific
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
Payload/
|
||||||
|
SideStore.ipa
|
||||||
|
*.dSYM
|
||||||
|
|
||||||
|
Dependencies/.*-prebuilt-fetch-*
|
||||||
|
Dependencies/minimuxer/*
|
||||||
|
Dependencies/em_proxy/*
|
||||||
|
!Dependencies/**/.gitkeep
|
||||||
|
.nightly-build-num
|
||||||
|
|||||||
21
.gitmodules
vendored
@@ -1,15 +1,6 @@
|
|||||||
[submodule "Dependencies/Roxas"]
|
[submodule "Dependencies/em_proxy"]
|
||||||
path = Dependencies/Roxas
|
path = SideStoreApp/Dependencies/em_proxy
|
||||||
url = https://github.com/rileytestut/Roxas.git
|
url = https://github.com/SideStore/em_proxy.git
|
||||||
[submodule "Dependencies/AltSign"]
|
[submodule "Dependencies/minimuxer"]
|
||||||
path = Dependencies/AltSign
|
path = SideStoreApp/Dependencies/minimuxer
|
||||||
url = https://github.com/rileytestut/AltSign.git
|
url = https://github.com/SideStore/minimuxer.git
|
||||||
[submodule "Dependencies/libimobiledevice"]
|
|
||||||
path = Dependencies/libimobiledevice
|
|
||||||
url = https://github.com/rileytestut/libimobiledevice.git
|
|
||||||
[submodule "Dependencies/libusbmuxd"]
|
|
||||||
path = Dependencies/libusbmuxd
|
|
||||||
url = https://github.com/libimobiledevice/libusbmuxd.git
|
|
||||||
[submodule "Dependencies/libplist"]
|
|
||||||
path = Dependencies/libplist
|
|
||||||
url = https://github.com/libimobiledevice/libplist.git
|
|
||||||
|
|||||||
28
.jazzy.yaml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# ---- About ----
|
||||||
|
module: SideStore
|
||||||
|
module_version: 1.0,0
|
||||||
|
author: SideStore
|
||||||
|
readme: README.md
|
||||||
|
copyright: 'See [license](https://github.com/SideStore/SideStore/blob/develop/LICENSE) for more details.'
|
||||||
|
|
||||||
|
# ---- URLs ----
|
||||||
|
author_url: https://sidestore.io
|
||||||
|
dash_url: https://sidestore.io/docsets/SideStore.xml
|
||||||
|
github_url: https://github.com/SideStore/SideStore/
|
||||||
|
github_file_prefix: https://github.com/SideStore/SideStore/tree/1.0.2/
|
||||||
|
|
||||||
|
# ---- Sources ----
|
||||||
|
source_directory: Sources
|
||||||
|
documentation: .build/x86_64-apple-macosx/debug/SideStore.docc
|
||||||
|
|
||||||
|
# ---- Generation ----
|
||||||
|
clean: true
|
||||||
|
output: docs
|
||||||
|
min_acl: public
|
||||||
|
hide_documentation_coverage: false
|
||||||
|
skip_undocumented: false
|
||||||
|
objc: false
|
||||||
|
swift_version: 5.1.0
|
||||||
|
|
||||||
|
# ---- Formatting ----
|
||||||
|
theme: fullwidth
|
||||||
42
.swiftformat
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# .swiftformat
|
||||||
|
|
||||||
|
## file options
|
||||||
|
|
||||||
|
--exclude .build,.github,.swiftpm,.vscode,Configurations,Dependencies
|
||||||
|
|
||||||
|
## format options
|
||||||
|
|
||||||
|
--allman false
|
||||||
|
--binarygrouping 4,8
|
||||||
|
--commas always
|
||||||
|
--comments indent
|
||||||
|
--decimalgrouping 3,6
|
||||||
|
--elseposition same-line
|
||||||
|
--empty void
|
||||||
|
--exponentcase lowercase
|
||||||
|
--exponentgrouping disabled
|
||||||
|
--fractiongrouping disabled
|
||||||
|
--header ignore
|
||||||
|
--hexgrouping 4,8
|
||||||
|
--hexliteralcase uppercase
|
||||||
|
--ifdef indent
|
||||||
|
--importgrouping testable-bottom
|
||||||
|
--indent 4
|
||||||
|
--indentcase false
|
||||||
|
--linebreaks lf
|
||||||
|
--maxwidth none
|
||||||
|
--octalgrouping 4,8
|
||||||
|
--operatorfunc spaced
|
||||||
|
--patternlet hoist
|
||||||
|
--ranges spaced
|
||||||
|
--self remove
|
||||||
|
--semicolons inline
|
||||||
|
--stripunusedargs always
|
||||||
|
--swiftversion 5.1
|
||||||
|
--trimwhitespace always
|
||||||
|
--wraparguments preserve
|
||||||
|
--wrapcollections preserve
|
||||||
|
|
||||||
|
## rules
|
||||||
|
|
||||||
|
--enable isEmpty,andOperator,assertionFailures
|
||||||
76
.swiftlint.yml
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
disabled_rules:
|
||||||
|
- block_based_kvo
|
||||||
|
- colon
|
||||||
|
- control_statement
|
||||||
|
- cyclomatic_complexity
|
||||||
|
- discarded_notification_center_observer
|
||||||
|
- file_length
|
||||||
|
- function_parameter_count
|
||||||
|
- generic_type_name
|
||||||
|
- identifier_name
|
||||||
|
- multiple_closures_with_trailing_closure
|
||||||
|
- nesting
|
||||||
|
- switch_case_alignment
|
||||||
|
- todo
|
||||||
|
- type_name
|
||||||
|
- type_body_length
|
||||||
|
- function_body_length
|
||||||
|
- unused_closure_parameter
|
||||||
|
|
||||||
|
# parameterized rules can be customized from this configuration file
|
||||||
|
line_length: 200
|
||||||
|
# parameterized rules are first parameterized as a warning level, then error level.
|
||||||
|
type_body_length:
|
||||||
|
- 300 # warning
|
||||||
|
- 600 # error
|
||||||
|
# parameterized rules are first parameterized as a warning level, then error level.
|
||||||
|
# identifier_name_max_length:
|
||||||
|
# - 40 # warning
|
||||||
|
# - 60 # error
|
||||||
|
# # parameterized rules are first parameterized as a warning level, then error level.
|
||||||
|
# identifier_name_min_length:
|
||||||
|
# - 3 # warning
|
||||||
|
# - 2 # error
|
||||||
|
function_body_length:
|
||||||
|
- 200 # warning
|
||||||
|
- 500 # error
|
||||||
|
large_tuple:
|
||||||
|
- 4 # warning
|
||||||
|
- 6 # error
|
||||||
|
|
||||||
|
opt_in_rules:
|
||||||
|
- empty_count
|
||||||
|
- force_unwrapping
|
||||||
|
|
||||||
|
excluded: # paths to ignore during linting. overridden byincluded.
|
||||||
|
- .build
|
||||||
|
- .github
|
||||||
|
- .swiftpm
|
||||||
|
- .vscode
|
||||||
|
- Dependencies
|
||||||
|
|
||||||
|
analyzer_rules: # Rules run by `swiftlint analyze` (experimental)
|
||||||
|
- explicit_self
|
||||||
|
|
||||||
|
# Override these rules to be warnings for now
|
||||||
|
force_cast: warning
|
||||||
|
force_try: warning
|
||||||
|
empty_count: warning
|
||||||
|
|
||||||
|
reporter: "xcode" # reporter type (xcode, json, csv, checkstyle, junit)
|
||||||
|
|
||||||
|
custom_rules:
|
||||||
|
placeholders_in_comments:
|
||||||
|
included: ".*\\.swift"
|
||||||
|
name: "No Placeholders in Comments"
|
||||||
|
regex: "<#([^#]+)#>"
|
||||||
|
match_kinds:
|
||||||
|
- comment
|
||||||
|
- doccomment
|
||||||
|
message: "Placeholder left in comment."
|
||||||
|
tiles_deprecated:
|
||||||
|
included: ".*\\.swift"
|
||||||
|
name: "Tiles are deprecated in favor of Frame"
|
||||||
|
regex: "([T,t]ile$|^[T,t]il[e,es])"
|
||||||
|
message: "Tiles are deprecated in favor of Frame"
|
||||||
|
severity: warning
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
//
|
|
||||||
// AltKit.h
|
|
||||||
// AltKit
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 5/30/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "NSError+ALTServerError.h"
|
|
||||||
@@ -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,265 +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
|
|
||||||
|
|
||||||
if !UserDefaults.standard.didPresentInitialNotification
|
|
||||||
{
|
|
||||||
let content = UNMutableNotificationContent()
|
|
||||||
content.title = NSLocalizedString("AltServer Running", comment: "")
|
|
||||||
content.body = NSLocalizedString("AltServer runs in the background as a menu bar app listening for AltStore.", comment: "")
|
|
||||||
|
|
||||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
|
||||||
UNUserNotificationCenter.current().add(request)
|
|
||||||
|
|
||||||
UserDefaults.standard.didPresentInitialNotification = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
If you have two-factor authentication enabled, please create an app-specific password for use with AltStore at https://appleid.apple.com.
|
|
||||||
""", 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,68 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"size" : "16x16",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"filename" : "Icon@16.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "16x16",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"filename" : "Icon@32-1.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "32x32",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"filename" : "Icon@32.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "32x32",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"filename" : "Icon@64.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "128x128",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"filename" : "Icon@128.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "128x128",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"filename" : "Icon@256-1.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "256x256",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"filename" : "Icon@256.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "256x256",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"filename" : "Icon@512-1.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "512x512",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"filename" : "Icon@512.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "512x512",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"filename" : "Icon@1024.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"version" : 1,
|
|
||||||
"author" : "xcode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"filename" : "MenuBar@19.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"filename" : "MenuBar@38.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"version" : 1,
|
|
||||||
"author" : "xcode"
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"template-rendering-intent" : "template"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.6 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,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://f000.backblazeb2.com/file/altstore/altstore.ipa")!
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var didPresentInitialNotification: Bool {
|
|
||||||
get {
|
|
||||||
return self.bool(forKey: "didPresentInitialNotification")
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
self.set(newValue, forKey: "didPresentInitialNotification")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerDefaults()
|
|
||||||
{
|
|
||||||
if self.serverID == nil
|
|
||||||
{
|
|
||||||
self.serverID = UUID().uuidString
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
16
AltStore.xcworkspace/contents.xcworkspacedata
generated
@@ -1,16 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Workspace
|
|
||||||
version = "1.0">
|
|
||||||
<FileRef
|
|
||||||
location = "container:AltStore.xcodeproj">
|
|
||||||
</FileRef>
|
|
||||||
<FileRef
|
|
||||||
location = "group:Dependencies/AltSign/AltSign.xcodeproj">
|
|
||||||
</FileRef>
|
|
||||||
<FileRef
|
|
||||||
location = "group:Dependencies/Roxas/Roxas.xcodeproj">
|
|
||||||
</FileRef>
|
|
||||||
<FileRef
|
|
||||||
location = "group:Pods/Pods.xcodeproj">
|
|
||||||
</FileRef>
|
|
||||||
</Workspace>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
//
|
|
||||||
// Use this file to import your target's public headers that you would like to expose to Swift.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "NSError+ALTServerError.h"
|
|
||||||
#import "ALTAppPermission.h"
|
|
||||||
#import "ALTPatreonBenefitType.h"
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
//
|
|
||||||
// AppContentViewController.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/22/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
import Nuke
|
|
||||||
|
|
||||||
extension AppContentViewController
|
|
||||||
{
|
|
||||||
private enum Row: Int, CaseIterable
|
|
||||||
{
|
|
||||||
case subtitle
|
|
||||||
case screenshots
|
|
||||||
case description
|
|
||||||
case versionDescription
|
|
||||||
case permissions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AppContentViewController: UITableViewController
|
|
||||||
{
|
|
||||||
var app: StoreApp!
|
|
||||||
|
|
||||||
private lazy var screenshotsDataSource = self.makeScreenshotsDataSource()
|
|
||||||
private lazy var permissionsDataSource = self.makePermissionsDataSource()
|
|
||||||
|
|
||||||
private lazy var dateFormatter: DateFormatter = {
|
|
||||||
let dateFormatter = DateFormatter()
|
|
||||||
dateFormatter.dateStyle = .medium
|
|
||||||
dateFormatter.timeStyle = .none
|
|
||||||
return dateFormatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
private lazy var byteCountFormatter: ByteCountFormatter = {
|
|
||||||
let formatter = ByteCountFormatter()
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
@IBOutlet private var subtitleLabel: UILabel!
|
|
||||||
@IBOutlet private var descriptionTextView: CollapsingTextView!
|
|
||||||
@IBOutlet private var versionDescriptionTextView: CollapsingTextView!
|
|
||||||
@IBOutlet private var versionLabel: UILabel!
|
|
||||||
@IBOutlet private var versionDateLabel: UILabel!
|
|
||||||
@IBOutlet private var sizeLabel: UILabel!
|
|
||||||
|
|
||||||
@IBOutlet private var screenshotsCollectionView: UICollectionView!
|
|
||||||
@IBOutlet private var permissionsCollectionView: UICollectionView!
|
|
||||||
|
|
||||||
var preferredScreenshotSize: CGSize? {
|
|
||||||
let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
|
||||||
|
|
||||||
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()
|
|
||||||
{
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
self.tableView.contentInset.bottom = 20
|
|
||||||
|
|
||||||
self.screenshotsCollectionView.dataSource = self.screenshotsDataSource
|
|
||||||
self.permissionsCollectionView.dataSource = self.permissionsDataSource
|
|
||||||
|
|
||||||
self.subtitleLabel.text = self.app.subtitle
|
|
||||||
self.descriptionTextView.text = self.app.localizedDescription
|
|
||||||
self.versionDescriptionTextView.text = self.app.versionDescription
|
|
||||||
self.versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), self.app.version)
|
|
||||||
self.versionDateLabel.text = Date().relativeDateString(since: self.app.versionDate, dateFormatter: self.dateFormatter)
|
|
||||||
self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: Int64(self.app.size))
|
|
||||||
|
|
||||||
self.descriptionTextView.maximumNumberOfLines = 5
|
|
||||||
self.descriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
|
|
||||||
|
|
||||||
self.versionDescriptionTextView.maximumNumberOfLines = 3
|
|
||||||
self.versionDescriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayoutSubviews()
|
|
||||||
{
|
|
||||||
super.viewDidLayoutSubviews()
|
|
||||||
|
|
||||||
guard var size = self.preferredScreenshotSize else { return }
|
|
||||||
size.height = min(size.height, self.screenshotsCollectionView.bounds.height) // Silence temporary "item too tall" warning.
|
|
||||||
|
|
||||||
let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
|
||||||
layout.itemSize = size
|
|
||||||
}
|
|
||||||
|
|
||||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?)
|
|
||||||
{
|
|
||||||
guard segue.identifier == "showPermission" else { return }
|
|
||||||
|
|
||||||
guard let cell = sender as? UICollectionViewCell, let indexPath = self.permissionsCollectionView.indexPath(for: cell) else { return }
|
|
||||||
|
|
||||||
let permission = self.permissionsDataSource.item(at: indexPath)
|
|
||||||
|
|
||||||
let maximumWidth = self.view.bounds.width - 20
|
|
||||||
|
|
||||||
let permissionPopoverViewController = segue.destination as! PermissionPopoverViewController
|
|
||||||
permissionPopoverViewController.permission = permission
|
|
||||||
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
|
|
||||||
{
|
|
||||||
func makeScreenshotsDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>
|
|
||||||
{
|
|
||||||
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>(items: self.app.screenshotURLs as [NSURL])
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func makePermissionsDataSource() -> RSTArrayCollectionViewDataSource<AppPermission>
|
|
||||||
{
|
|
||||||
let dataSource = RSTArrayCollectionViewDataSource(items: self.app.permissions)
|
|
||||||
dataSource.cellConfigurationHandler = { (cell, permission, indexPath) in
|
|
||||||
let cell = cell as! PermissionCollectionViewCell
|
|
||||||
cell.button.setImage(permission.type.icon, for: .normal)
|
|
||||||
cell.textLabel.text = permission.type.localizedShortName
|
|
||||||
}
|
|
||||||
|
|
||||||
return dataSource
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension AppContentViewController
|
|
||||||
{
|
|
||||||
@objc func toggleCollapsingSection(_ sender: UIButton)
|
|
||||||
{
|
|
||||||
let indexPath: IndexPath
|
|
||||||
|
|
||||||
switch sender
|
|
||||||
{
|
|
||||||
case self.descriptionTextView.moreButton: indexPath = IndexPath(row: Row.description.rawValue, section: 0)
|
|
||||||
case self.versionDescriptionTextView.moreButton: indexPath = IndexPath(row: Row.versionDescription.rawValue, section: 0)
|
|
||||||
default: return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disable animations to prevent some potentially strange ones.
|
|
||||||
UIView.performWithoutAnimation {
|
|
||||||
self.tableView.reloadRows(at: [indexPath], with: .none)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppContentViewController
|
|
||||||
{
|
|
||||||
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath)
|
|
||||||
{
|
|
||||||
cell.tintColor = self.app.tintColor
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat
|
|
||||||
{
|
|
||||||
guard indexPath.row == Row.screenshots.rawValue else { return super.tableView(tableView, heightForRowAt: indexPath) }
|
|
||||||
|
|
||||||
guard let size = self.preferredScreenshotSize else { return 0.0 }
|
|
||||||
return size.height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppContentViewController: UIPopoverPresentationControllerDelegate
|
|
||||||
{
|
|
||||||
func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle
|
|
||||||
{
|
|
||||||
return .none
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,534 +0,0 @@
|
|||||||
//
|
|
||||||
// AppViewController.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/22/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
import Nuke
|
|
||||||
|
|
||||||
class AppViewController: UIViewController
|
|
||||||
{
|
|
||||||
var app: StoreApp!
|
|
||||||
|
|
||||||
private var contentViewController: AppContentViewController!
|
|
||||||
private var contentViewControllerShadowView: UIView!
|
|
||||||
|
|
||||||
private var blurAnimator: UIViewPropertyAnimator?
|
|
||||||
private var navigationBarAnimator: UIViewPropertyAnimator?
|
|
||||||
|
|
||||||
private var contentSizeObservation: NSKeyValueObservation?
|
|
||||||
|
|
||||||
@IBOutlet private var scrollView: UIScrollView!
|
|
||||||
@IBOutlet private var contentView: UIView!
|
|
||||||
|
|
||||||
@IBOutlet private var headerView: UIView!
|
|
||||||
@IBOutlet private var headerContentView: UIView!
|
|
||||||
|
|
||||||
@IBOutlet private var backButton: UIButton!
|
|
||||||
@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 backgroundBlurView: UIVisualEffectView!
|
|
||||||
|
|
||||||
@IBOutlet private var navigationBarTitleView: UIView!
|
|
||||||
@IBOutlet private var navigationBarDownloadButton: PillButton!
|
|
||||||
@IBOutlet private var navigationBarAppIconImageView: UIImageView!
|
|
||||||
@IBOutlet private var navigationBarAppNameLabel: UILabel!
|
|
||||||
|
|
||||||
private var _shouldResetLayout = false
|
|
||||||
private var _backgroundBlurEffect: UIBlurEffect?
|
|
||||||
private var _backgroundBlurTintColor: UIColor?
|
|
||||||
|
|
||||||
override func viewDidLoad()
|
|
||||||
{
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
self.navigationBarTitleView.sizeToFit()
|
|
||||||
self.navigationItem.titleView = self.navigationBarTitleView
|
|
||||||
|
|
||||||
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.contentViewController.view.superview?.insertSubview(self.contentViewControllerShadowView, at: 0)
|
|
||||||
|
|
||||||
self.contentView.addGestureRecognizer(self.scrollView.panGestureRecognizer)
|
|
||||||
|
|
||||||
self.contentViewController.view.layer.cornerRadius = 38
|
|
||||||
self.contentViewController.view.layer.masksToBounds = true
|
|
||||||
|
|
||||||
self.contentViewController.tableView.panGestureRecognizer.require(toFail: self.scrollView.panGestureRecognizer)
|
|
||||||
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.
|
|
||||||
self.view.bringSubviewToFront(self.scrollView)
|
|
||||||
self.scrollView.isUserInteractionEnabled = false
|
|
||||||
|
|
||||||
self.nameLabel.text = self.app.name
|
|
||||||
self.developerLabel.text = self.app.developerName
|
|
||||||
self.developerLabel.textColor = self.app.tintColor
|
|
||||||
self.appIconImageView.image = nil
|
|
||||||
self.appIconImageView.tintColor = self.app.tintColor
|
|
||||||
self.downloadButton.tintColor = self.app.tintColor
|
|
||||||
self.betaBadgeView.isHidden = !self.app.isBeta
|
|
||||||
|
|
||||||
self.backButtonContainerView.tintColor = self.app.tintColor
|
|
||||||
|
|
||||||
self.navigationController?.navigationBar.tintColor = self.app.tintColor
|
|
||||||
self.navigationBarDownloadButton.tintColor = self.app.tintColor
|
|
||||||
self.navigationBarAppNameLabel.text = self.app.name
|
|
||||||
self.navigationBarAppIconImageView.tintColor = self.app.tintColor
|
|
||||||
|
|
||||||
self.contentSizeObservation = self.contentViewController.tableView.observe(\.contentSize) { [weak self] (tableView, change) in
|
|
||||||
self?.view.setNeedsLayout()
|
|
||||||
self?.view.layoutIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
self._backgroundBlurEffect = self.backgroundBlurView.effect as? UIBlurEffect
|
|
||||||
self._backgroundBlurTintColor = self.backgroundBlurView.contentView.backgroundColor
|
|
||||||
|
|
||||||
// Load Images
|
|
||||||
for imageView in [self.appIconImageView!, self.backgroundAppIconImageView!, self.navigationBarAppIconImageView!]
|
|
||||||
{
|
|
||||||
imageView.isIndicatingActivity = true
|
|
||||||
|
|
||||||
Nuke.loadImage(with: self.app.iconURL, options: .shared, into: imageView, progress: nil) { [weak imageView] (response, error) in
|
|
||||||
if response?.image != nil
|
|
||||||
{
|
|
||||||
imageView?.isIndicatingActivity = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool)
|
|
||||||
{
|
|
||||||
super.viewWillAppear(animated)
|
|
||||||
|
|
||||||
self.prepareBlur()
|
|
||||||
|
|
||||||
// Update blur immediately.
|
|
||||||
self.view.setNeedsLayout()
|
|
||||||
self.view.layoutIfNeeded()
|
|
||||||
|
|
||||||
self.transitionCoordinator?.animate(alongsideTransition: { (context) in
|
|
||||||
self.hideNavigationBar()
|
|
||||||
}, completion: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool)
|
|
||||||
{
|
|
||||||
super.viewDidAppear(animated)
|
|
||||||
|
|
||||||
self._shouldResetLayout = true
|
|
||||||
self.view.setNeedsLayout()
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
super.viewDidDisappear(animated)
|
|
||||||
|
|
||||||
if self.navigationController == nil
|
|
||||||
{
|
|
||||||
self.resetNavigationBarAnimation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?)
|
|
||||||
{
|
|
||||||
guard segue.identifier == "embedAppContentViewController" else { return }
|
|
||||||
|
|
||||||
self.contentViewController = segue.destination as? AppContentViewController
|
|
||||||
self.contentViewController.app = self.app
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayoutSubviews()
|
|
||||||
{
|
|
||||||
super.viewDidLayoutSubviews()
|
|
||||||
|
|
||||||
if self._shouldResetLayout
|
|
||||||
{
|
|
||||||
// Various events can cause UI to mess up, so reset affected components now.
|
|
||||||
|
|
||||||
if self.navigationController?.topViewController == self
|
|
||||||
{
|
|
||||||
self.hideNavigationBar()
|
|
||||||
}
|
|
||||||
|
|
||||||
self.prepareBlur()
|
|
||||||
|
|
||||||
// Reset navigation bar animation, and create a new one later in this method if necessary.
|
|
||||||
self.resetNavigationBarAnimation()
|
|
||||||
|
|
||||||
self._shouldResetLayout = false
|
|
||||||
}
|
|
||||||
|
|
||||||
let statusBarHeight = UIApplication.shared.statusBarFrame.height
|
|
||||||
let cornerRadius = self.contentViewControllerShadowView.layer.cornerRadius
|
|
||||||
|
|
||||||
let inset = 12 as CGFloat
|
|
||||||
let padding = 20 as CGFloat
|
|
||||||
|
|
||||||
let backButtonSize = self.backButton.sizeThatFits(CGSize(width: 1000, height: 1000))
|
|
||||||
var backButtonFrame = CGRect(x: inset, y: statusBarHeight,
|
|
||||||
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 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 minimumHeaderY = backButtonFrame.maxY + 8
|
|
||||||
|
|
||||||
let minimumContentY = minimumHeaderY + headerFrame.height + padding
|
|
||||||
let maximumContentY = self.view.bounds.width * 0.667
|
|
||||||
|
|
||||||
// A full blur is too much, so we reduce the visible blur by 0.3, resulting in 70% blur.
|
|
||||||
let minimumBlurFraction = 0.3 as CGFloat
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
let blurThreshold = 0 as CGFloat
|
|
||||||
if self.scrollView.contentOffset.y < blurThreshold
|
|
||||||
{
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Animate navigation bar.
|
|
||||||
let showNavigationBarThreshold = (maximumContentY - minimumContentY) + backButtonFrame.origin.y
|
|
||||||
if self.scrollView.contentOffset.y > showNavigationBarThreshold
|
|
||||||
{
|
|
||||||
if self.navigationBarAnimator == nil
|
|
||||||
{
|
|
||||||
self.prepareNavigationBarAnimation()
|
|
||||||
}
|
|
||||||
|
|
||||||
let difference = self.scrollView.contentOffset.y - showNavigationBarThreshold
|
|
||||||
let range = (headerFrame.height + padding) - (self.navigationController?.navigationBar.bounds.height ?? self.view.safeAreaInsets.top)
|
|
||||||
|
|
||||||
let fractionComplete = min(difference, range) / range
|
|
||||||
self.navigationBarAnimator?.fractionComplete = fractionComplete
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.resetNavigationBarAnimation()
|
|
||||||
}
|
|
||||||
|
|
||||||
let beginMovingBackButtonThreshold = (maximumContentY - minimumContentY)
|
|
||||||
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.tableView.contentOffset.y = difference
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Keep content table view's content offset at the top.
|
|
||||||
self.contentViewController.tableView.contentOffset.y = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.superview?.frame = contentFrame
|
|
||||||
self.headerView.frame = headerFrame
|
|
||||||
self.backgroundAppIconImageView.frame = backgroundIconFrame
|
|
||||||
self.backgroundBlurView.frame = backgroundIconFrame
|
|
||||||
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.backButtonContainerView.layer.cornerRadius = self.backButtonContainerView.bounds.midY
|
|
||||||
|
|
||||||
self.scrollView.scrollIndicatorInsets.top = statusBarHeight
|
|
||||||
|
|
||||||
// Adjust content offset + size.
|
|
||||||
let contentOffset = self.scrollView.contentOffset
|
|
||||||
|
|
||||||
var contentSize = self.contentViewController.tableView.contentSize
|
|
||||||
contentSize.height += maximumContentY
|
|
||||||
|
|
||||||
self.scrollView.contentSize = contentSize
|
|
||||||
self.scrollView.contentOffset = contentOffset
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit
|
|
||||||
{
|
|
||||||
self.blurAnimator?.stopAnimation(true)
|
|
||||||
self.navigationBarAnimator?.stopAnimation(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppViewController
|
|
||||||
{
|
|
||||||
class func makeAppViewController(app: StoreApp) -> AppViewController
|
|
||||||
{
|
|
||||||
let storyboard = UIStoryboard(name: "Main", bundle: nil)
|
|
||||||
|
|
||||||
let appViewController = storyboard.instantiateViewController(withIdentifier: "appViewController") as! AppViewController
|
|
||||||
appViewController.app = app
|
|
||||||
return appViewController
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension AppViewController
|
|
||||||
{
|
|
||||||
func update()
|
|
||||||
{
|
|
||||||
for button in [self.downloadButton!, self.navigationBarDownloadButton!]
|
|
||||||
{
|
|
||||||
button.tintColor = self.app.tintColor
|
|
||||||
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.downloadButton.countdownDate = self.app.versionDate
|
|
||||||
self.navigationBarDownloadButton.countdownDate = self.app.versionDate
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.downloadButton.countdownDate = nil
|
|
||||||
self.navigationBarDownloadButton.countdownDate = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let barButtonItem = self.navigationItem.rightBarButtonItem
|
|
||||||
self.navigationItem.rightBarButtonItem = nil
|
|
||||||
self.navigationItem.rightBarButtonItem = barButtonItem
|
|
||||||
}
|
|
||||||
|
|
||||||
func showNavigationBar(for navigationController: UINavigationController? = nil)
|
|
||||||
{
|
|
||||||
let navigationController = navigationController ?? self.navigationController
|
|
||||||
navigationController?.navigationBar.barStyle = .default
|
|
||||||
navigationController?.navigationBar.alpha = 1.0
|
|
||||||
navigationController?.navigationBar.barTintColor = .white
|
|
||||||
navigationController?.navigationBar.tintColor = .altPrimary
|
|
||||||
}
|
|
||||||
|
|
||||||
func hideNavigationBar(for navigationController: UINavigationController? = nil)
|
|
||||||
{
|
|
||||||
let navigationController = navigationController ?? self.navigationController
|
|
||||||
navigationController?.navigationBar.barStyle = .black
|
|
||||||
navigationController?.navigationBar.alpha = 0.0
|
|
||||||
navigationController?.navigationBar.barTintColor = .white
|
|
||||||
}
|
|
||||||
|
|
||||||
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 prepareNavigationBarAnimation()
|
|
||||||
{
|
|
||||||
self.resetNavigationBarAnimation()
|
|
||||||
|
|
||||||
self.navigationBarAnimator = UIViewPropertyAnimator(duration: 1.0, curve: .linear) { [weak self] in
|
|
||||||
self?.showNavigationBar()
|
|
||||||
self?.navigationController?.navigationBar.tintColor = self?.app.tintColor
|
|
||||||
self?.navigationController?.navigationBar.barTintColor = nil
|
|
||||||
self?.contentViewController.view.layer.cornerRadius = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
self.navigationBarAnimator?.startAnimation()
|
|
||||||
self.navigationBarAnimator?.pauseAnimation()
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
|
|
||||||
func resetNavigationBarAnimation()
|
|
||||||
{
|
|
||||||
self.navigationBarAnimator?.stopAnimation(true)
|
|
||||||
self.navigationBarAnimator = nil
|
|
||||||
|
|
||||||
self.hideNavigationBar()
|
|
||||||
self.navigationController?.navigationBar.barTintColor = .white
|
|
||||||
self.contentViewController.view.layer.cornerRadius = self.contentViewControllerShadowView.layer.cornerRadius
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppViewController
|
|
||||||
{
|
|
||||||
@IBAction func popViewController(_ sender: UIButton)
|
|
||||||
{
|
|
||||||
self.navigationController?.popViewController(animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@IBAction func performAppAction(_ sender: PillButton)
|
|
||||||
{
|
|
||||||
if let installedApp = self.app.installedApp
|
|
||||||
{
|
|
||||||
self.open(installedApp)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.downloadApp()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadApp()
|
|
||||||
{
|
|
||||||
guard self.app.installedApp == nil else { return }
|
|
||||||
|
|
||||||
let progress = AppManager.shared.install(self.app, presentingViewController: self) { (result) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
_ = try result.get()
|
|
||||||
}
|
|
||||||
catch OperationError.cancelled
|
|
||||||
{
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
|
|
||||||
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.downloadButton.progress = nil
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.downloadButton.progress = progress
|
|
||||||
}
|
|
||||||
|
|
||||||
func open(_ installedApp: InstalledApp)
|
|
||||||
{
|
|
||||||
UIApplication.shared.open(installedApp.openAppURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension AppViewController
|
|
||||||
{
|
|
||||||
@objc func didChangeApp(_ notification: Notification)
|
|
||||||
{
|
|
||||||
// Async so that AppManager.installationProgress(for:) is nil when we update.
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func willEnterForeground(_ notification: Notification)
|
|
||||||
{
|
|
||||||
guard let navigationController = self.navigationController, navigationController.topViewController == self else { return }
|
|
||||||
|
|
||||||
self._shouldResetLayout = true
|
|
||||||
self.view.setNeedsLayout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppViewController: UIScrollViewDelegate
|
|
||||||
{
|
|
||||||
func scrollViewDidScroll(_ scrollView: UIScrollView)
|
|
||||||
{
|
|
||||||
self.view.setNeedsLayout()
|
|
||||||
self.view.layoutIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,529 +0,0 @@
|
|||||||
//
|
|
||||||
// AppDelegate.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 5/9/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import UserNotifications
|
|
||||||
import AVFoundation
|
|
||||||
|
|
||||||
import AltSign
|
|
||||||
import AltKit
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
private enum RefreshError: LocalizedError
|
|
||||||
{
|
|
||||||
case noInstalledApps
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self
|
|
||||||
{
|
|
||||||
case .noInstalledApps: return NSLocalizedString("No installed apps to refresh.", comment: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension CFNotificationName
|
|
||||||
{
|
|
||||||
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 =
|
|
||||||
{ (center, observer, name, object, userInfo) in
|
|
||||||
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate, let name = name else { return }
|
|
||||||
appDelegate.receivedApplicationState(notification: name)
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppDelegate
|
|
||||||
{
|
|
||||||
static let openPatreonSettingsDeepLinkNotification = Notification.Name("openPatreonSettingsDeepLinkNotification")
|
|
||||||
}
|
|
||||||
|
|
||||||
@UIApplicationMain
|
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
||||||
|
|
||||||
var window: UIWindow?
|
|
||||||
|
|
||||||
private var runningApplications: Set<String>?
|
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool
|
|
||||||
{
|
|
||||||
self.setTintColor()
|
|
||||||
|
|
||||||
ServerManager.shared.startDiscovering()
|
|
||||||
|
|
||||||
UserDefaults.standard.registerDefaults()
|
|
||||||
|
|
||||||
if UserDefaults.standard.firstLaunch == nil
|
|
||||||
{
|
|
||||||
Keychain.shared.reset()
|
|
||||||
UserDefaults.standard.firstLaunch = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
UserDefaults.standard.preferredServerID = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.serverID) as? String
|
|
||||||
|
|
||||||
#if DEBUG || BETA
|
|
||||||
UserDefaults.standard.isDebugModeEnabled = true
|
|
||||||
#endif
|
|
||||||
|
|
||||||
self.prepareForBackgroundFetch()
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationDidEnterBackground(_ application: UIApplication)
|
|
||||||
{
|
|
||||||
ServerManager.shared.stopDiscovering()
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationWillEnterForeground(_ application: UIApplication)
|
|
||||||
{
|
|
||||||
AppManager.shared.update()
|
|
||||||
ServerManager.shared.startDiscovering()
|
|
||||||
|
|
||||||
PatreonAPI.shared.refreshPatreonAccount()
|
|
||||||
}
|
|
||||||
|
|
||||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) -> Bool
|
|
||||||
{
|
|
||||||
return self.open(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension AppDelegate
|
|
||||||
{
|
|
||||||
func setTintColor()
|
|
||||||
{
|
|
||||||
self.window?.tintColor = .altPrimary
|
|
||||||
}
|
|
||||||
|
|
||||||
func open(_ url: URL) -> Bool
|
|
||||||
{
|
|
||||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false }
|
|
||||||
guard let host = components.host, host.lowercased() == "patreon" else { return false }
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
NotificationCenter.default.post(name: AppDelegate.openPatreonSettingsDeepLinkNotification, object: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppDelegate
|
|
||||||
{
|
|
||||||
private func prepareForBackgroundFetch()
|
|
||||||
{
|
|
||||||
// "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)
|
|
||||||
|
|
||||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { (success, error) in
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
UIApplication.shared.registerForRemoteNotifications()
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)
|
|
||||||
{
|
|
||||||
let tokenParts = deviceToken.map { data -> String in
|
|
||||||
return String(format: "%02.2hhx", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
let token = tokenParts.joined()
|
|
||||||
print("Push Token:", token)
|
|
||||||
}
|
|
||||||
|
|
||||||
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void)
|
|
||||||
{
|
|
||||||
self.application(application, performFetchWithCompletionHandler: completionHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
func application(_ application: UIApplication, performFetchWithCompletionHandler backgroundFetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void)
|
|
||||||
{
|
|
||||||
if UserDefaults.standard.isBackgroundRefreshEnabled
|
|
||||||
{
|
|
||||||
ServerManager.shared.startDiscovering()
|
|
||||||
|
|
||||||
if !UserDefaults.standard.presentedLaunchReminderNotification
|
|
||||||
{
|
|
||||||
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 AltStore, 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
|
|
||||||
|
|
||||||
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
|
|
||||||
{
|
|
||||||
print("Error starting extended background task. Aborting.", error)
|
|
||||||
backgroundFetchCompletionHandler(.failed)
|
|
||||||
finish(.failure(error))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !DatabaseManager.shared.isStarted
|
|
||||||
{
|
|
||||||
DatabaseManager.shared.start() { (error) in
|
|
||||||
if let error = error
|
|
||||||
{
|
|
||||||
backgroundFetchCompletionHandler(.failed)
|
|
||||||
finish(.failure(error))
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.refreshApps(identifier: refreshIdentifier, backgroundFetchCompletionHandler: backgroundFetchCompletionHandler, completionHandler: finish(_:))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.refreshApps(identifier: refreshIdentifier, backgroundFetchCompletionHandler: backgroundFetchCompletionHandler, completionHandler: finish(_:))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension AppDelegate
|
|
||||||
{
|
|
||||||
func refreshApps(identifier: String,
|
|
||||||
backgroundFetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void,
|
|
||||||
completionHandler: @escaping (Result<[String: Result<InstalledApp, Error>], Error>) -> Void)
|
|
||||||
{
|
|
||||||
var fetchSourceResult: Result<Source, Error>?
|
|
||||||
var serversResult: Result<Void, Error>?
|
|
||||||
|
|
||||||
let dispatchGroup = DispatchGroup()
|
|
||||||
dispatchGroup.enter()
|
|
||||||
|
|
||||||
AppManager.shared.fetchSource() { (result) in
|
|
||||||
fetchSourceResult = result
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let source = try result.get()
|
|
||||||
|
|
||||||
guard let context = source.managedObjectContext else { return }
|
|
||||||
|
|
||||||
let previousUpdatesFetchRequest = InstalledApp.updatesFetchRequest() as! NSFetchRequest<NSFetchRequestResult>
|
|
||||||
previousUpdatesFetchRequest.includesPendingChanges = false
|
|
||||||
previousUpdatesFetchRequest.resultType = .dictionaryResultType
|
|
||||||
previousUpdatesFetchRequest.propertiesToFetch = [#keyPath(InstalledApp.bundleIdentifier)]
|
|
||||||
|
|
||||||
let previousNewsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NSFetchRequestResult>
|
|
||||||
previousNewsItemsFetchRequest.includesPendingChanges = false
|
|
||||||
previousNewsItemsFetchRequest.resultType = .dictionaryResultType
|
|
||||||
previousNewsItemsFetchRequest.propertiesToFetch = [#keyPath(NewsItem.identifier)]
|
|
||||||
|
|
||||||
let previousUpdates = try context.fetch(previousUpdatesFetchRequest) as! [[String: String]]
|
|
||||||
let previousNewsItems = try context.fetch(previousNewsItemsFetchRequest) as! [[String: String]]
|
|
||||||
|
|
||||||
try context.save()
|
|
||||||
|
|
||||||
let updatesFetchRequest = InstalledApp.updatesFetchRequest()
|
|
||||||
let newsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NewsItem>
|
|
||||||
|
|
||||||
let updates = try context.fetch(updatesFetchRequest)
|
|
||||||
let newsItems = try context.fetch(newsItemsFetchRequest)
|
|
||||||
|
|
||||||
for update in updates
|
|
||||||
{
|
|
||||||
guard !previousUpdates.contains(where: { $0[#keyPath(InstalledApp.bundleIdentifier)] == update.bundleIdentifier }) else { continue }
|
|
||||||
guard let storeApp = update.storeApp else { continue }
|
|
||||||
|
|
||||||
let content = UNMutableNotificationContent()
|
|
||||||
content.title = NSLocalizedString("New Update Available", comment: "")
|
|
||||||
content.body = String(format: NSLocalizedString("%@ %@ is now available for download.", comment: ""), update.name, storeApp.version)
|
|
||||||
content.sound = .default
|
|
||||||
|
|
||||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
|
||||||
UNUserNotificationCenter.current().add(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
for newsItem in newsItems
|
|
||||||
{
|
|
||||||
guard !previousNewsItems.contains(where: { $0[#keyPath(NewsItem.identifier)] == newsItem.identifier }) else { continue }
|
|
||||||
guard !newsItem.isSilent else { continue }
|
|
||||||
|
|
||||||
let content = UNMutableNotificationContent()
|
|
||||||
|
|
||||||
if let app = newsItem.storeApp
|
|
||||||
{
|
|
||||||
content.title = String(format: NSLocalizedString("%@ News", comment: ""), app.name)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
content.title = NSLocalizedString("AltStore News", comment: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
content.body = newsItem.title
|
|
||||||
content.sound = .default
|
|
||||||
|
|
||||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
|
||||||
UNUserNotificationCenter.current().add(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
UIApplication.shared.applicationIconBadgeNumber = updates.count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
print("Error fetching apps:", 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,161 +0,0 @@
|
|||||||
//
|
|
||||||
// AuthenticationViewController.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 9/5/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
import AltSign
|
|
||||||
|
|
||||||
class AuthenticationViewController: UIViewController
|
|
||||||
{
|
|
||||||
var authenticationHandler: (((ALTAccount, String)?) -> Void)?
|
|
||||||
|
|
||||||
private weak var toastView: ToastView?
|
|
||||||
|
|
||||||
@IBOutlet private var appleIDTextField: UITextField!
|
|
||||||
@IBOutlet private var passwordTextField: UITextField!
|
|
||||||
@IBOutlet private var signInButton: UIButton!
|
|
||||||
|
|
||||||
@IBOutlet private var appleIDBackgroundView: UIView!
|
|
||||||
@IBOutlet private var passwordBackgroundView: UIView!
|
|
||||||
|
|
||||||
@IBOutlet private var scrollView: UIScrollView!
|
|
||||||
@IBOutlet private var contentStackView: UIStackView!
|
|
||||||
|
|
||||||
override func viewDidLoad()
|
|
||||||
{
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
for view in [self.appleIDBackgroundView!, self.passwordBackgroundView!, self.signInButton!]
|
|
||||||
{
|
|
||||||
view.clipsToBounds = true
|
|
||||||
view.layer.cornerRadius = 16
|
|
||||||
}
|
|
||||||
|
|
||||||
if UIScreen.main.isExtraCompactHeight
|
|
||||||
{
|
|
||||||
self.contentStackView.spacing = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(AuthenticationViewController.textFieldDidChangeText(_:)), name: UITextField.textDidChangeNotification, object: self.appleIDTextField)
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(AuthenticationViewController.textFieldDidChangeText(_:)), name: UITextField.textDidChangeNotification, object: self.passwordTextField)
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidDisappear(_ animated: Bool)
|
|
||||||
{
|
|
||||||
super.viewDidDisappear(animated)
|
|
||||||
|
|
||||||
self.signInButton.isIndicatingActivity = false
|
|
||||||
self.toastView?.dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension AuthenticationViewController
|
|
||||||
{
|
|
||||||
func update()
|
|
||||||
{
|
|
||||||
if let _ = self.validate()
|
|
||||||
{
|
|
||||||
self.signInButton.isEnabled = true
|
|
||||||
self.signInButton.alpha = 1.0
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.signInButton.isEnabled = false
|
|
||||||
self.signInButton.alpha = 0.6
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validate() -> (String, String)?
|
|
||||||
{
|
|
||||||
guard
|
|
||||||
let emailAddress = self.appleIDTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines), !emailAddress.isEmpty,
|
|
||||||
let password = self.passwordTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines), !password.isEmpty
|
|
||||||
else { return nil }
|
|
||||||
|
|
||||||
return (emailAddress, password)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension AuthenticationViewController
|
|
||||||
{
|
|
||||||
@IBAction func authenticate()
|
|
||||||
{
|
|
||||||
guard let (emailAddress, password) = self.validate() else { return }
|
|
||||||
|
|
||||||
self.appleIDTextField.resignFirstResponder()
|
|
||||||
self.passwordTextField.resignFirstResponder()
|
|
||||||
|
|
||||||
self.signInButton.isIndicatingActivity = true
|
|
||||||
|
|
||||||
ALTAppleAPI.shared.authenticate(appleID: emailAddress, password: password) { (account, error) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let account = try Result(account, error).get()
|
|
||||||
self.authenticationHandler?((account, password))
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
let toastView = ToastView(text: NSLocalizedString("Failed to Log In", comment: ""), detailText: error.localizedDescription)
|
|
||||||
toastView.textLabel.textColor = .altPink
|
|
||||||
toastView.detailTextLabel.textColor = .altPink
|
|
||||||
toastView.show(in: self.navigationController?.view ?? self.view)
|
|
||||||
self.toastView = toastView
|
|
||||||
|
|
||||||
self.signInButton.isIndicatingActivity = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.scrollView.setContentOffset(CGPoint(x: 0, y: -self.view.safeAreaInsets.top), animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@IBAction func cancel(_ sender: UIBarButtonItem)
|
|
||||||
{
|
|
||||||
self.authenticationHandler?(nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AuthenticationViewController: UITextFieldDelegate
|
|
||||||
{
|
|
||||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool
|
|
||||||
{
|
|
||||||
switch textField
|
|
||||||
{
|
|
||||||
case self.appleIDTextField: self.passwordTextField.becomeFirstResponder()
|
|
||||||
case self.passwordTextField: self.authenticate()
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func textFieldDidBeginEditing(_ textField: UITextField)
|
|
||||||
{
|
|
||||||
guard UIScreen.main.isExtraCompactHeight else { return }
|
|
||||||
|
|
||||||
// Position all the controls within visible frame.
|
|
||||||
var contentOffset = self.scrollView.contentOffset
|
|
||||||
contentOffset.y = 44
|
|
||||||
self.scrollView.setContentOffset(contentOffset, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AuthenticationViewController
|
|
||||||
{
|
|
||||||
@objc func textFieldDidChangeText(_ notification: Notification)
|
|
||||||
{
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
//
|
|
||||||
// InstructionsViewController.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 9/6/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class InstructionsViewController: UIViewController
|
|
||||||
{
|
|
||||||
var completionHandler: (() -> Void)?
|
|
||||||
|
|
||||||
var showsBottomButton: Bool = false
|
|
||||||
|
|
||||||
@IBOutlet private var contentStackView: UIStackView!
|
|
||||||
@IBOutlet private var dismissButton: UIButton!
|
|
||||||
|
|
||||||
override func viewDidLoad()
|
|
||||||
{
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
if UIScreen.main.isExtraCompactHeight
|
|
||||||
{
|
|
||||||
self.contentStackView.layoutMargins.top = 0
|
|
||||||
self.contentStackView.layoutMargins.bottom = self.contentStackView.layoutMargins.left
|
|
||||||
}
|
|
||||||
|
|
||||||
self.dismissButton.clipsToBounds = true
|
|
||||||
self.dismissButton.layer.cornerRadius = 16
|
|
||||||
|
|
||||||
if self.showsBottomButton
|
|
||||||
{
|
|
||||||
self.navigationItem.hidesBackButton = true
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.dismissButton.isHidden = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension InstructionsViewController
|
|
||||||
{
|
|
||||||
@IBAction func dismiss()
|
|
||||||
{
|
|
||||||
self.completionHandler?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
//
|
|
||||||
// BrowseViewController.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/15/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
import Nuke
|
|
||||||
|
|
||||||
class BrowseViewController: UICollectionViewController
|
|
||||||
{
|
|
||||||
private lazy var dataSource = self.makeDataSource()
|
|
||||||
private lazy var placeholderView = RSTPlaceholderView(frame: .zero)
|
|
||||||
|
|
||||||
private let prototypeCell = BrowseCollectionViewCell.instantiate(with: BrowseCollectionViewCell.nib!)!
|
|
||||||
|
|
||||||
private var loadingState: LoadingState = .loading {
|
|
||||||
didSet {
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var cachedItemSizes = [String: CGSize]()
|
|
||||||
|
|
||||||
override func viewDidLoad()
|
|
||||||
{
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
self.prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
|
|
||||||
self.collectionView.register(BrowseCollectionViewCell.nib, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
|
||||||
|
|
||||||
self.collectionView.dataSource = self.dataSource
|
|
||||||
self.collectionView.prefetchDataSource = self.dataSource
|
|
||||||
|
|
||||||
self.registerForPreviewing(with: self, sourceView: self.collectionView)
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool)
|
|
||||||
{
|
|
||||||
super.viewWillAppear(animated)
|
|
||||||
|
|
||||||
self.fetchSource()
|
|
||||||
self.updateDataSource()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension BrowseViewController
|
|
||||||
{
|
|
||||||
func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>
|
|
||||||
{
|
|
||||||
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
|
|
||||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp.sortIndex, ascending: true), NSSortDescriptor(keyPath: \StoreApp.name, ascending: true)]
|
|
||||||
fetchRequest.returnsObjectsAsFaults = false
|
|
||||||
|
|
||||||
if let source = Source.fetchAltStoreSource(in: DatabaseManager.shared.viewContext)
|
|
||||||
{
|
|
||||||
fetchRequest.predicate = NSPredicate(format: "%K != %@ AND %K == %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID, #keyPath(StoreApp.source), source)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
fetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID)
|
|
||||||
}
|
|
||||||
|
|
||||||
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
|
||||||
dataSource.cellConfigurationHandler = { (cell, app, indexPath) in
|
|
||||||
let cell = cell as! BrowseCollectionViewCell
|
|
||||||
cell.nameLabel.text = app.name
|
|
||||||
cell.developerLabel.text = app.developerName
|
|
||||||
cell.subtitleLabel.text = app.subtitle
|
|
||||||
cell.imageURLs = Array(app.screenshotURLs.prefix(2))
|
|
||||||
cell.appIconImageView.image = nil
|
|
||||||
cell.appIconImageView.isIndicatingActivity = true
|
|
||||||
cell.betaBadgeView.isHidden = !app.isBeta
|
|
||||||
|
|
||||||
cell.actionButton.addTarget(self, action: #selector(BrowseViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
|
||||||
cell.actionButton.activityIndicatorView.style = .white
|
|
||||||
|
|
||||||
// Explicitly set to false to ensure we're starting from a non-activity indicating state.
|
|
||||||
// Otherwise, cell reuse can mess up some cached values.
|
|
||||||
cell.actionButton.isIndicatingActivity = false
|
|
||||||
|
|
||||||
let tintColor = app.tintColor ?? .altPrimary
|
|
||||||
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
|
|
||||||
let iconURL = storeApp.iconURL
|
|
||||||
|
|
||||||
return RSTAsyncBlockOperation() { (operation) in
|
|
||||||
ImagePipeline.shared.loadImage(with: iconURL, 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! BrowseCollectionViewCell
|
|
||||||
cell.appIconImageView.isIndicatingActivity = false
|
|
||||||
cell.appIconImageView.image = image
|
|
||||||
|
|
||||||
if let error = error
|
|
||||||
{
|
|
||||||
print("Error loading image:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dataSource.placeholderView = self.placeholderView
|
|
||||||
|
|
||||||
return dataSource
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateDataSource()
|
|
||||||
{
|
|
||||||
if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated
|
|
||||||
{
|
|
||||||
self.dataSource.predicate = nil
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.dataSource.predicate = NSPredicate(format: "%K == NO", #keyPath(StoreApp.isBeta))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchSource()
|
|
||||||
{
|
|
||||||
self.loadingState = .loading
|
|
||||||
|
|
||||||
AppManager.shared.fetchSource() { (result) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let source = try result.get()
|
|
||||||
try source.managedObjectContext?.save()
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.loadingState = .finished(.success(()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if self.dataSource.itemCount > 0
|
|
||||||
{
|
|
||||||
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
|
|
||||||
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.loadingState = .finished(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func update()
|
|
||||||
{
|
|
||||||
switch self.loadingState
|
|
||||||
{
|
|
||||||
case .loading:
|
|
||||||
self.placeholderView.textLabel.isHidden = true
|
|
||||||
self.placeholderView.detailTextLabel.isHidden = false
|
|
||||||
|
|
||||||
self.placeholderView.detailTextLabel.text = NSLocalizedString("Loading...", comment: "")
|
|
||||||
|
|
||||||
self.placeholderView.activityIndicatorView.startAnimating()
|
|
||||||
|
|
||||||
case .finished(.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 .finished(.success):
|
|
||||||
self.placeholderView.textLabel.isHidden = true
|
|
||||||
self.placeholderView.detailTextLabel.isHidden = true
|
|
||||||
|
|
||||||
self.placeholderView.activityIndicatorView.stopAnimating()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension BrowseViewController
|
|
||||||
{
|
|
||||||
@IBAction 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 app = self.dataSource.item(at: indexPath)
|
|
||||||
|
|
||||||
if let installedApp = app.installedApp
|
|
||||||
{
|
|
||||||
self.open(installedApp)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.install(app, at: indexPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func install(_ app: StoreApp, at indexPath: IndexPath)
|
|
||||||
{
|
|
||||||
let previousProgress = AppManager.shared.installationProgress(for: app)
|
|
||||||
guard previousProgress == nil else {
|
|
||||||
previousProgress?.cancel()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = AppManager.shared.install(app, presentingViewController: self) { (result) in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(OperationError.cancelled): break // Ignore
|
|
||||||
case .failure(let error):
|
|
||||||
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
|
|
||||||
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2)
|
|
||||||
|
|
||||||
case .success: print("Installed app:", app.bundleIdentifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.collectionView.reloadItems(at: [indexPath])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.collectionView.reloadItems(at: [indexPath])
|
|
||||||
}
|
|
||||||
|
|
||||||
func open(_ installedApp: InstalledApp)
|
|
||||||
{
|
|
||||||
UIApplication.shared.open(installedApp.openAppURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension BrowseViewController: UICollectionViewDelegateFlowLayout
|
|
||||||
{
|
|
||||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
|
|
||||||
{
|
|
||||||
let item = self.dataSource.item(at: indexPath)
|
|
||||||
|
|
||||||
if let previousSize = self.cachedItemSizes[item.bundleIdentifier]
|
|
||||||
{
|
|
||||||
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)
|
|
||||||
|
|
||||||
let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width)
|
|
||||||
widthConstraint.isActive = true
|
|
||||||
defer { widthConstraint.isActive = false }
|
|
||||||
|
|
||||||
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)
|
|
||||||
self.cachedItemSizes[item.bundleIdentifier] = itemSize
|
|
||||||
return itemSize
|
|
||||||
}
|
|
||||||
|
|
||||||
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
|
||||||
{
|
|
||||||
let app = self.dataSource.item(at: indexPath)
|
|
||||||
|
|
||||||
let appViewController = AppViewController.makeAppViewController(app: app)
|
|
||||||
self.navigationController?.pushViewController(appViewController, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension BrowseViewController: UIViewControllerPreviewingDelegate
|
|
||||||
{
|
|
||||||
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController?
|
|
||||||
{
|
|
||||||
guard
|
|
||||||
let indexPath = self.collectionView.indexPathForItem(at: location),
|
|
||||||
let cell = self.collectionView.cellForItem(at: indexPath)
|
|
||||||
else { return nil }
|
|
||||||
|
|
||||||
previewingContext.sourceRect = cell.frame
|
|
||||||
|
|
||||||
let app = self.dataSource.item(at: indexPath)
|
|
||||||
|
|
||||||
let appViewController = AppViewController.makeAppViewController(app: app)
|
|
||||||
return appViewController
|
|
||||||
}
|
|
||||||
|
|
||||||
func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController)
|
|
||||||
{
|
|
||||||
self.navigationController?.pushViewController(viewControllerToCommit, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
//
|
|
||||||
// ScreenshotCollectionViewCell.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/15/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
@objc(ScreenshotCollectionViewCell)
|
|
||||||
class ScreenshotCollectionViewCell: UICollectionViewCell
|
|
||||||
{
|
|
||||||
let imageView = UIImageView(image: nil)
|
|
||||||
|
|
||||||
override init(frame: CGRect)
|
|
||||||
{
|
|
||||||
super.init(frame: frame)
|
|
||||||
|
|
||||||
self.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder aDecoder: NSCoder)
|
|
||||||
{
|
|
||||||
super.init(coder: aDecoder)
|
|
||||||
|
|
||||||
self.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func initialize()
|
|
||||||
{
|
|
||||||
self.imageView.layer.masksToBounds = true
|
|
||||||
self.addSubview(self.imageView, pinningEdgesWith: .zero)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutSubviews()
|
|
||||||
{
|
|
||||||
super.layoutSubviews()
|
|
||||||
|
|
||||||
self.imageView.layer.cornerRadius = 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
//
|
|
||||||
// AppBannerView.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 8/29/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
class AppBannerView: RSTNibView
|
|
||||||
{
|
|
||||||
@IBOutlet var titleLabel: UILabel!
|
|
||||||
@IBOutlet var subtitleLabel: UILabel!
|
|
||||||
@IBOutlet var iconImageView: AppIconImageView!
|
|
||||||
@IBOutlet var button: PillButton!
|
|
||||||
@IBOutlet var betaBadgeView: UIView!
|
|
||||||
|
|
||||||
override func tintColorDidChange()
|
|
||||||
{
|
|
||||||
super.tintColorDidChange()
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension AppBannerView
|
|
||||||
{
|
|
||||||
func update()
|
|
||||||
{
|
|
||||||
self.clipsToBounds = true
|
|
||||||
self.layer.cornerRadius = 22
|
|
||||||
|
|
||||||
self.subtitleLabel.textColor = self.tintColor
|
|
||||||
self.button.tintColor = self.tintColor
|
|
||||||
|
|
||||||
self.backgroundColor = self.tintColor.withAlphaComponent(0.1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +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="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
|
||||||
</dependencies>
|
|
||||||
<objects>
|
|
||||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="AppBannerView" customModule="AltStore" customModuleProvider="target">
|
|
||||||
<connections>
|
|
||||||
<outlet property="betaBadgeView" destination="qQl-Ez-zC5" id="6O1-Cx-7qz"/>
|
|
||||||
<outlet property="button" destination="tVx-3G-dcu" id="joa-AH-syX"/>
|
|
||||||
<outlet property="iconImageView" destination="avS-dx-4iy" id="TQs-Ej-gin"/>
|
|
||||||
<outlet property="subtitleLabel" destination="oN5-vu-Dnw" id="gA4-iJ-Tix"/>
|
|
||||||
<outlet property="titleLabel" destination="mFe-zJ-eva" id="2OH-f8-cid"/>
|
|
||||||
</connections>
|
|
||||||
</placeholder>
|
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
|
||||||
<view opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="FxI-Fh-ll5">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="88"/>
|
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
|
||||||
<subviews>
|
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" 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"/>
|
|
||||||
<subviews>
|
|
||||||
<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"/>
|
|
||||||
<constraints>
|
|
||||||
<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"/>
|
|
||||||
</constraints>
|
|
||||||
</imageView>
|
|
||||||
<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"/>
|
|
||||||
<subviews>
|
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" spacing="6" translatesAutoresizingMaskIntoConstraints="NO" id="1Es-pv-zwd">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="135" height="21.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="mFe-zJ-eva">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="88" height="21.5"/>
|
|
||||||
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="18"/>
|
|
||||||
<nil key="textColor"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
<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"/>
|
|
||||||
</imageView>
|
|
||||||
</subviews>
|
|
||||||
</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">
|
|
||||||
<rect key="frame" x="0.0" y="23.5" width="66" height="17"/>
|
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
|
||||||
<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="tVx-3G-dcu" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
|
|
||||||
<rect key="frame" x="291" y="28.5" 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="height" constant="31" id="Zwh-yQ-GTu"/>
|
|
||||||
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="72" id="eGc-Dk-QbL"/>
|
|
||||||
</constraints>
|
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
|
|
||||||
<state key="normal" title="FREE"/>
|
|
||||||
</button>
|
|
||||||
</subviews>
|
|
||||||
<edgeInsets key="layoutMargins" top="14" left="14" bottom="14" right="12"/>
|
|
||||||
</stackView>
|
|
||||||
</subviews>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="bottom" secondItem="d1T-UD-gWG" secondAttribute="bottom" id="B9e-Mf-cy5"/>
|
|
||||||
<constraint firstAttribute="trailing" secondItem="d1T-UD-gWG" secondAttribute="trailing" id="HcT-2k-z0H"/>
|
|
||||||
<constraint firstItem="d1T-UD-gWG" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="PIM-W5-dkh"/>
|
|
||||||
<constraint firstItem="d1T-UD-gWG" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="RHn-ZK-jgl"/>
|
|
||||||
</constraints>
|
|
||||||
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
|
|
||||||
</view>
|
|
||||||
</objects>
|
|
||||||
<resources>
|
|
||||||
<image name="BetaBadge" width="41" height="17"/>
|
|
||||||
</resources>
|
|
||||||
</document>
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
//
|
|
||||||
// AppIconImageView.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 5/9/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class AppIconImageView: UIImageView
|
|
||||||
{
|
|
||||||
override func awakeFromNib()
|
|
||||||
{
|
|
||||||
super.awakeFromNib()
|
|
||||||
|
|
||||||
self.contentMode = .scaleAspectFill
|
|
||||||
self.clipsToBounds = true
|
|
||||||
|
|
||||||
self.backgroundColor = .white
|
|
||||||
|
|
||||||
self.layer.borderWidth = 0.5
|
|
||||||
self.layer.borderColor = self.tintColor.cgColor
|
|
||||||
|
|
||||||
// Allows us to match system look for app icons.
|
|
||||||
if self.layer.responds(to: Selector(("continuousCorners")))
|
|
||||||
{
|
|
||||||
self.layer.setValue(true, forKey: "continuousCorners")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutSubviews()
|
|
||||||
{
|
|
||||||
super.layoutSubviews()
|
|
||||||
|
|
||||||
// Based off of 60pt icon having 12pt radius.
|
|
||||||
let radius = self.bounds.height / 5
|
|
||||||
self.layer.cornerRadius = radius
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tintColorDidChange()
|
|
||||||
{
|
|
||||||
super.tintColorDidChange()
|
|
||||||
|
|
||||||
self.layer.borderColor = self.tintColor.cgColor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
//
|
|
||||||
// CollapsingTextView.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/23/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class CollapsingTextView: UITextView
|
|
||||||
{
|
|
||||||
var isCollapsed = true {
|
|
||||||
didSet {
|
|
||||||
self.setNeedsLayout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var maximumNumberOfLines = 2 {
|
|
||||||
didSet {
|
|
||||||
self.setNeedsLayout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var lineSpacing: CGFloat = 2 {
|
|
||||||
didSet {
|
|
||||||
self.setNeedsLayout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let moreButton = UIButton(type: .system)
|
|
||||||
|
|
||||||
override func awakeFromNib()
|
|
||||||
{
|
|
||||||
super.awakeFromNib()
|
|
||||||
|
|
||||||
self.layoutManager.delegate = self
|
|
||||||
|
|
||||||
self.textContainerInset = .zero
|
|
||||||
self.textContainer.lineFragmentPadding = 0
|
|
||||||
self.textContainer.lineBreakMode = .byTruncatingTail
|
|
||||||
self.textContainer.heightTracksTextView = true
|
|
||||||
self.textContainer.widthTracksTextView = true
|
|
||||||
|
|
||||||
self.moreButton.setTitle(NSLocalizedString("More", comment: ""), for: .normal)
|
|
||||||
self.moreButton.addTarget(self, action: #selector(CollapsingTextView.toggleCollapsed(_:)), for: .primaryActionTriggered)
|
|
||||||
self.addSubview(self.moreButton)
|
|
||||||
|
|
||||||
self.setNeedsLayout()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutSubviews()
|
|
||||||
{
|
|
||||||
super.layoutSubviews()
|
|
||||||
|
|
||||||
guard let font = self.font else { return }
|
|
||||||
|
|
||||||
let buttonFont = UIFont.systemFont(ofSize: font.pointSize, weight: .medium)
|
|
||||||
self.moreButton.titleLabel?.font = buttonFont
|
|
||||||
|
|
||||||
let buttonY = (font.lineHeight + self.lineSpacing) * CGFloat(self.maximumNumberOfLines - 1)
|
|
||||||
let size = self.moreButton.sizeThatFits(CGSize(width: 1000, height: 1000))
|
|
||||||
|
|
||||||
let moreButtonFrame = CGRect(x: self.bounds.width - self.moreButton.bounds.width,
|
|
||||||
y: buttonY,
|
|
||||||
width: size.width,
|
|
||||||
height: font.lineHeight)
|
|
||||||
self.moreButton.frame = moreButtonFrame
|
|
||||||
|
|
||||||
if self.isCollapsed
|
|
||||||
{
|
|
||||||
self.textContainer.maximumNumberOfLines = self.maximumNumberOfLines
|
|
||||||
|
|
||||||
let maximumCollapsedHeight = font.lineHeight * CGFloat(self.maximumNumberOfLines)
|
|
||||||
if self.intrinsicContentSize.height > maximumCollapsedHeight
|
|
||||||
{
|
|
||||||
var exclusionFrame = moreButtonFrame
|
|
||||||
exclusionFrame.origin.y += self.moreButton.bounds.midY
|
|
||||||
exclusionFrame.size.width = self.bounds.width // Extra wide to make sure it wraps to next line.
|
|
||||||
self.textContainer.exclusionPaths = [UIBezierPath(rect: exclusionFrame)]
|
|
||||||
|
|
||||||
self.moreButton.isHidden = false
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.textContainer.exclusionPaths = []
|
|
||||||
|
|
||||||
self.moreButton.isHidden = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.textContainer.maximumNumberOfLines = 0
|
|
||||||
self.textContainer.exclusionPaths = []
|
|
||||||
|
|
||||||
self.moreButton.isHidden = true
|
|
||||||
}
|
|
||||||
|
|
||||||
self.invalidateIntrinsicContentSize()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension CollapsingTextView
|
|
||||||
{
|
|
||||||
@objc func toggleCollapsed(_ sender: UIButton)
|
|
||||||
{
|
|
||||||
self.isCollapsed.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension CollapsingTextView: NSLayoutManagerDelegate
|
|
||||||
{
|
|
||||||
func layoutManager(_ layoutManager: NSLayoutManager, lineSpacingAfterGlyphAt glyphIndex: Int, withProposedLineFragmentRect rect: CGRect) -> CGFloat
|
|
||||||
{
|
|
||||||
return self.lineSpacing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
//
|
|
||||||
// NavigationBar.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/15/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
class NavigationBar: UINavigationBar
|
|
||||||
{
|
|
||||||
@IBInspectable var automaticallyAdjustsItemPositions: Bool = true
|
|
||||||
|
|
||||||
private let backgroundColorView = UIView()
|
|
||||||
|
|
||||||
override init(frame: CGRect)
|
|
||||||
{
|
|
||||||
super.init(frame: frame)
|
|
||||||
|
|
||||||
self.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder aDecoder: NSCoder)
|
|
||||||
{
|
|
||||||
super.init(coder: aDecoder)
|
|
||||||
|
|
||||||
self.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func initialize()
|
|
||||||
{
|
|
||||||
self.shadowImage = UIImage()
|
|
||||||
|
|
||||||
if let tintColor = self.barTintColor
|
|
||||||
{
|
|
||||||
self.backgroundColorView.backgroundColor = tintColor
|
|
||||||
|
|
||||||
// Top = -50 to cover status bar area above navigation bar on any device.
|
|
||||||
// Bottom = -1 to prevent a flickering gray line from appearing.
|
|
||||||
self.addSubview(self.backgroundColorView, pinningEdgesWith: UIEdgeInsets(top: -50, left: 0, bottom: -1, right: 0))
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.barTintColor = .white
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutSubviews()
|
|
||||||
{
|
|
||||||
super.layoutSubviews()
|
|
||||||
|
|
||||||
if self.backgroundColorView.superview != nil
|
|
||||||
{
|
|
||||||
self.insertSubview(self.backgroundColorView, at: 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.automaticallyAdjustsItemPositions
|
|
||||||
{
|
|
||||||
// We can't easily shift just the back button up, so we shift the entire content view slightly.
|
|
||||||
for contentView in self.subviews
|
|
||||||
{
|
|
||||||
guard NSStringFromClass(type(of: contentView)).contains("ContentView") else { continue }
|
|
||||||
contentView.center.y -= 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
//
|
|
||||||
// PillButton.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/15/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class PillButton: UIButton
|
|
||||||
{
|
|
||||||
var progress: Progress? {
|
|
||||||
didSet {
|
|
||||||
self.progressView.progress = Float(self.progress?.fractionCompleted ?? 0)
|
|
||||||
self.progressView.observedProgress = self.progress
|
|
||||||
|
|
||||||
let isUserInteractionEnabled = self.isUserInteractionEnabled
|
|
||||||
self.isIndicatingActivity = (self.progress != nil)
|
|
||||||
self.isUserInteractionEnabled = isUserInteractionEnabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var progressTintColor: UIColor? {
|
|
||||||
get {
|
|
||||||
return self.progressView.progressTintColor
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
self.progressView.progressTintColor = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isInverted: Bool = false {
|
|
||||||
didSet {
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var countdownDate: Date? {
|
|
||||||
didSet {
|
|
||||||
self.isEnabled = (self.countdownDate == nil)
|
|
||||||
self.displayLink.isPaused = (self.countdownDate == nil)
|
|
||||||
|
|
||||||
if self.countdownDate == nil
|
|
||||||
{
|
|
||||||
self.setTitle(nil, for: .disabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private let progressView = UIProgressView(progressViewStyle: .default)
|
|
||||||
|
|
||||||
private lazy var displayLink: CADisplayLink = {
|
|
||||||
let displayLink = CADisplayLink(target: self, selector: #selector(PillButton.updateCountdown))
|
|
||||||
displayLink.preferredFramesPerSecond = 15
|
|
||||||
displayLink.isPaused = true
|
|
||||||
displayLink.add(to: .main, forMode: .common)
|
|
||||||
return displayLink
|
|
||||||
}()
|
|
||||||
|
|
||||||
private let dateComponentsFormatter: DateComponentsFormatter = {
|
|
||||||
let dateComponentsFormatter = DateComponentsFormatter()
|
|
||||||
dateComponentsFormatter.zeroFormattingBehavior = [.pad]
|
|
||||||
dateComponentsFormatter.collapsesLargestUnit = false
|
|
||||||
return dateComponentsFormatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
override var intrinsicContentSize: CGSize {
|
|
||||||
var size = super.intrinsicContentSize
|
|
||||||
size.width += 26
|
|
||||||
size.height += 3
|
|
||||||
return size
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit
|
|
||||||
{
|
|
||||||
self.displayLink.remove(from: .main, forMode: RunLoop.Mode.default)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func awakeFromNib()
|
|
||||||
{
|
|
||||||
super.awakeFromNib()
|
|
||||||
|
|
||||||
self.layer.masksToBounds = true
|
|
||||||
|
|
||||||
self.activityIndicatorView.style = .white
|
|
||||||
self.activityIndicatorView.isUserInteractionEnabled = false
|
|
||||||
|
|
||||||
self.progressView.progress = 0
|
|
||||||
self.progressView.trackImage = UIImage()
|
|
||||||
self.progressView.isUserInteractionEnabled = false
|
|
||||||
self.addSubview(self.progressView)
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutSubviews()
|
|
||||||
{
|
|
||||||
super.layoutSubviews()
|
|
||||||
|
|
||||||
self.progressView.bounds.size.width = self.bounds.width
|
|
||||||
|
|
||||||
let scale = self.bounds.height / self.progressView.bounds.height
|
|
||||||
|
|
||||||
self.progressView.transform = CGAffineTransform.identity.scaledBy(x: 1, y: scale)
|
|
||||||
self.progressView.center = CGPoint(x: self.bounds.midX, y: self.bounds.midY)
|
|
||||||
|
|
||||||
self.layer.cornerRadius = self.bounds.midY
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tintColorDidChange()
|
|
||||||
{
|
|
||||||
super.tintColorDidChange()
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension PillButton
|
|
||||||
{
|
|
||||||
func update()
|
|
||||||
{
|
|
||||||
if self.isInverted
|
|
||||||
{
|
|
||||||
self.setTitleColor(.white, for: .normal)
|
|
||||||
self.backgroundColor = self.tintColor
|
|
||||||
self.progressView.progressTintColor = self.tintColor.withAlphaComponent(0.15)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.setTitleColor(self.tintColor, for: .normal)
|
|
||||||
self.backgroundColor = self.tintColor.withAlphaComponent(0.15)
|
|
||||||
self.progressView.progressTintColor = self.tintColor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func updateCountdown()
|
|
||||||
{
|
|
||||||
guard let endDate = self.countdownDate else { return }
|
|
||||||
|
|
||||||
let startDate = Date()
|
|
||||||
|
|
||||||
let interval = endDate.timeIntervalSince(startDate)
|
|
||||||
guard interval > 0 else {
|
|
||||||
self.isEnabled = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let text: String?
|
|
||||||
|
|
||||||
if interval < (1 * 60 * 60)
|
|
||||||
{
|
|
||||||
self.dateComponentsFormatter.unitsStyle = .positional
|
|
||||||
self.dateComponentsFormatter.allowedUnits = [.minute, .second]
|
|
||||||
|
|
||||||
text = self.dateComponentsFormatter.string(from: startDate, to: endDate)
|
|
||||||
}
|
|
||||||
else if interval < (2 * 24 * 60 * 60)
|
|
||||||
{
|
|
||||||
self.dateComponentsFormatter.unitsStyle = .positional
|
|
||||||
self.dateComponentsFormatter.allowedUnits = [.hour, .minute, .second]
|
|
||||||
|
|
||||||
text = self.dateComponentsFormatter.string(from: startDate, to: endDate)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.dateComponentsFormatter.unitsStyle = .full
|
|
||||||
self.dateComponentsFormatter.allowedUnits = [.day]
|
|
||||||
|
|
||||||
let numberOfDays = endDate.numberOfCalendarDays(since: startDate)
|
|
||||||
text = String(format: NSLocalizedString("%@ DAYS", comment: ""), NSNumber(value: numberOfDays))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let text = text
|
|
||||||
{
|
|
||||||
UIView.performWithoutAnimation {
|
|
||||||
self.isEnabled = false
|
|
||||||
self.setTitle(text, for: .disabled)
|
|
||||||
self.layoutIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.isEnabled = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
//
|
|
||||||
// ToastView.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/19/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
class ToastView: RSTToastView
|
|
||||||
{
|
|
||||||
override init(text: String, detailText detailedText: String?)
|
|
||||||
{
|
|
||||||
super.init(text: text, detailText: detailedText)
|
|
||||||
|
|
||||||
self.layoutMargins = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init(coder aDecoder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutSubviews()
|
|
||||||
{
|
|
||||||
super.layoutSubviews()
|
|
||||||
|
|
||||||
self.layer.cornerRadius = 16
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
//
|
|
||||||
// JSONDecoder+ManagedObjectContext.swift
|
|
||||||
// Harmony
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 10/3/18.
|
|
||||||
// Copyright © 2018 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
private extension CodingUserInfoKey
|
|
||||||
{
|
|
||||||
static let managedObjectContext = CodingUserInfoKey(rawValue: "managedObjectContext")!
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension JSONDecoder
|
|
||||||
{
|
|
||||||
var managedObjectContext: NSManagedObjectContext? {
|
|
||||||
get {
|
|
||||||
let managedObjectContext = self.userInfo[.managedObjectContext] as? NSManagedObjectContext
|
|
||||||
return managedObjectContext
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
self.userInfo[.managedObjectContext] = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension Decoder
|
|
||||||
{
|
|
||||||
var managedObjectContext: NSManagedObjectContext? {
|
|
||||||
let managedObjectContext = self.userInfo[.managedObjectContext] as? NSManagedObjectContext
|
|
||||||
return managedObjectContext
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
//
|
|
||||||
// UIColor+AltStore.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 5/9/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension UIColor
|
|
||||||
{
|
|
||||||
static let altPrimary = UIColor(named: "Primary")!
|
|
||||||
|
|
||||||
static let altPink = UIColor(named: "Pink")!
|
|
||||||
|
|
||||||
static let refreshRed = UIColor(named: "RefreshRed")!
|
|
||||||
static let refreshOrange = UIColor(named: "RefreshOrange")!
|
|
||||||
static let refreshYellow = UIColor(named: "RefreshYellow")!
|
|
||||||
static let refreshGreen = UIColor(named: "RefreshGreen")!
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
//
|
|
||||||
// UIColor+Hex.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/15/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension UIColor
|
|
||||||
{
|
|
||||||
// Borrowed from https://stackoverflow.com/a/33397427
|
|
||||||
convenience init?(hexString: String)
|
|
||||||
{
|
|
||||||
let hex = hexString.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
|
||||||
var int = UInt32()
|
|
||||||
Scanner(string: hex).scanHexInt32(&int)
|
|
||||||
let a, r, g, b: UInt32
|
|
||||||
switch hex.count {
|
|
||||||
case 3: // RGB (12-bit)
|
|
||||||
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
|
||||||
case 6: // RGB (24-bit)
|
|
||||||
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
|
||||||
case 8: // ARGB (32-bit)
|
|
||||||
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
self.init(red: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a) / 255)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
//
|
|
||||||
// UserDefaults+AltStore.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 6/4/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
extension UserDefaults
|
|
||||||
{
|
|
||||||
@NSManaged var firstLaunch: Date?
|
|
||||||
|
|
||||||
@NSManaged var preferredServerID: String?
|
|
||||||
|
|
||||||
@NSManaged var isBackgroundRefreshEnabled: Bool
|
|
||||||
@NSManaged var isDebugModeEnabled: Bool
|
|
||||||
@NSManaged var presentedLaunchReminderNotification: Bool
|
|
||||||
|
|
||||||
func registerDefaults()
|
|
||||||
{
|
|
||||||
self.register(defaults: [#keyPath(UserDefaults.isBackgroundRefreshEnabled): true])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
//
|
|
||||||
// LaunchViewController.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/30/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
class LaunchViewController: RSTLaunchViewController
|
|
||||||
{
|
|
||||||
override var launchConditions: [RSTLaunchCondition] {
|
|
||||||
let isDatabaseStarted = RSTLaunchCondition(condition: { DatabaseManager.shared.isStarted }) { (completionHandler) in
|
|
||||||
DatabaseManager.shared.start(completionHandler: completionHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
return [isDatabaseStarted]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension LaunchViewController
|
|
||||||
{
|
|
||||||
override func handleLaunchError(_ error: Error)
|
|
||||||
{
|
|
||||||
do
|
|
||||||
{
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
catch let error as NSError
|
|
||||||
{
|
|
||||||
let title = error.userInfo[NSLocalizedFailureErrorKey] as? String ?? NSLocalizedString("Unable to Launch AltStore", comment: "")
|
|
||||||
|
|
||||||
let alertController = UIAlertController(title: title, message: error.localizedDescription, preferredStyle: .alert)
|
|
||||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Retry", comment: ""), style: .default, handler: { (action) in
|
|
||||||
self.handleLaunchConditions()
|
|
||||||
}))
|
|
||||||
self.present(alertController, animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func finishLaunching()
|
|
||||||
{
|
|
||||||
super.finishLaunching()
|
|
||||||
|
|
||||||
AppManager.shared.update()
|
|
||||||
PatreonAPI.shared.refreshPatreonAccount()
|
|
||||||
|
|
||||||
self.performSegue(withIdentifier: "finishLaunching", sender: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
//
|
|
||||||
// AppManager.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 5/29/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import UIKit
|
|
||||||
import UserNotifications
|
|
||||||
|
|
||||||
import AltSign
|
|
||||||
import AltKit
|
|
||||||
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
extension AppManager
|
|
||||||
{
|
|
||||||
static let didFetchSourceNotification = Notification.Name("com.altstore.AppManager.didFetchSource")
|
|
||||||
|
|
||||||
static let expirationWarningNotificationID = "altstore-expiration-warning"
|
|
||||||
}
|
|
||||||
|
|
||||||
class AppManager
|
|
||||||
{
|
|
||||||
static let shared = AppManager()
|
|
||||||
|
|
||||||
private let operationQueue = OperationQueue()
|
|
||||||
private let processingQueue = DispatchQueue(label: "com.altstore.AppManager.processingQueue")
|
|
||||||
|
|
||||||
private var installationProgress = [String: Progress]()
|
|
||||||
private var refreshProgress = [String: Progress]()
|
|
||||||
|
|
||||||
private init()
|
|
||||||
{
|
|
||||||
self.operationQueue.name = "com.altstore.AppManager.operationQueue"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppManager
|
|
||||||
{
|
|
||||||
func update()
|
|
||||||
{
|
|
||||||
#if targetEnvironment(simulator)
|
|
||||||
// Apps aren't ever actually installed to simulator, so just do nothing rather than delete them from database.
|
|
||||||
return
|
|
||||||
#else
|
|
||||||
|
|
||||||
let context = DatabaseManager.shared.persistentContainer.newBackgroundSavingViewContext()
|
|
||||||
|
|
||||||
let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest<InstalledApp>
|
|
||||||
fetchRequest.returnsObjectsAsFaults = false
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let installedApps = try context.fetch(fetchRequest)
|
|
||||||
for app in installedApps where app.storeApp != nil
|
|
||||||
{
|
|
||||||
if app.bundleIdentifier == StoreApp.altstoreAppID
|
|
||||||
{
|
|
||||||
self.scheduleExpirationWarningLocalNotification(for: app)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if !UIApplication.shared.canOpenURL(app.openAppURL)
|
|
||||||
{
|
|
||||||
context.delete(app)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try context.save()
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
print("Error while fetching installed apps")
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
func authenticate(presentingViewController: UIViewController?, completionHandler: @escaping (Result<ALTSigner, Error>) -> Void)
|
|
||||||
{
|
|
||||||
let authenticationOperation = AuthenticationOperation(presentingViewController: presentingViewController)
|
|
||||||
authenticationOperation.resultHandler = { (result) in
|
|
||||||
completionHandler(result)
|
|
||||||
}
|
|
||||||
self.operationQueue.addOperation(authenticationOperation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppManager
|
|
||||||
{
|
|
||||||
func fetchSource(completionHandler: @escaping (Result<Source, Error>) -> Void)
|
|
||||||
{
|
|
||||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
|
||||||
guard let source = Source.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Source.identifier), Source.altStoreIdentifier), in: context) else {
|
|
||||||
return completionHandler(.failure(OperationError.noSources))
|
|
||||||
}
|
|
||||||
|
|
||||||
let fetchSourceOperation = FetchSourceOperation(sourceURL: source.sourceURL)
|
|
||||||
fetchSourceOperation.resultHandler = { (result) in
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(let error):
|
|
||||||
completionHandler(.failure(error))
|
|
||||||
|
|
||||||
case .success(let source):
|
|
||||||
completionHandler(.success(source))
|
|
||||||
NotificationCenter.default.post(name: AppManager.didFetchSourceNotification, object: self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.operationQueue.addOperation(fetchSourceOperation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppManager
|
|
||||||
{
|
|
||||||
func install(_ app: AppProtocol, presentingViewController: UIViewController, completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> Progress
|
|
||||||
{
|
|
||||||
if let progress = self.installationProgress(for: app)
|
|
||||||
{
|
|
||||||
return progress
|
|
||||||
}
|
|
||||||
|
|
||||||
let bundleIdentifier = app.bundleIdentifier
|
|
||||||
|
|
||||||
let group = self.install([app], forceDownload: true, presentingViewController: presentingViewController)
|
|
||||||
group.completionHandler = { (result) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
self.installationProgress[bundleIdentifier] = nil
|
|
||||||
|
|
||||||
guard let (_, result) = try result.get().first else { throw OperationError.unknown }
|
|
||||||
completionHandler(result)
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
completionHandler(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.installationProgress[bundleIdentifier] = group.progress
|
|
||||||
|
|
||||||
return group.progress
|
|
||||||
}
|
|
||||||
|
|
||||||
func refresh(_ installedApps: [InstalledApp], presentingViewController: UIViewController?, group: OperationGroup? = nil) -> OperationGroup
|
|
||||||
{
|
|
||||||
let apps = installedApps.filter { self.refreshProgress(for: $0) == nil }
|
|
||||||
|
|
||||||
let group = self.install(apps, forceDownload: false, presentingViewController: presentingViewController, group: group)
|
|
||||||
|
|
||||||
for app in apps
|
|
||||||
{
|
|
||||||
guard let progress = group.progress(for: app) else { continue }
|
|
||||||
self.refreshProgress[app.bundleIdentifier] = progress
|
|
||||||
}
|
|
||||||
|
|
||||||
return group
|
|
||||||
}
|
|
||||||
|
|
||||||
func installationProgress(for app: AppProtocol) -> Progress?
|
|
||||||
{
|
|
||||||
let progress = self.installationProgress[app.bundleIdentifier]
|
|
||||||
return progress
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshProgress(for app: AppProtocol) -> Progress?
|
|
||||||
{
|
|
||||||
let progress = self.refreshProgress[app.bundleIdentifier]
|
|
||||||
return progress
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension AppManager
|
|
||||||
{
|
|
||||||
func install(_ apps: [AppProtocol], forceDownload: Bool, presentingViewController: UIViewController?, group: OperationGroup? = nil) -> OperationGroup
|
|
||||||
{
|
|
||||||
// Authenticate -> Download (if necessary) -> Resign -> Send -> Install.
|
|
||||||
let group = group ?? OperationGroup()
|
|
||||||
var operations = [Operation]()
|
|
||||||
|
|
||||||
|
|
||||||
/* Authenticate */
|
|
||||||
let authenticationOperation = AuthenticationOperation(presentingViewController: presentingViewController)
|
|
||||||
authenticationOperation.resultHandler = { (result) in
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(let error): group.error = error
|
|
||||||
case .success(let signer): group.signer = signer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
operations.append(authenticationOperation)
|
|
||||||
|
|
||||||
/* Find Server */
|
|
||||||
let findServerOperation = FindServerOperation(group: group)
|
|
||||||
findServerOperation.resultHandler = { (result) in
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(let error): group.error = error
|
|
||||||
case .success(let server): group.server = server
|
|
||||||
}
|
|
||||||
}
|
|
||||||
findServerOperation.addDependency(authenticationOperation)
|
|
||||||
operations.append(findServerOperation)
|
|
||||||
|
|
||||||
|
|
||||||
for app in apps
|
|
||||||
{
|
|
||||||
let context = AppOperationContext(bundleIdentifier: app.bundleIdentifier, group: group)
|
|
||||||
let progress = Progress.discreteProgress(totalUnitCount: 100)
|
|
||||||
|
|
||||||
|
|
||||||
/* Resign */
|
|
||||||
let resignAppOperation = ResignAppOperation(context: context)
|
|
||||||
resignAppOperation.resultHandler = { (result) in
|
|
||||||
guard let resignedApp = self.process(result, context: context) else { return }
|
|
||||||
context.resignedApp = resignedApp
|
|
||||||
}
|
|
||||||
resignAppOperation.addDependency(findServerOperation)
|
|
||||||
progress.addChild(resignAppOperation.progress, withPendingUnitCount: 20)
|
|
||||||
operations.append(resignAppOperation)
|
|
||||||
|
|
||||||
|
|
||||||
/* Download */
|
|
||||||
let fileURL = InstalledApp.fileURL(for: app)
|
|
||||||
|
|
||||||
var localApp: ALTApplication?
|
|
||||||
|
|
||||||
let managedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
|
||||||
managedObjectContext.performAndWait {
|
|
||||||
let predicate = NSPredicate(format: "%K == %@", #keyPath(InstalledApp.bundleIdentifier), context.bundleIdentifier)
|
|
||||||
|
|
||||||
if let installedApp = InstalledApp.first(satisfying: predicate, in: managedObjectContext), FileManager.default.fileExists(atPath: fileURL.path), !forceDownload
|
|
||||||
{
|
|
||||||
localApp = ALTApplication(fileURL: installedApp.fileURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let localApp = localApp
|
|
||||||
{
|
|
||||||
// Already installed, don't need to download.
|
|
||||||
|
|
||||||
// If we don't need to download the app, reduce the total unit count by 40.
|
|
||||||
progress.totalUnitCount -= 40
|
|
||||||
|
|
||||||
context.app = localApp
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// App is not yet installed (or we're forcing it to download a new version), so download it before resigning it.
|
|
||||||
|
|
||||||
let downloadOperation = DownloadAppOperation(app: app, context: context)
|
|
||||||
downloadOperation.resultHandler = { (result) in
|
|
||||||
guard let app = self.process(result, context: context) else { return }
|
|
||||||
context.app = app
|
|
||||||
}
|
|
||||||
progress.addChild(downloadOperation.progress, withPendingUnitCount: 40)
|
|
||||||
downloadOperation.addDependency(findServerOperation)
|
|
||||||
resignAppOperation.addDependency(downloadOperation)
|
|
||||||
operations.append(downloadOperation)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Send */
|
|
||||||
let sendAppOperation = SendAppOperation(context: context)
|
|
||||||
sendAppOperation.resultHandler = { (result) in
|
|
||||||
guard let connection = self.process(result, context: context) else { return }
|
|
||||||
context.connection = connection
|
|
||||||
}
|
|
||||||
progress.addChild(sendAppOperation.progress, withPendingUnitCount: 10)
|
|
||||||
sendAppOperation.addDependency(resignAppOperation)
|
|
||||||
operations.append(sendAppOperation)
|
|
||||||
|
|
||||||
|
|
||||||
let beginInstallationHandler = group.beginInstallationHandler
|
|
||||||
group.beginInstallationHandler = { (installedApp) in
|
|
||||||
if installedApp.bundleIdentifier == StoreApp.altstoreAppID
|
|
||||||
{
|
|
||||||
self.scheduleExpirationWarningLocalNotification(for: installedApp)
|
|
||||||
}
|
|
||||||
|
|
||||||
beginInstallationHandler?(installedApp)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Install */
|
|
||||||
let installOperation = InstallAppOperation(context: context)
|
|
||||||
installOperation.resultHandler = { (result) in
|
|
||||||
if let error = result.error
|
|
||||||
{
|
|
||||||
context.error = error
|
|
||||||
}
|
|
||||||
|
|
||||||
if let installedApp = result.value
|
|
||||||
{
|
|
||||||
if let app = app as? StoreApp, let storeApp = installedApp.managedObjectContext?.object(with: app.objectID) as? StoreApp
|
|
||||||
{
|
|
||||||
installedApp.storeApp = storeApp
|
|
||||||
}
|
|
||||||
|
|
||||||
context.installedApp = installedApp
|
|
||||||
}
|
|
||||||
|
|
||||||
self.finishAppOperation(context) // Finish operation no matter what.
|
|
||||||
}
|
|
||||||
progress.addChild(installOperation.progress, withPendingUnitCount: 30)
|
|
||||||
installOperation.addDependency(sendAppOperation)
|
|
||||||
operations.append(installOperation)
|
|
||||||
|
|
||||||
group.set(progress, for: app)
|
|
||||||
}
|
|
||||||
|
|
||||||
group.addOperations(operations)
|
|
||||||
|
|
||||||
return group
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult func process<T>(_ result: Result<T, Error>, context: AppOperationContext) -> T?
|
|
||||||
{
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let value = try result.get()
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
catch OperationError.cancelled
|
|
||||||
{
|
|
||||||
context.error = OperationError.cancelled
|
|
||||||
self.finishAppOperation(context)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
context.error = error
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func finishAppOperation(_ context: AppOperationContext)
|
|
||||||
{
|
|
||||||
self.processingQueue.sync {
|
|
||||||
guard !context.isFinished else { return }
|
|
||||||
context.isFinished = true
|
|
||||||
|
|
||||||
self.refreshProgress[context.bundleIdentifier] = nil
|
|
||||||
|
|
||||||
if let error = context.error
|
|
||||||
{
|
|
||||||
switch error
|
|
||||||
{
|
|
||||||
case let error as ALTServerError where error.code == .deviceNotFound || error.code == .lostConnection:
|
|
||||||
if let server = context.group.server, server.isPreferred
|
|
||||||
{
|
|
||||||
// Preferred server, so report errors normally.
|
|
||||||
context.group.results[context.bundleIdentifier] = .failure(error)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Not preferred server, so ignore these specific errors and throw serverNotFound instead.
|
|
||||||
context.group.results[context.bundleIdentifier] = .failure(ConnectionError.serverNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
case let error:
|
|
||||||
context.group.results[context.bundleIdentifier] = .failure(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
else if let installedApp = context.installedApp
|
|
||||||
{
|
|
||||||
context.group.results[context.bundleIdentifier] = .success(installedApp)
|
|
||||||
|
|
||||||
// Save after each installation.
|
|
||||||
installedApp.managedObjectContext?.performAndWait {
|
|
||||||
do { try installedApp.managedObjectContext?.save() }
|
|
||||||
catch { print("Error saving installed app.", error) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
do { try FileManager.default.removeItem(at: context.temporaryDirectory) }
|
|
||||||
catch { print("Failed to remove temporary directory.", error) }
|
|
||||||
|
|
||||||
print("Finished operation!", context.bundleIdentifier)
|
|
||||||
|
|
||||||
if context.group.results.count == context.group.progress.totalUnitCount
|
|
||||||
{
|
|
||||||
context.group.completionHandler?(.success(context.group.results))
|
|
||||||
|
|
||||||
let backgroundContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
|
||||||
backgroundContext.performAndWait {
|
|
||||||
guard let altstore = InstalledApp.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID), in: backgroundContext) else { return }
|
|
||||||
self.scheduleExpirationWarningLocalNotification(for: altstore)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func scheduleExpirationWarningLocalNotification(for app: InstalledApp)
|
|
||||||
{
|
|
||||||
let notificationDate = app.expirationDate.addingTimeInterval(-1 * 60 * 60 * 24) // 24 hours before expiration.
|
|
||||||
|
|
||||||
let timeIntervalUntilNotification = notificationDate.timeIntervalSinceNow
|
|
||||||
guard timeIntervalUntilNotification > 0 else {
|
|
||||||
// Crashes if we pass negative value to UNTimeIntervalNotificationTrigger initializer.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeIntervalUntilNotification, repeats: false)
|
|
||||||
|
|
||||||
let content = UNMutableNotificationContent()
|
|
||||||
content.title = NSLocalizedString("AltStore Expiring Soon", comment: "")
|
|
||||||
content.body = NSLocalizedString("AltStore will expire in 24 hours. Open the app and refresh it to prevent it from expiring.", comment: "")
|
|
||||||
content.sound = .default
|
|
||||||
|
|
||||||
let request = UNNotificationRequest(identifier: AppManager.expirationWarningNotificationID, content: content, trigger: trigger)
|
|
||||||
UNUserNotificationCenter.current().add(request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
//
|
|
||||||
// Account.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 6/5/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
import AltSign
|
|
||||||
|
|
||||||
@objc(Account)
|
|
||||||
class Account: NSManagedObject, Fetchable
|
|
||||||
{
|
|
||||||
var localizedName: String {
|
|
||||||
var components = PersonNameComponents()
|
|
||||||
components.givenName = self.firstName
|
|
||||||
components.familyName = self.lastName
|
|
||||||
|
|
||||||
let name = PersonNameComponentsFormatter.localizedString(from: components, style: .default)
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Properties */
|
|
||||||
@NSManaged var appleID: String
|
|
||||||
@NSManaged var identifier: String
|
|
||||||
|
|
||||||
@NSManaged var firstName: String
|
|
||||||
@NSManaged var lastName: String
|
|
||||||
|
|
||||||
@NSManaged var isActiveAccount: Bool
|
|
||||||
|
|
||||||
/* Relationships */
|
|
||||||
@NSManaged var teams: Set<Team>
|
|
||||||
|
|
||||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
|
||||||
{
|
|
||||||
super.init(entity: entity, insertInto: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(_ account: ALTAccount, context: NSManagedObjectContext)
|
|
||||||
{
|
|
||||||
super.init(entity: Account.entity(), insertInto: context)
|
|
||||||
|
|
||||||
self.appleID = account.appleID
|
|
||||||
self.identifier = account.identifier
|
|
||||||
|
|
||||||
self.firstName = account.firstName
|
|
||||||
self.lastName = account.lastName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Account
|
|
||||||
{
|
|
||||||
@nonobjc class func fetchRequest() -> NSFetchRequest<Account>
|
|
||||||
{
|
|
||||||
return NSFetchRequest<Account>(entityName: "Account")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
//
|
|
||||||
// AppPermission.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/23/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import CoreData
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension ALTAppPermissionType
|
|
||||||
{
|
|
||||||
var localizedShortName: String? {
|
|
||||||
switch self
|
|
||||||
{
|
|
||||||
case .photos: return NSLocalizedString("Photos", comment: "")
|
|
||||||
case .backgroundAudio: return NSLocalizedString("Audio (BG)", comment: "")
|
|
||||||
case .backgroundFetch: return NSLocalizedString("Fetch (BG)", comment: "")
|
|
||||||
default: return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var localizedName: String? {
|
|
||||||
switch self
|
|
||||||
{
|
|
||||||
case .photos: return NSLocalizedString("Photos", comment: "")
|
|
||||||
case .backgroundAudio: return NSLocalizedString("Background Audio", comment: "")
|
|
||||||
case .backgroundFetch: return NSLocalizedString("Background Fetch", comment: "")
|
|
||||||
default: return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var icon: UIImage? {
|
|
||||||
switch self
|
|
||||||
{
|
|
||||||
case .photos: return UIImage(named: "PhotosPermission")
|
|
||||||
case .backgroundAudio: return UIImage(named: "BackgroundAudioPermission")
|
|
||||||
case .backgroundFetch: return UIImage(named: "BackgroundFetchPermission")
|
|
||||||
default: return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc(AppPermission)
|
|
||||||
class AppPermission: NSManagedObject, Decodable, Fetchable
|
|
||||||
{
|
|
||||||
/* Properties */
|
|
||||||
@NSManaged var type: ALTAppPermissionType
|
|
||||||
@NSManaged var usageDescription: String
|
|
||||||
|
|
||||||
/* Relationships */
|
|
||||||
@NSManaged private(set) var app: StoreApp!
|
|
||||||
|
|
||||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
|
||||||
{
|
|
||||||
super.init(entity: entity, insertInto: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey
|
|
||||||
{
|
|
||||||
case type
|
|
||||||
case usageDescription
|
|
||||||
}
|
|
||||||
|
|
||||||
required init(from decoder: Decoder) throws
|
|
||||||
{
|
|
||||||
guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") }
|
|
||||||
|
|
||||||
super.init(entity: AppPermission.entity(), insertInto: nil)
|
|
||||||
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
self.usageDescription = try container.decode(String.self, forKey: .usageDescription)
|
|
||||||
|
|
||||||
let rawType = try container.decode(String.self, forKey: .type)
|
|
||||||
self.type = ALTAppPermissionType(rawValue: rawType)
|
|
||||||
|
|
||||||
context.insert(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppPermission
|
|
||||||
{
|
|
||||||
@nonobjc class func fetchRequest() -> NSFetchRequest<AppPermission>
|
|
||||||
{
|
|
||||||
return NSFetchRequest<AppPermission>(entityName: "AppPermission")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
//
|
|
||||||
// DatabaseManager.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 5/20/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
import AltSign
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
public class DatabaseManager
|
|
||||||
{
|
|
||||||
public static let shared = DatabaseManager()
|
|
||||||
|
|
||||||
public let persistentContainer: RSTPersistentContainer
|
|
||||||
|
|
||||||
public private(set) var isStarted = false
|
|
||||||
|
|
||||||
private var startCompletionHandlers = [(Error?) -> Void]()
|
|
||||||
|
|
||||||
private init()
|
|
||||||
{
|
|
||||||
self.persistentContainer = RSTPersistentContainer(name: "AltStore")
|
|
||||||
self.persistentContainer.preferredMergePolicy = MergePolicy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension DatabaseManager
|
|
||||||
{
|
|
||||||
func start(completionHandler: @escaping (Error?) -> Void)
|
|
||||||
{
|
|
||||||
self.startCompletionHandlers.append(completionHandler)
|
|
||||||
|
|
||||||
guard self.startCompletionHandlers.count == 1 else { return }
|
|
||||||
|
|
||||||
func finish(_ error: Error?)
|
|
||||||
{
|
|
||||||
self.startCompletionHandlers.forEach { $0(error) }
|
|
||||||
self.startCompletionHandlers.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !self.isStarted else { return finish(nil) }
|
|
||||||
|
|
||||||
self.persistentContainer.loadPersistentStores { (description, error) in
|
|
||||||
guard error == nil else { return finish(error!) }
|
|
||||||
|
|
||||||
self.prepareDatabase() { (result) in
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(let error):
|
|
||||||
finish(error)
|
|
||||||
|
|
||||||
case .success:
|
|
||||||
self.isStarted = true
|
|
||||||
finish(nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func signOut(completionHandler: @escaping (Error?) -> Void)
|
|
||||||
{
|
|
||||||
self.persistentContainer.performBackgroundTask { (context) in
|
|
||||||
if let account = self.activeAccount(in: context)
|
|
||||||
{
|
|
||||||
account.isActiveAccount = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if let team = self.activeTeam(in: context)
|
|
||||||
{
|
|
||||||
team.isActiveTeam = false
|
|
||||||
}
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
try context.save()
|
|
||||||
|
|
||||||
Keychain.shared.reset()
|
|
||||||
|
|
||||||
completionHandler(nil)
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
print("Failed to save when signing out.", error)
|
|
||||||
completionHandler(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension DatabaseManager
|
|
||||||
{
|
|
||||||
var viewContext: NSManagedObjectContext {
|
|
||||||
return self.persistentContainer.viewContext
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension DatabaseManager
|
|
||||||
{
|
|
||||||
func activeAccount(in context: NSManagedObjectContext = DatabaseManager.shared.viewContext) -> Account?
|
|
||||||
{
|
|
||||||
let predicate = NSPredicate(format: "%K == YES", #keyPath(Account.isActiveAccount))
|
|
||||||
|
|
||||||
let activeAccount = Account.first(satisfying: predicate, in: context)
|
|
||||||
return activeAccount
|
|
||||||
}
|
|
||||||
|
|
||||||
func activeTeam(in context: NSManagedObjectContext = DatabaseManager.shared.viewContext) -> Team?
|
|
||||||
{
|
|
||||||
let predicate = NSPredicate(format: "%K == YES", #keyPath(Team.isActiveTeam))
|
|
||||||
|
|
||||||
let activeTeam = Team.first(satisfying: predicate, in: context)
|
|
||||||
return activeTeam
|
|
||||||
}
|
|
||||||
|
|
||||||
func patreonAccount(in context: NSManagedObjectContext = DatabaseManager.shared.viewContext) -> PatreonAccount?
|
|
||||||
{
|
|
||||||
let patronAccount = PatreonAccount.first(in: context)
|
|
||||||
return patronAccount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension DatabaseManager
|
|
||||||
{
|
|
||||||
func prepareDatabase(completionHandler: @escaping (Result<Void, Error>) -> Void)
|
|
||||||
{
|
|
||||||
let context = self.persistentContainer.newBackgroundContext()
|
|
||||||
context.performAndWait {
|
|
||||||
guard let localApp = ALTApplication(fileURL: Bundle.main.bundleURL) else { return }
|
|
||||||
|
|
||||||
let altStoreSource: Source
|
|
||||||
|
|
||||||
if let source = Source.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Source.identifier), Source.altStoreIdentifier), in: context)
|
|
||||||
{
|
|
||||||
altStoreSource = source
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
altStoreSource = Source.makeAltStoreSource(in: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sure to always update source URL to be current.
|
|
||||||
altStoreSource.sourceURL = Source.altStoreSourceURL
|
|
||||||
|
|
||||||
let storeApp: StoreApp
|
|
||||||
|
|
||||||
if let app = StoreApp.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID), in: context)
|
|
||||||
{
|
|
||||||
storeApp = app
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
storeApp = StoreApp.makeAltStoreApp(in: context)
|
|
||||||
storeApp.version = localApp.version
|
|
||||||
storeApp.source = altStoreSource
|
|
||||||
}
|
|
||||||
|
|
||||||
let installedApp: InstalledApp
|
|
||||||
|
|
||||||
if let app = storeApp.installedApp
|
|
||||||
{
|
|
||||||
installedApp = app
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
installedApp = InstalledApp(resignedApp: localApp, originalBundleIdentifier: StoreApp.altstoreAppID, context: context)
|
|
||||||
installedApp.storeApp = storeApp
|
|
||||||
}
|
|
||||||
|
|
||||||
let fileURL = installedApp.fileURL
|
|
||||||
if !FileManager.default.fileExists(atPath: fileURL.path) || installedApp.version != localApp.version
|
|
||||||
{
|
|
||||||
FileManager.default.prepareTemporaryURL() { (temporaryFileURL) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
try FileManager.default.copyItem(at: Bundle.main.bundleURL, to: temporaryFileURL)
|
|
||||||
|
|
||||||
let infoPlistURL = temporaryFileURL.appendingPathComponent("Info.plist")
|
|
||||||
|
|
||||||
guard var infoDictionary = Bundle.main.infoDictionary else { throw ALTError(.missingInfoPlist) }
|
|
||||||
infoDictionary[kCFBundleIdentifierKey as String] = StoreApp.altstoreAppID
|
|
||||||
try (infoDictionary as NSDictionary).write(to: infoPlistURL)
|
|
||||||
|
|
||||||
try FileManager.default.copyItem(at: temporaryFileURL, to: fileURL, shouldReplace: true)
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
print("Failed to copy AltStore app bundle to its proper location.", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Must go after comparing versions to see if we need to update our cached AltStore app bundle.
|
|
||||||
installedApp.version = localApp.version
|
|
||||||
|
|
||||||
if let provisioningProfile = localApp.provisioningProfile
|
|
||||||
{
|
|
||||||
installedApp.refreshedDate = provisioningProfile.creationDate
|
|
||||||
installedApp.expirationDate = provisioningProfile.expirationDate
|
|
||||||
}
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
try context.save()
|
|
||||||
completionHandler(.success(()))
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
completionHandler(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
//
|
|
||||||
// InstalledApp.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 5/20/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
import AltSign
|
|
||||||
|
|
||||||
@objc(InstalledApp)
|
|
||||||
class InstalledApp: NSManagedObject, Fetchable
|
|
||||||
{
|
|
||||||
/* Properties */
|
|
||||||
@NSManaged var name: String
|
|
||||||
@NSManaged var bundleIdentifier: String
|
|
||||||
@NSManaged var resignedBundleIdentifier: String
|
|
||||||
@NSManaged var version: String
|
|
||||||
|
|
||||||
@NSManaged var refreshedDate: Date
|
|
||||||
@NSManaged var expirationDate: Date
|
|
||||||
|
|
||||||
/* Relationships */
|
|
||||||
@NSManaged var storeApp: StoreApp?
|
|
||||||
|
|
||||||
var isSideloaded: Bool {
|
|
||||||
return self.storeApp == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
|
||||||
{
|
|
||||||
super.init(entity: entity, insertInto: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(resignedApp: ALTApplication, originalBundleIdentifier: String, context: NSManagedObjectContext)
|
|
||||||
{
|
|
||||||
super.init(entity: InstalledApp.entity(), insertInto: context)
|
|
||||||
|
|
||||||
self.name = resignedApp.name
|
|
||||||
self.bundleIdentifier = originalBundleIdentifier
|
|
||||||
self.resignedBundleIdentifier = resignedApp.bundleIdentifier
|
|
||||||
|
|
||||||
self.version = resignedApp.version
|
|
||||||
|
|
||||||
if let provisioningProfile = resignedApp.provisioningProfile
|
|
||||||
{
|
|
||||||
self.refreshedDate = provisioningProfile.creationDate
|
|
||||||
self.expirationDate = provisioningProfile.expirationDate
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.refreshedDate = Date()
|
|
||||||
self.expirationDate = self.refreshedDate.addingTimeInterval(60 * 60 * 24 * 7) // Rough estimate until we get real values from provisioning profile.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension InstalledApp
|
|
||||||
{
|
|
||||||
@nonobjc class func fetchRequest() -> NSFetchRequest<InstalledApp>
|
|
||||||
{
|
|
||||||
return NSFetchRequest<InstalledApp>(entityName: "InstalledApp")
|
|
||||||
}
|
|
||||||
|
|
||||||
class func updatesFetchRequest() -> NSFetchRequest<InstalledApp>
|
|
||||||
{
|
|
||||||
let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest<InstalledApp>
|
|
||||||
fetchRequest.predicate = NSPredicate(format: "%K != nil AND %K != %K", #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.version), #keyPath(InstalledApp.storeApp.version))
|
|
||||||
return fetchRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
class func fetchAltStore(in context: NSManagedObjectContext) -> InstalledApp?
|
|
||||||
{
|
|
||||||
let predicate = NSPredicate(format: "%K == %@", #keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID)
|
|
||||||
|
|
||||||
let altStore = InstalledApp.first(satisfying: predicate, in: context)
|
|
||||||
return altStore
|
|
||||||
}
|
|
||||||
|
|
||||||
class func fetchAppsForRefreshingAll(in context: NSManagedObjectContext) -> [InstalledApp]
|
|
||||||
{
|
|
||||||
var predicate = NSPredicate(format: "%K != %@", #keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID)
|
|
||||||
|
|
||||||
if let patreonAccount = DatabaseManager.shared.patreonAccount(in: context), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated
|
|
||||||
{
|
|
||||||
// No additional predicate
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate,
|
|
||||||
NSPredicate(format: "%K == nil OR %K == NO", #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.storeApp.isBeta))])
|
|
||||||
}
|
|
||||||
|
|
||||||
var installedApps = InstalledApp.all(satisfying: predicate,
|
|
||||||
sortedBy: [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true)],
|
|
||||||
in: context)
|
|
||||||
|
|
||||||
if let altStoreApp = InstalledApp.fetchAltStore(in: context)
|
|
||||||
{
|
|
||||||
// Refresh AltStore last since it causes app to quit.
|
|
||||||
installedApps.append(altStoreApp)
|
|
||||||
}
|
|
||||||
|
|
||||||
return installedApps
|
|
||||||
}
|
|
||||||
|
|
||||||
class func fetchAppsForBackgroundRefresh(in context: NSManagedObjectContext) -> [InstalledApp]
|
|
||||||
{
|
|
||||||
// Date 6 hours before now.
|
|
||||||
let date = Date().addingTimeInterval(-1 * 6 * 60 * 60)
|
|
||||||
|
|
||||||
var predicate = NSPredicate(format: "(%K < %@) AND (%K != %@)",
|
|
||||||
#keyPath(InstalledApp.refreshedDate), date as NSDate,
|
|
||||||
#keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID)
|
|
||||||
|
|
||||||
if let patreonAccount = DatabaseManager.shared.patreonAccount(in: context), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated
|
|
||||||
{
|
|
||||||
// No additional predicate
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate,
|
|
||||||
NSPredicate(format: "%K == nil OR %K == NO", #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.storeApp.isBeta))])
|
|
||||||
}
|
|
||||||
|
|
||||||
var installedApps = InstalledApp.all(satisfying: predicate,
|
|
||||||
sortedBy: [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true)],
|
|
||||||
in: context)
|
|
||||||
|
|
||||||
if let altStoreApp = InstalledApp.fetchAltStore(in: context), altStoreApp.refreshedDate < date
|
|
||||||
{
|
|
||||||
// Refresh AltStore last since it causes app to quit.
|
|
||||||
installedApps.append(altStoreApp)
|
|
||||||
}
|
|
||||||
|
|
||||||
return installedApps
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension InstalledApp
|
|
||||||
{
|
|
||||||
var openAppURL: URL {
|
|
||||||
let openAppURL = URL(string: "altstore-" + self.bundleIdentifier + "://")!
|
|
||||||
return openAppURL
|
|
||||||
}
|
|
||||||
|
|
||||||
class func openAppURL(for app: AppProtocol) -> URL
|
|
||||||
{
|
|
||||||
let openAppURL = URL(string: "altstore-" + app.bundleIdentifier + "://")!
|
|
||||||
return openAppURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension InstalledApp
|
|
||||||
{
|
|
||||||
class var appsDirectoryURL: URL {
|
|
||||||
let appsDirectoryURL = FileManager.default.applicationSupportDirectory.appendingPathComponent("Apps")
|
|
||||||
|
|
||||||
do { try FileManager.default.createDirectory(at: appsDirectoryURL, withIntermediateDirectories: true, attributes: nil) }
|
|
||||||
catch { print(error) }
|
|
||||||
|
|
||||||
return appsDirectoryURL
|
|
||||||
}
|
|
||||||
|
|
||||||
class func fileURL(for app: AppProtocol) -> URL
|
|
||||||
{
|
|
||||||
let appURL = self.directoryURL(for: app).appendingPathComponent("App.app")
|
|
||||||
return appURL
|
|
||||||
}
|
|
||||||
|
|
||||||
class func refreshedIPAURL(for app: AppProtocol) -> URL
|
|
||||||
{
|
|
||||||
let ipaURL = self.directoryURL(for: app).appendingPathComponent("Refreshed.ipa")
|
|
||||||
return ipaURL
|
|
||||||
}
|
|
||||||
|
|
||||||
class func directoryURL(for app: AppProtocol) -> URL
|
|
||||||
{
|
|
||||||
let directoryURL = InstalledApp.appsDirectoryURL.appendingPathComponent(app.bundleIdentifier)
|
|
||||||
|
|
||||||
do { try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) }
|
|
||||||
catch { print(error) }
|
|
||||||
|
|
||||||
return directoryURL
|
|
||||||
}
|
|
||||||
|
|
||||||
var directoryURL: URL {
|
|
||||||
return InstalledApp.directoryURL(for: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
var fileURL: URL {
|
|
||||||
return InstalledApp.fileURL(for: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
var refreshedIPAURL: URL {
|
|
||||||
return InstalledApp.refreshedIPAURL(for: self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
//
|
|
||||||
// MergePolicy.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/23/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
open class MergePolicy: RSTRelationshipPreservingMergePolicy
|
|
||||||
{
|
|
||||||
open override func resolve(constraintConflicts conflicts: [NSConstraintConflict]) throws
|
|
||||||
{
|
|
||||||
guard conflicts.allSatisfy({ $0.databaseObject != nil }) else {
|
|
||||||
assertionFailure("MergePolicy is only intended to work with database-level conflicts.")
|
|
||||||
return try super.resolve(constraintConflicts: conflicts)
|
|
||||||
}
|
|
||||||
|
|
||||||
for conflict in conflicts
|
|
||||||
{
|
|
||||||
switch conflict.databaseObject
|
|
||||||
{
|
|
||||||
case let databaseObject as StoreApp:
|
|
||||||
// Delete previous permissions
|
|
||||||
for permission in databaseObject.permissions
|
|
||||||
{
|
|
||||||
permission.managedObjectContext?.delete(permission)
|
|
||||||
}
|
|
||||||
|
|
||||||
case let databaseObject as Source:
|
|
||||||
guard let conflictedObject = conflict.conflictingObjects.first as? Source else { break }
|
|
||||||
|
|
||||||
let bundleIdentifiers = Set(conflictedObject.apps.map { $0.bundleIdentifier })
|
|
||||||
let newsItemIdentifiers = Set(conflictedObject.newsItems.map { $0.identifier })
|
|
||||||
|
|
||||||
for app in databaseObject.apps
|
|
||||||
{
|
|
||||||
if !bundleIdentifiers.contains(app.bundleIdentifier)
|
|
||||||
{
|
|
||||||
// No longer listed in Source, so remove it from database.
|
|
||||||
app.managedObjectContext?.delete(app)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for newsItem in databaseObject.newsItems
|
|
||||||
{
|
|
||||||
if !newsItemIdentifiers.contains(newsItem.identifier)
|
|
||||||
{
|
|
||||||
// No longer listed in Source, so remove it from database.
|
|
||||||
newsItem.managedObjectContext?.delete(newsItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try super.resolve(constraintConflicts: conflicts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
//
|
|
||||||
// NewsItem.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 8/29/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
@objc(NewsItem)
|
|
||||||
class NewsItem: NSManagedObject, Decodable, Fetchable
|
|
||||||
{
|
|
||||||
/* Properties */
|
|
||||||
@NSManaged var identifier: String
|
|
||||||
@NSManaged var date: Date
|
|
||||||
|
|
||||||
@NSManaged var title: String
|
|
||||||
@NSManaged var caption: String
|
|
||||||
@NSManaged var tintColor: UIColor
|
|
||||||
@NSManaged var sortIndex: Int32
|
|
||||||
@NSManaged var isSilent: Bool
|
|
||||||
|
|
||||||
@NSManaged var imageURL: URL?
|
|
||||||
@NSManaged var externalURL: URL?
|
|
||||||
|
|
||||||
@NSManaged var appID: String?
|
|
||||||
|
|
||||||
/* Relationships */
|
|
||||||
@NSManaged var storeApp: StoreApp?
|
|
||||||
@NSManaged var source: Source?
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey
|
|
||||||
{
|
|
||||||
case identifier
|
|
||||||
case date
|
|
||||||
case title
|
|
||||||
case caption
|
|
||||||
case tintColor
|
|
||||||
case imageURL
|
|
||||||
case externalURL = "url"
|
|
||||||
case appID
|
|
||||||
case notify
|
|
||||||
}
|
|
||||||
|
|
||||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
|
||||||
{
|
|
||||||
super.init(entity: entity, insertInto: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init(from decoder: Decoder) throws
|
|
||||||
{
|
|
||||||
guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") }
|
|
||||||
|
|
||||||
super.init(entity: NewsItem.entity(), insertInto: context)
|
|
||||||
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
self.identifier = try container.decode(String.self, forKey: .identifier)
|
|
||||||
self.date = try container.decode(Date.self, forKey: .date)
|
|
||||||
|
|
||||||
self.title = try container.decode(String.self, forKey: .title)
|
|
||||||
self.caption = try container.decode(String.self, forKey: .caption)
|
|
||||||
|
|
||||||
if let tintColorHex = try container.decodeIfPresent(String.self, forKey: .tintColor)
|
|
||||||
{
|
|
||||||
guard let tintColor = UIColor(hexString: tintColorHex) else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .tintColor, in: container, debugDescription: "Hex code is invalid.")
|
|
||||||
}
|
|
||||||
|
|
||||||
self.tintColor = tintColor
|
|
||||||
}
|
|
||||||
|
|
||||||
self.imageURL = try container.decodeIfPresent(URL.self, forKey: .imageURL)
|
|
||||||
self.externalURL = try container.decodeIfPresent(URL.self, forKey: .externalURL)
|
|
||||||
|
|
||||||
self.appID = try container.decodeIfPresent(String.self, forKey: .appID)
|
|
||||||
|
|
||||||
let notify = try container.decodeIfPresent(Bool.self, forKey: .notify) ?? false
|
|
||||||
self.isSilent = !notify
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension NewsItem
|
|
||||||
{
|
|
||||||
@nonobjc class func fetchRequest() -> NSFetchRequest<NewsItem>
|
|
||||||
{
|
|
||||||
return NSFetchRequest<NewsItem>(entityName: "NewsItem")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
//
|
|
||||||
// PatreonAccount.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 8/20/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
extension PatreonAPI
|
|
||||||
{
|
|
||||||
struct AccountResponse: Decodable
|
|
||||||
{
|
|
||||||
struct Data: Decodable
|
|
||||||
{
|
|
||||||
struct Attributes: Decodable
|
|
||||||
{
|
|
||||||
var first_name: String?
|
|
||||||
var full_name: String
|
|
||||||
}
|
|
||||||
|
|
||||||
var id: String
|
|
||||||
var attributes: Attributes
|
|
||||||
}
|
|
||||||
|
|
||||||
var data: Data
|
|
||||||
var included: [PatronResponse]?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc(PatreonAccount)
|
|
||||||
class PatreonAccount: NSManagedObject, Fetchable
|
|
||||||
{
|
|
||||||
@NSManaged var identifier: String
|
|
||||||
|
|
||||||
@NSManaged var name: String
|
|
||||||
@NSManaged var firstName: String?
|
|
||||||
|
|
||||||
@NSManaged var isPatron: Bool
|
|
||||||
|
|
||||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
|
||||||
{
|
|
||||||
super.init(entity: entity, insertInto: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(response: PatreonAPI.AccountResponse, context: NSManagedObjectContext)
|
|
||||||
{
|
|
||||||
super.init(entity: PatreonAccount.entity(), insertInto: context)
|
|
||||||
|
|
||||||
self.identifier = response.data.id
|
|
||||||
self.name = response.data.attributes.full_name
|
|
||||||
self.firstName = response.data.attributes.first_name
|
|
||||||
|
|
||||||
if let patronResponse = response.included?.first
|
|
||||||
{
|
|
||||||
let patron = Patron(response: patronResponse)
|
|
||||||
self.isPatron = (patron.status == .active)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.isPatron = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PatreonAccount
|
|
||||||
{
|
|
||||||
@nonobjc class func fetchRequest() -> NSFetchRequest<PatreonAccount>
|
|
||||||
{
|
|
||||||
return NSFetchRequest<PatreonAccount>(entityName: "PatreonAccount")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
//
|
|
||||||
// RefreshAttempt.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/31/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
@objc(RefreshAttempt)
|
|
||||||
class RefreshAttempt: NSManagedObject, Fetchable
|
|
||||||
{
|
|
||||||
@NSManaged var identifier: String
|
|
||||||
@NSManaged var date: Date
|
|
||||||
|
|
||||||
@NSManaged var isSuccess: Bool
|
|
||||||
@NSManaged var errorDescription: String?
|
|
||||||
|
|
||||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
|
||||||
{
|
|
||||||
super.init(entity: entity, insertInto: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(identifier: String, result: Result<[String: Result<InstalledApp, Error>], Error>, context: NSManagedObjectContext)
|
|
||||||
{
|
|
||||||
super.init(entity: RefreshAttempt.entity(), insertInto: context)
|
|
||||||
|
|
||||||
self.identifier = identifier
|
|
||||||
self.date = Date()
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let results = try result.get()
|
|
||||||
|
|
||||||
for (_, result) in results
|
|
||||||
{
|
|
||||||
guard case let .failure(error) = result else { continue }
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
self.isSuccess = true
|
|
||||||
self.errorDescription = nil
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
self.isSuccess = false
|
|
||||||
self.errorDescription = error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension RefreshAttempt
|
|
||||||
{
|
|
||||||
@nonobjc class func fetchRequest() -> NSFetchRequest<RefreshAttempt>
|
|
||||||
{
|
|
||||||
return NSFetchRequest<RefreshAttempt>(entityName: "RefreshAttempt")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
//
|
|
||||||
// Source.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/30/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
extension Source
|
|
||||||
{
|
|
||||||
static let altStoreIdentifier = "com.rileytestut.AltStore"
|
|
||||||
static let altStoreSourceURL = URL(string: "https://cdn.altstore.io/file/altstore/apps.json")!
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc(Source)
|
|
||||||
class Source: NSManagedObject, Fetchable, Decodable
|
|
||||||
{
|
|
||||||
/* Properties */
|
|
||||||
@NSManaged var name: String
|
|
||||||
@NSManaged var identifier: String
|
|
||||||
@NSManaged var sourceURL: URL
|
|
||||||
|
|
||||||
/* Relationships */
|
|
||||||
@objc(apps) @NSManaged private(set) var _apps: NSOrderedSet
|
|
||||||
@objc(newsItems) @NSManaged private(set) var _newsItems: NSOrderedSet
|
|
||||||
|
|
||||||
@nonobjc var apps: [StoreApp] {
|
|
||||||
get {
|
|
||||||
return self._apps.array as! [StoreApp]
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
self._apps = NSOrderedSet(array: newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@nonobjc var newsItems: [NewsItem] {
|
|
||||||
get {
|
|
||||||
return self._newsItems.array as! [NewsItem]
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
self._newsItems = NSOrderedSet(array: newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey
|
|
||||||
{
|
|
||||||
case name
|
|
||||||
case identifier
|
|
||||||
case sourceURL
|
|
||||||
case apps
|
|
||||||
case news
|
|
||||||
}
|
|
||||||
|
|
||||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
|
||||||
{
|
|
||||||
super.init(entity: entity, insertInto: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init(from decoder: Decoder) throws
|
|
||||||
{
|
|
||||||
guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") }
|
|
||||||
|
|
||||||
super.init(entity: Source.entity(), insertInto: nil)
|
|
||||||
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
self.name = try container.decode(String.self, forKey: .name)
|
|
||||||
self.identifier = try container.decode(String.self, forKey: .identifier)
|
|
||||||
self.sourceURL = try container.decode(URL.self, forKey: .sourceURL)
|
|
||||||
|
|
||||||
let apps = try container.decodeIfPresent([StoreApp].self, forKey: .apps) ?? []
|
|
||||||
for (index, app) in apps.enumerated()
|
|
||||||
{
|
|
||||||
app.sortIndex = Int32(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
let newsItems = try container.decodeIfPresent([NewsItem].self, forKey: .news) ?? []
|
|
||||||
for (index, item) in newsItems.enumerated()
|
|
||||||
{
|
|
||||||
item.sortIndex = Int32(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
context.insert(self)
|
|
||||||
|
|
||||||
let appsByID = Dictionary(apps.map { ($0.bundleIdentifier, $0) }, uniquingKeysWith: { (a, b) in return a })
|
|
||||||
|
|
||||||
for newsItem in newsItems
|
|
||||||
{
|
|
||||||
newsItem.source = self
|
|
||||||
|
|
||||||
guard let appID = newsItem.appID else { continue }
|
|
||||||
|
|
||||||
if let storeApp = appsByID[appID]
|
|
||||||
{
|
|
||||||
newsItem.storeApp = storeApp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Must assign after we're inserted into context.
|
|
||||||
self._apps = NSMutableOrderedSet(array: apps)
|
|
||||||
self._newsItems = NSMutableOrderedSet(array: newsItems)
|
|
||||||
|
|
||||||
print("Downloaded Order:", self.apps.map { $0.bundleIdentifier })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Source
|
|
||||||
{
|
|
||||||
class func makeAltStoreSource(in context: NSManagedObjectContext) -> Source
|
|
||||||
{
|
|
||||||
let source = Source(context: context)
|
|
||||||
source.name = "AltStore"
|
|
||||||
source.identifier = Source.altStoreIdentifier
|
|
||||||
source.sourceURL = Source.altStoreSourceURL
|
|
||||||
|
|
||||||
return source
|
|
||||||
}
|
|
||||||
|
|
||||||
class func fetchAltStoreSource(in context: NSManagedObjectContext) -> Source?
|
|
||||||
{
|
|
||||||
let source = Source.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Source.identifier), Source.altStoreIdentifier), in: context)
|
|
||||||
return source
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
//
|
|
||||||
// StoreApp.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 5/20/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
import Roxas
|
|
||||||
import AltSign
|
|
||||||
|
|
||||||
extension StoreApp
|
|
||||||
{
|
|
||||||
#if BETA
|
|
||||||
static let altstoreAppID = "com.rileytestut.AltStore.Beta"
|
|
||||||
static let alternativeAltStoreAppID = "com.rileytestut.AltStore"
|
|
||||||
#else
|
|
||||||
static let altstoreAppID = "com.rileytestut.AltStore"
|
|
||||||
static let alternativeAltStoreAppID = "com.rileytestut.AltStore.Beta"
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc(StoreApp)
|
|
||||||
class StoreApp: NSManagedObject, Decodable, Fetchable
|
|
||||||
{
|
|
||||||
/* Properties */
|
|
||||||
@NSManaged private(set) var name: String
|
|
||||||
@NSManaged private(set) var bundleIdentifier: String
|
|
||||||
@NSManaged private(set) var subtitle: String?
|
|
||||||
|
|
||||||
@NSManaged private(set) var developerName: String
|
|
||||||
@NSManaged private(set) var localizedDescription: String
|
|
||||||
@NSManaged private(set) var size: Int32
|
|
||||||
|
|
||||||
@NSManaged private(set) var iconURL: URL
|
|
||||||
@NSManaged private(set) var screenshotURLs: [URL]
|
|
||||||
|
|
||||||
@NSManaged var version: String
|
|
||||||
@NSManaged private(set) var versionDate: Date
|
|
||||||
@NSManaged private(set) var versionDescription: String?
|
|
||||||
|
|
||||||
@NSManaged private(set) var downloadURL: URL
|
|
||||||
@NSManaged private(set) var tintColor: UIColor?
|
|
||||||
@NSManaged private(set) var isBeta: Bool
|
|
||||||
|
|
||||||
@NSManaged var sortIndex: Int32
|
|
||||||
|
|
||||||
/* Relationships */
|
|
||||||
@NSManaged var installedApp: InstalledApp?
|
|
||||||
@NSManaged var source: Source?
|
|
||||||
@objc(permissions) @NSManaged var _permissions: NSOrderedSet
|
|
||||||
|
|
||||||
@nonobjc var permissions: [AppPermission] {
|
|
||||||
return self._permissions.array as! [AppPermission]
|
|
||||||
}
|
|
||||||
|
|
||||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
|
||||||
{
|
|
||||||
super.init(entity: entity, insertInto: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey
|
|
||||||
{
|
|
||||||
case name
|
|
||||||
case bundleIdentifier
|
|
||||||
case developerName
|
|
||||||
case localizedDescription
|
|
||||||
case version
|
|
||||||
case versionDescription
|
|
||||||
case versionDate
|
|
||||||
case iconURL
|
|
||||||
case screenshotURLs
|
|
||||||
case downloadURL
|
|
||||||
case tintColor
|
|
||||||
case subtitle
|
|
||||||
case permissions
|
|
||||||
case size
|
|
||||||
case isBeta = "beta"
|
|
||||||
}
|
|
||||||
|
|
||||||
required init(from decoder: Decoder) throws
|
|
||||||
{
|
|
||||||
guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") }
|
|
||||||
|
|
||||||
super.init(entity: StoreApp.entity(), insertInto: nil)
|
|
||||||
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
self.name = try container.decode(String.self, forKey: .name)
|
|
||||||
self.bundleIdentifier = try container.decode(String.self, forKey: .bundleIdentifier)
|
|
||||||
self.developerName = try container.decode(String.self, forKey: .developerName)
|
|
||||||
self.localizedDescription = try container.decode(String.self, forKey: .localizedDescription)
|
|
||||||
|
|
||||||
self.subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle)
|
|
||||||
|
|
||||||
self.version = try container.decode(String.self, forKey: .version)
|
|
||||||
self.versionDate = try container.decode(Date.self, forKey: .versionDate)
|
|
||||||
self.versionDescription = try container.decodeIfPresent(String.self, forKey: .versionDescription)
|
|
||||||
|
|
||||||
self.iconURL = try container.decode(URL.self, forKey: .iconURL)
|
|
||||||
self.screenshotURLs = try container.decodeIfPresent([URL].self, forKey: .screenshotURLs) ?? []
|
|
||||||
|
|
||||||
self.downloadURL = try container.decode(URL.self, forKey: .downloadURL)
|
|
||||||
|
|
||||||
if let tintColorHex = try container.decodeIfPresent(String.self, forKey: .tintColor)
|
|
||||||
{
|
|
||||||
guard let tintColor = UIColor(hexString: tintColorHex) else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .tintColor, in: container, debugDescription: "Hex code is invalid.")
|
|
||||||
}
|
|
||||||
|
|
||||||
self.tintColor = tintColor
|
|
||||||
}
|
|
||||||
|
|
||||||
self.size = try container.decode(Int32.self, forKey: .size)
|
|
||||||
self.isBeta = try container.decodeIfPresent(Bool.self, forKey: .isBeta) ?? false
|
|
||||||
|
|
||||||
let permissions = try container.decodeIfPresent([AppPermission].self, forKey: .permissions) ?? []
|
|
||||||
|
|
||||||
context.insert(self)
|
|
||||||
|
|
||||||
// Must assign after we're inserted into context.
|
|
||||||
self._permissions = NSOrderedSet(array: permissions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension StoreApp
|
|
||||||
{
|
|
||||||
@nonobjc class func fetchRequest() -> NSFetchRequest<StoreApp>
|
|
||||||
{
|
|
||||||
return NSFetchRequest<StoreApp>(entityName: "StoreApp")
|
|
||||||
}
|
|
||||||
|
|
||||||
class func makeAltStoreApp(in context: NSManagedObjectContext) -> StoreApp
|
|
||||||
{
|
|
||||||
let app = StoreApp(context: context)
|
|
||||||
app.name = "AltStore"
|
|
||||||
app.bundleIdentifier = StoreApp.altstoreAppID
|
|
||||||
app.developerName = "Riley Testut"
|
|
||||||
app.localizedDescription = "AltStore is an alternative App Store."
|
|
||||||
app.iconURL = URL(string: "https://user-images.githubusercontent.com/705880/63392210-540c5980-c37b-11e9-968c-8742fc68ab2e.png")!
|
|
||||||
app.screenshotURLs = []
|
|
||||||
app.version = "1.0"
|
|
||||||
app.versionDate = Date()
|
|
||||||
app.downloadURL = URL(string: "http://rileytestut.com")!
|
|
||||||
|
|
||||||
#if BETA
|
|
||||||
app.isBeta = true
|
|
||||||
#endif
|
|
||||||
|
|
||||||
return app
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
//
|
|
||||||
// Team.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 5/31/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
import AltSign
|
|
||||||
|
|
||||||
extension ALTTeamType
|
|
||||||
{
|
|
||||||
var localizedDescription: String {
|
|
||||||
switch self
|
|
||||||
{
|
|
||||||
case .free: return NSLocalizedString("Free Developer Account", comment: "")
|
|
||||||
case .individual: return NSLocalizedString("Individual", comment: "")
|
|
||||||
case .organization: return NSLocalizedString("Organization", comment: "")
|
|
||||||
case .unknown: fallthrough
|
|
||||||
@unknown default: return NSLocalizedString("Unknown", comment: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc(Team)
|
|
||||||
class Team: NSManagedObject, Fetchable
|
|
||||||
{
|
|
||||||
/* Properties */
|
|
||||||
@NSManaged var name: String
|
|
||||||
@NSManaged var identifier: String
|
|
||||||
@NSManaged var type: ALTTeamType
|
|
||||||
|
|
||||||
@NSManaged var isActiveTeam: Bool
|
|
||||||
|
|
||||||
/* Relationships */
|
|
||||||
@NSManaged private(set) var account: Account!
|
|
||||||
|
|
||||||
var altTeam: ALTTeam?
|
|
||||||
|
|
||||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
|
||||||
{
|
|
||||||
super.init(entity: entity, insertInto: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(_ team: ALTTeam, account: Account, context: NSManagedObjectContext)
|
|
||||||
{
|
|
||||||
super.init(entity: Team.entity(), insertInto: context)
|
|
||||||
|
|
||||||
self.altTeam = team
|
|
||||||
|
|
||||||
self.name = team.name
|
|
||||||
self.identifier = team.identifier
|
|
||||||
self.type = team.type
|
|
||||||
|
|
||||||
self.account = account
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Team
|
|
||||||
{
|
|
||||||
@nonobjc class func fetchRequest() -> NSFetchRequest<Team>
|
|
||||||
{
|
|
||||||
return NSFetchRequest<Team>(entityName: "Team")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
//
|
|
||||||
// MyAppsComponents.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/17/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class InstalledAppCollectionViewCell: UICollectionViewCell
|
|
||||||
{
|
|
||||||
@IBOutlet var appIconImageView: UIImageView!
|
|
||||||
@IBOutlet var nameLabel: UILabel!
|
|
||||||
@IBOutlet var developerLabel: UILabel!
|
|
||||||
@IBOutlet var refreshButton: PillButton!
|
|
||||||
@IBOutlet var betaBadgeView: UIImageView!
|
|
||||||
}
|
|
||||||
|
|
||||||
class InstalledAppsCollectionHeaderView: UICollectionReusableView
|
|
||||||
{
|
|
||||||
@IBOutlet var textLabel: UILabel!
|
|
||||||
@IBOutlet var button: UIButton!
|
|
||||||
}
|
|
||||||
|
|
||||||
class UpdatesCollectionHeaderView: UICollectionReusableView
|
|
||||||
{
|
|
||||||
let button = PillButton(type: .system)
|
|
||||||
|
|
||||||
override init(frame: CGRect)
|
|
||||||
{
|
|
||||||
super.init(frame: frame)
|
|
||||||
|
|
||||||
self.button.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
self.button.setTitle(">", for: .normal)
|
|
||||||
self.addSubview(self.button)
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([self.button.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -20),
|
|
||||||
self.button.topAnchor.constraint(equalTo: self.topAnchor),
|
|
||||||
self.button.widthAnchor.constraint(equalToConstant: 50),
|
|
||||||
self.button.heightAnchor.constraint(equalToConstant: 26)])
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder aDecoder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,918 +0,0 @@
|
|||||||
//
|
|
||||||
// MyAppsViewController.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/16/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
import AltKit
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
import AltSign
|
|
||||||
|
|
||||||
import Nuke
|
|
||||||
|
|
||||||
private let maximumCollapsedUpdatesCount = 2
|
|
||||||
|
|
||||||
extension MyAppsViewController
|
|
||||||
{
|
|
||||||
private enum Section: Int, CaseIterable
|
|
||||||
{
|
|
||||||
case noUpdates
|
|
||||||
case updates
|
|
||||||
case installedApps
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MyAppsViewController: UICollectionViewController
|
|
||||||
{
|
|
||||||
private lazy var dataSource = self.makeDataSource()
|
|
||||||
private lazy var noUpdatesDataSource = self.makeNoUpdatesDataSource()
|
|
||||||
private lazy var updatesDataSource = self.makeUpdatesDataSource()
|
|
||||||
private lazy var installedAppsDataSource = self.makeInstalledAppsDataSource()
|
|
||||||
|
|
||||||
private var prototypeUpdateCell: UpdateCollectionViewCell!
|
|
||||||
private var longPressGestureRecognizer: UILongPressGestureRecognizer!
|
|
||||||
private var sideloadingProgressView: UIProgressView!
|
|
||||||
|
|
||||||
// State
|
|
||||||
private var isUpdateSectionCollapsed = true
|
|
||||||
private var expandedAppUpdates = Set<String>()
|
|
||||||
private var isRefreshingAllApps = false
|
|
||||||
private var refreshGroup: OperationGroup?
|
|
||||||
private var sideloadingProgress: Progress?
|
|
||||||
|
|
||||||
// Cache
|
|
||||||
private var cachedUpdateSizes = [String: CGSize]()
|
|
||||||
|
|
||||||
private lazy var dateFormatter: DateFormatter = {
|
|
||||||
let dateFormatter = DateFormatter()
|
|
||||||
dateFormatter.dateStyle = .medium
|
|
||||||
dateFormatter.timeStyle = .none
|
|
||||||
return dateFormatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
required init?(coder aDecoder: NSCoder)
|
|
||||||
{
|
|
||||||
super.init(coder: aDecoder)
|
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(MyAppsViewController.didFetchSource(_:)), name: AppManager.didFetchSourceNotification, object: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad()
|
|
||||||
{
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
// Allows us to intercept delegate callbacks.
|
|
||||||
self.updatesDataSource.fetchedResultsController.delegate = self
|
|
||||||
|
|
||||||
self.collectionView.dataSource = self.dataSource
|
|
||||||
self.collectionView.prefetchDataSource = self.dataSource
|
|
||||||
|
|
||||||
self.prototypeUpdateCell = UpdateCollectionViewCell.instantiate(with: UpdateCollectionViewCell.nib!)
|
|
||||||
self.prototypeUpdateCell.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
self.prototypeUpdateCell.contentView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
|
|
||||||
self.collectionView.register(UpdateCollectionViewCell.nib, forCellWithReuseIdentifier: "UpdateCell")
|
|
||||||
self.collectionView.register(UpdatesCollectionHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "UpdatesHeader")
|
|
||||||
|
|
||||||
self.sideloadingProgressView = UIProgressView(progressViewStyle: .bar)
|
|
||||||
self.sideloadingProgressView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
self.sideloadingProgressView.progressTintColor = .altPrimary
|
|
||||||
self.sideloadingProgressView.progress = 0
|
|
||||||
|
|
||||||
#if !BETA
|
|
||||||
self.navigationItem.leftBarButtonItem = nil
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if let navigationBar = self.navigationController?.navigationBar
|
|
||||||
{
|
|
||||||
navigationBar.addSubview(self.sideloadingProgressView)
|
|
||||||
NSLayoutConstraint.activate([self.sideloadingProgressView.leadingAnchor.constraint(equalTo: navigationBar.leadingAnchor),
|
|
||||||
self.sideloadingProgressView.trailingAnchor.constraint(equalTo: navigationBar.trailingAnchor),
|
|
||||||
self.sideloadingProgressView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor)])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gestures
|
|
||||||
self.longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(MyAppsViewController.handleLongPressGesture(_:)))
|
|
||||||
self.collectionView.addGestureRecognizer(self.longPressGestureRecognizer)
|
|
||||||
|
|
||||||
self.registerForPreviewing(with: self, sourceView: self.collectionView)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool)
|
|
||||||
{
|
|
||||||
super.viewWillAppear(animated)
|
|
||||||
|
|
||||||
self.updateDataSource()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?)
|
|
||||||
{
|
|
||||||
guard let identifier = segue.identifier else { return }
|
|
||||||
|
|
||||||
switch identifier
|
|
||||||
{
|
|
||||||
case "showApp", "showUpdate":
|
|
||||||
guard let cell = sender as? UICollectionViewCell, let indexPath = self.collectionView.indexPath(for: cell) else { return }
|
|
||||||
|
|
||||||
let installedApp = self.dataSource.item(at: indexPath)
|
|
||||||
|
|
||||||
let appViewController = segue.destination as! AppViewController
|
|
||||||
appViewController.app = installedApp.storeApp
|
|
||||||
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool
|
|
||||||
{
|
|
||||||
guard identifier == "showApp" else { return true }
|
|
||||||
|
|
||||||
guard let cell = sender as? UICollectionViewCell, let indexPath = self.collectionView.indexPath(for: cell) else { return true }
|
|
||||||
|
|
||||||
let installedApp = self.dataSource.item(at: indexPath)
|
|
||||||
return !installedApp.isSideloaded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension MyAppsViewController
|
|
||||||
{
|
|
||||||
func makeDataSource() -> RSTCompositeCollectionViewPrefetchingDataSource<InstalledApp, UIImage>
|
|
||||||
{
|
|
||||||
let dataSource = RSTCompositeCollectionViewPrefetchingDataSource<InstalledApp, UIImage>(dataSources: [self.noUpdatesDataSource, self.updatesDataSource, self.installedAppsDataSource])
|
|
||||||
dataSource.proxy = self
|
|
||||||
return dataSource
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeNoUpdatesDataSource() -> RSTDynamicCollectionViewPrefetchingDataSource<InstalledApp, UIImage>
|
|
||||||
{
|
|
||||||
let dynamicDataSource = RSTDynamicCollectionViewPrefetchingDataSource<InstalledApp, UIImage>()
|
|
||||||
dynamicDataSource.numberOfSectionsHandler = { 1 }
|
|
||||||
dynamicDataSource.numberOfItemsHandler = { _ in self.updatesDataSource.itemCount == 0 ? 1 : 0 }
|
|
||||||
dynamicDataSource.cellIdentifierHandler = { _ in "NoUpdatesCell" }
|
|
||||||
dynamicDataSource.cellConfigurationHandler = { (cell, _, indexPath) in
|
|
||||||
cell.layer.cornerRadius = 20
|
|
||||||
cell.layer.masksToBounds = true
|
|
||||||
cell.contentView.backgroundColor = UIColor.altPrimary.withAlphaComponent(0.15)
|
|
||||||
}
|
|
||||||
|
|
||||||
return dynamicDataSource
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeUpdatesDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage>
|
|
||||||
{
|
|
||||||
let fetchRequest = InstalledApp.updatesFetchRequest()
|
|
||||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \InstalledApp.storeApp?.versionDate, ascending: true),
|
|
||||||
NSSortDescriptor(keyPath: \InstalledApp.name, ascending: true)]
|
|
||||||
fetchRequest.returnsObjectsAsFaults = false
|
|
||||||
|
|
||||||
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
|
||||||
dataSource.liveFetchLimit = maximumCollapsedUpdatesCount
|
|
||||||
dataSource.cellIdentifierHandler = { _ in "UpdateCell" }
|
|
||||||
dataSource.cellConfigurationHandler = { [weak self] (cell, installedApp, indexPath) in
|
|
||||||
guard let self = self else { return }
|
|
||||||
guard let app = installedApp.storeApp else { return }
|
|
||||||
|
|
||||||
let cell = cell as! UpdateCollectionViewCell
|
|
||||||
cell.tintColor = app.tintColor ?? .altPrimary
|
|
||||||
cell.nameLabel.text = app.name
|
|
||||||
cell.versionDescriptionTextView.text = app.versionDescription
|
|
||||||
cell.appIconImageView.image = nil
|
|
||||||
cell.appIconImageView.isIndicatingActivity = true
|
|
||||||
cell.betaBadgeView.isHidden = !app.isBeta
|
|
||||||
|
|
||||||
cell.updateButton.isIndicatingActivity = false
|
|
||||||
cell.updateButton.addTarget(self, action: #selector(MyAppsViewController.updateApp(_:)), for: .primaryActionTriggered)
|
|
||||||
|
|
||||||
if self.expandedAppUpdates.contains(app.bundleIdentifier)
|
|
||||||
{
|
|
||||||
cell.mode = .expanded
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
cell.mode = .collapsed
|
|
||||||
}
|
|
||||||
|
|
||||||
cell.versionDescriptionTextView.moreButton.addTarget(self, action: #selector(MyAppsViewController.toggleUpdateCellMode(_:)), for: .primaryActionTriggered)
|
|
||||||
|
|
||||||
let progress = AppManager.shared.installationProgress(for: app)
|
|
||||||
cell.updateButton.progress = progress
|
|
||||||
|
|
||||||
cell.dateLabel.text = Date().relativeDateString(since: app.versionDate, dateFormatter: self.dateFormatter)
|
|
||||||
|
|
||||||
cell.setNeedsLayout()
|
|
||||||
}
|
|
||||||
dataSource.prefetchHandler = { (installedApp, indexPath, completionHandler) in
|
|
||||||
guard let iconURL = installedApp.storeApp?.iconURL else { return nil }
|
|
||||||
|
|
||||||
return RSTAsyncBlockOperation() { (operation) in
|
|
||||||
ImagePipeline.shared.loadImage(with: iconURL, 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! UpdateCollectionViewCell
|
|
||||||
cell.appIconImageView.isIndicatingActivity = false
|
|
||||||
cell.appIconImageView.image = image
|
|
||||||
|
|
||||||
if let error = error
|
|
||||||
{
|
|
||||||
print("Error loading image:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dataSource
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeInstalledAppsDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage>
|
|
||||||
{
|
|
||||||
let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest<InstalledApp>
|
|
||||||
fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(InstalledApp.storeApp)]
|
|
||||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true),
|
|
||||||
NSSortDescriptor(keyPath: \InstalledApp.refreshedDate, ascending: false),
|
|
||||||
NSSortDescriptor(keyPath: \InstalledApp.name, ascending: true)]
|
|
||||||
fetchRequest.returnsObjectsAsFaults = false
|
|
||||||
|
|
||||||
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
|
||||||
dataSource.cellIdentifierHandler = { _ in "AppCell" }
|
|
||||||
dataSource.cellConfigurationHandler = { (cell, installedApp, indexPath) in
|
|
||||||
let tintColor = installedApp.storeApp?.tintColor ?? .altPrimary
|
|
||||||
|
|
||||||
let cell = cell as! InstalledAppCollectionViewCell
|
|
||||||
cell.tintColor = tintColor
|
|
||||||
cell.appIconImageView.isIndicatingActivity = true
|
|
||||||
cell.betaBadgeView.isHidden = !(installedApp.storeApp?.isBeta ?? false)
|
|
||||||
|
|
||||||
cell.refreshButton.isIndicatingActivity = false
|
|
||||||
cell.refreshButton.addTarget(self, action: #selector(MyAppsViewController.refreshApp(_:)), for: .primaryActionTriggered)
|
|
||||||
|
|
||||||
let currentDate = Date()
|
|
||||||
|
|
||||||
let numberOfDays = installedApp.expirationDate.numberOfCalendarDays(since: currentDate)
|
|
||||||
|
|
||||||
if numberOfDays == 1
|
|
||||||
{
|
|
||||||
cell.refreshButton.setTitle(NSLocalizedString("1 DAY", comment: ""), for: .normal)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
cell.refreshButton.setTitle(String(format: NSLocalizedString("%@ DAYS", comment: ""), NSNumber(value: numberOfDays)), for: .normal)
|
|
||||||
}
|
|
||||||
|
|
||||||
cell.nameLabel.text = installedApp.name
|
|
||||||
cell.developerLabel.text = installedApp.storeApp?.developerName ?? NSLocalizedString("Sideloaded", comment: "")
|
|
||||||
|
|
||||||
// Make sure refresh button is correct size.
|
|
||||||
cell.layoutIfNeeded()
|
|
||||||
|
|
||||||
switch numberOfDays
|
|
||||||
{
|
|
||||||
case 2...3: cell.refreshButton.tintColor = .refreshOrange
|
|
||||||
case 4...5: cell.refreshButton.tintColor = .refreshYellow
|
|
||||||
case 6...: cell.refreshButton.tintColor = .refreshGreen
|
|
||||||
default: cell.refreshButton.tintColor = .refreshRed
|
|
||||||
}
|
|
||||||
|
|
||||||
if let refreshGroup = self.refreshGroup, let progress = refreshGroup.progress(for: installedApp), progress.fractionCompleted < 1.0
|
|
||||||
{
|
|
||||||
cell.refreshButton.progress = progress
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
cell.refreshButton.progress = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dataSource.prefetchHandler = { (item, indexPath, completion) in
|
|
||||||
let fileURL = item.fileURL
|
|
||||||
|
|
||||||
return BlockOperation {
|
|
||||||
guard let application = ALTApplication(fileURL: fileURL) else {
|
|
||||||
completion(nil, OperationError.invalidApp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let icon = application.icon
|
|
||||||
completion(icon, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
|
|
||||||
let cell = cell as! InstalledAppCollectionViewCell
|
|
||||||
cell.appIconImageView.image = image
|
|
||||||
cell.appIconImageView.isIndicatingActivity = false
|
|
||||||
}
|
|
||||||
|
|
||||||
return dataSource
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateDataSource()
|
|
||||||
{
|
|
||||||
if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated
|
|
||||||
{
|
|
||||||
self.dataSource.predicate = nil
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.dataSource.predicate = NSPredicate(format: "%K == nil OR %K == NO OR %K == %@",
|
|
||||||
#keyPath(InstalledApp.storeApp),
|
|
||||||
#keyPath(InstalledApp.storeApp.isBeta),
|
|
||||||
#keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension MyAppsViewController
|
|
||||||
{
|
|
||||||
func update()
|
|
||||||
{
|
|
||||||
if self.updatesDataSource.itemCount > 0
|
|
||||||
{
|
|
||||||
self.navigationController?.tabBarItem.badgeValue = String(describing: self.updatesDataSource.itemCount)
|
|
||||||
UIApplication.shared.applicationIconBadgeNumber = Int(self.updatesDataSource.itemCount)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.navigationController?.tabBarItem.badgeValue = nil
|
|
||||||
UIApplication.shared.applicationIconBadgeNumber = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.isViewLoaded
|
|
||||||
{
|
|
||||||
UIView.performWithoutAnimation {
|
|
||||||
self.collectionView.reloadSections(IndexSet(integer: Section.updates.rawValue))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func refresh(_ installedApps: [InstalledApp], completionHandler: @escaping (Result<[String : Result<InstalledApp, Error>], Error>) -> Void)
|
|
||||||
{
|
|
||||||
func refresh()
|
|
||||||
{
|
|
||||||
let group = AppManager.shared.refresh(installedApps, presentingViewController: self, group: self.refreshGroup)
|
|
||||||
group.completionHandler = { (result) in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(let error):
|
|
||||||
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
|
|
||||||
toastView.setNeedsLayout()
|
|
||||||
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
|
|
||||||
|
|
||||||
case .success(let results):
|
|
||||||
let failures = results.compactMapValues { (result) -> Error? in
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(OperationError.cancelled): return nil
|
|
||||||
case .failure(let error): return error
|
|
||||||
case .success: return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !failures.isEmpty else { break }
|
|
||||||
|
|
||||||
let localizedText: String
|
|
||||||
let detailText: String?
|
|
||||||
|
|
||||||
if let failure = failures.first, failures.count == 1
|
|
||||||
{
|
|
||||||
localizedText = failure.value.localizedDescription
|
|
||||||
detailText = nil
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
localizedText = String(format: NSLocalizedString("Failed to refresh %@ apps.", comment: ""), NSNumber(value: failures.count))
|
|
||||||
detailText = failures.first?.value.localizedDescription
|
|
||||||
}
|
|
||||||
|
|
||||||
let toastView = ToastView(text: localizedText, detailText: detailText)
|
|
||||||
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.refreshGroup = nil
|
|
||||||
completionHandler(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.refreshGroup = group
|
|
||||||
|
|
||||||
UIView.performWithoutAnimation {
|
|
||||||
self.collectionView.reloadSections(IndexSet(integer: Section.installedApps.rawValue))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if installedApps.contains(where: { $0.bundleIdentifier == StoreApp.altstoreAppID })
|
|
||||||
{
|
|
||||||
let alertController = UIAlertController(title: NSLocalizedString("Refresh AltStore?", comment: ""), message: NSLocalizedString("AltStore will quit when it is finished refreshing.", comment: ""), preferredStyle: .alert)
|
|
||||||
alertController.addAction(UIAlertAction(title: RSTSystemLocalizedString("Cancel"), style: .cancel) { (action) in
|
|
||||||
completionHandler(.failure(OperationError.cancelled))
|
|
||||||
})
|
|
||||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Refresh", comment: ""), style: .default) { (action) in
|
|
||||||
refresh()
|
|
||||||
})
|
|
||||||
self.present(alertController, animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension MyAppsViewController
|
|
||||||
{
|
|
||||||
@IBAction func toggleAppUpdates(_ sender: UIButton)
|
|
||||||
{
|
|
||||||
let visibleCells = self.collectionView.visibleCells
|
|
||||||
|
|
||||||
self.collectionView.performBatchUpdates({
|
|
||||||
|
|
||||||
self.isUpdateSectionCollapsed.toggle()
|
|
||||||
|
|
||||||
UIView.animate(withDuration: 0.3, animations: {
|
|
||||||
if self.isUpdateSectionCollapsed
|
|
||||||
{
|
|
||||||
self.updatesDataSource.liveFetchLimit = maximumCollapsedUpdatesCount
|
|
||||||
self.expandedAppUpdates.removeAll()
|
|
||||||
|
|
||||||
for case let cell as UpdateCollectionViewCell in visibleCells
|
|
||||||
{
|
|
||||||
cell.mode = .collapsed
|
|
||||||
}
|
|
||||||
|
|
||||||
self.cachedUpdateSizes.removeAll()
|
|
||||||
|
|
||||||
sender.titleLabel?.transform = .identity
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.updatesDataSource.liveFetchLimit = 0
|
|
||||||
|
|
||||||
sender.titleLabel?.transform = CGAffineTransform.identity.rotated(by: .pi)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
self.collectionView.collectionViewLayout.invalidateLayout()
|
|
||||||
|
|
||||||
}, completion: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@IBAction func toggleUpdateCellMode(_ sender: UIButton)
|
|
||||||
{
|
|
||||||
let point = self.collectionView.convert(sender.center, from: sender.superview)
|
|
||||||
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
|
|
||||||
|
|
||||||
let installedApp = self.dataSource.item(at: indexPath)
|
|
||||||
|
|
||||||
let cell = self.collectionView.cellForItem(at: indexPath) as? UpdateCollectionViewCell
|
|
||||||
|
|
||||||
if self.expandedAppUpdates.contains(installedApp.bundleIdentifier)
|
|
||||||
{
|
|
||||||
self.expandedAppUpdates.remove(installedApp.bundleIdentifier)
|
|
||||||
cell?.mode = .collapsed
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.expandedAppUpdates.insert(installedApp.bundleIdentifier)
|
|
||||||
cell?.mode = .expanded
|
|
||||||
}
|
|
||||||
|
|
||||||
self.cachedUpdateSizes[installedApp.bundleIdentifier] = nil
|
|
||||||
|
|
||||||
self.collectionView.performBatchUpdates({
|
|
||||||
self.collectionView.collectionViewLayout.invalidateLayout()
|
|
||||||
}, completion: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@IBAction func refreshApp(_ sender: UIButton)
|
|
||||||
{
|
|
||||||
let point = self.collectionView.convert(sender.center, from: sender.superview)
|
|
||||||
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
|
|
||||||
|
|
||||||
let installedApp = self.dataSource.item(at: indexPath)
|
|
||||||
|
|
||||||
let previousProgress = AppManager.shared.refreshProgress(for: installedApp)
|
|
||||||
guard previousProgress == nil else {
|
|
||||||
previousProgress?.cancel()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.refresh([installedApp]) { (result) in
|
|
||||||
// If an error occured, reload the section so the progress bar is no longer visible.
|
|
||||||
if result.error != nil || result.value?.values.contains(where: { $0.error != nil }) == true
|
|
||||||
{
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.collectionView.reloadSections(IndexSet(integer: Section.installedApps.rawValue))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
print("Finished refreshing with result:", result.error?.localizedDescription ?? "success")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@IBAction func refreshAllApps(_ sender: UIBarButtonItem)
|
|
||||||
{
|
|
||||||
self.isRefreshingAllApps = true
|
|
||||||
self.collectionView.collectionViewLayout.invalidateLayout()
|
|
||||||
|
|
||||||
let installedApps = InstalledApp.fetchAppsForRefreshingAll(in: DatabaseManager.shared.viewContext)
|
|
||||||
|
|
||||||
self.refresh(installedApps) { (result) in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.isRefreshingAllApps = false
|
|
||||||
self.collectionView.reloadSections(IndexSet(integer: Section.installedApps.rawValue))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@IBAction func updateApp(_ sender: UIButton)
|
|
||||||
{
|
|
||||||
let point = self.collectionView.convert(sender.center, from: sender.superview)
|
|
||||||
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
|
|
||||||
|
|
||||||
guard let storeApp = self.dataSource.item(at: indexPath).storeApp else { return }
|
|
||||||
|
|
||||||
let previousProgress = AppManager.shared.installationProgress(for: storeApp)
|
|
||||||
guard previousProgress == nil else {
|
|
||||||
previousProgress?.cancel()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = AppManager.shared.install(storeApp, presentingViewController: self) { (result) in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(OperationError.cancelled):
|
|
||||||
self.collectionView.reloadItems(at: [indexPath])
|
|
||||||
|
|
||||||
case .failure(let error):
|
|
||||||
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
|
|
||||||
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2)
|
|
||||||
|
|
||||||
self.collectionView.reloadItems(at: [indexPath])
|
|
||||||
|
|
||||||
case .success:
|
|
||||||
print("Updated app:", storeApp.bundleIdentifier)
|
|
||||||
// No need to reload, since the the update cell is gone now.
|
|
||||||
}
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.collectionView.reloadItems(at: [indexPath])
|
|
||||||
}
|
|
||||||
|
|
||||||
@IBAction func sideloadApp(_ sender: UIBarButtonItem)
|
|
||||||
{
|
|
||||||
func sideloadApp()
|
|
||||||
{
|
|
||||||
let iOSAppUTI = "com.apple.itunes.ipa" // Declared by the system.
|
|
||||||
|
|
||||||
let documentPickerViewController = UIDocumentPickerViewController(documentTypes: [iOSAppUTI], in: .import)
|
|
||||||
documentPickerViewController.delegate = self
|
|
||||||
self.present(documentPickerViewController, animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
let alertController = UIAlertController(title: NSLocalizedString("Sideload Apps (Beta)", comment: ""), message: NSLocalizedString("You may only install 10 apps + app extensions per week due to Apple's restrictions.\n\nIf you encounter an app that is not able to be sideloaded, please report the app to support@altstore.io.", comment: ""), preferredStyle: .alert)
|
|
||||||
alertController.addAction(UIAlertAction(title: RSTSystemLocalizedString("OK"), style: .default, handler: { (action) in
|
|
||||||
sideloadApp()
|
|
||||||
}))
|
|
||||||
alertController.addAction(.cancel)
|
|
||||||
self.present(alertController, animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func presentAlert(for installedApp: InstalledApp)
|
|
||||||
{
|
|
||||||
let alertController = UIAlertController(title: nil, message: NSLocalizedString("Removing a sideloaded app only removes it from AltStore. You must also delete it from the home screen to fully uninstall the app.", comment: ""), preferredStyle: .actionSheet)
|
|
||||||
alertController.addAction(.cancel)
|
|
||||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Remove", comment: ""), style: .destructive, handler: { (action) in
|
|
||||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
|
||||||
let installedApp = context.object(with: installedApp.objectID) as! InstalledApp
|
|
||||||
context.delete(installedApp)
|
|
||||||
|
|
||||||
do { try context.save() }
|
|
||||||
catch { print("Failed to remove sideloaded app.", error) }
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
self.present(alertController, animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension MyAppsViewController
|
|
||||||
{
|
|
||||||
@objc func didFetchSource(_ notification: Notification)
|
|
||||||
{
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if self.updatesDataSource.fetchedResultsController.fetchedObjects == nil
|
|
||||||
{
|
|
||||||
do { try self.updatesDataSource.fetchedResultsController.performFetch() }
|
|
||||||
catch { print("Error fetching:", error) }
|
|
||||||
}
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func handleLongPressGesture(_ gestureRecognizer: UILongPressGestureRecognizer)
|
|
||||||
{
|
|
||||||
guard gestureRecognizer.state == .began else { return }
|
|
||||||
|
|
||||||
let point = gestureRecognizer.location(in: self.collectionView)
|
|
||||||
|
|
||||||
guard
|
|
||||||
let indexPath = self.collectionView.indexPathForItem(at: point),
|
|
||||||
indexPath.section == Section.installedApps.rawValue
|
|
||||||
else { return }
|
|
||||||
|
|
||||||
let installedApp = self.dataSource.item(at: indexPath)
|
|
||||||
guard installedApp.storeApp == nil else { return }
|
|
||||||
|
|
||||||
self.presentAlert(for: installedApp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MyAppsViewController
|
|
||||||
{
|
|
||||||
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
|
|
||||||
{
|
|
||||||
let section = Section(rawValue: indexPath.section)!
|
|
||||||
|
|
||||||
switch section
|
|
||||||
{
|
|
||||||
case .noUpdates: return UICollectionReusableView()
|
|
||||||
case .updates:
|
|
||||||
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "UpdatesHeader", for: indexPath) as! UpdatesCollectionHeaderView
|
|
||||||
|
|
||||||
UIView.performWithoutAnimation {
|
|
||||||
headerView.button.backgroundColor = UIColor.altPrimary.withAlphaComponent(0.15)
|
|
||||||
headerView.button.setTitle("▾", for: .normal)
|
|
||||||
headerView.button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 28)
|
|
||||||
headerView.button.setTitleColor(.altPrimary, for: .normal)
|
|
||||||
headerView.button.addTarget(self, action: #selector(MyAppsViewController.toggleAppUpdates), for: .primaryActionTriggered)
|
|
||||||
|
|
||||||
if self.isUpdateSectionCollapsed
|
|
||||||
{
|
|
||||||
headerView.button.titleLabel?.transform = .identity
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
headerView.button.titleLabel?.transform = CGAffineTransform.identity.rotated(by: .pi)
|
|
||||||
}
|
|
||||||
|
|
||||||
headerView.isHidden = (self.updatesDataSource.itemCount <= 2)
|
|
||||||
|
|
||||||
headerView.button.layoutIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
return headerView
|
|
||||||
|
|
||||||
case .installedApps:
|
|
||||||
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "InstalledAppsHeader", for: indexPath) as! InstalledAppsCollectionHeaderView
|
|
||||||
|
|
||||||
UIView.performWithoutAnimation {
|
|
||||||
headerView.textLabel.text = NSLocalizedString("Installed", comment: "")
|
|
||||||
|
|
||||||
headerView.button.isIndicatingActivity = false
|
|
||||||
headerView.button.activityIndicatorView.color = .altPrimary
|
|
||||||
headerView.button.setTitle(NSLocalizedString("Refresh All", comment: ""), for: .normal)
|
|
||||||
headerView.button.addTarget(self, action: #selector(MyAppsViewController.refreshAllApps(_:)), for: .primaryActionTriggered)
|
|
||||||
headerView.button.isIndicatingActivity = self.isRefreshingAllApps
|
|
||||||
|
|
||||||
headerView.button.layoutIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
return headerView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
|
||||||
{
|
|
||||||
let section = Section.allCases[indexPath.section]
|
|
||||||
switch section
|
|
||||||
{
|
|
||||||
case .updates:
|
|
||||||
guard let cell = collectionView.cellForItem(at: indexPath) else { break }
|
|
||||||
self.performSegue(withIdentifier: "showUpdate", sender: cell)
|
|
||||||
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MyAppsViewController: UICollectionViewDelegateFlowLayout
|
|
||||||
{
|
|
||||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
|
|
||||||
{
|
|
||||||
let padding = 30 as CGFloat
|
|
||||||
let width = collectionView.bounds.width - padding
|
|
||||||
|
|
||||||
let section = Section.allCases[indexPath.section]
|
|
||||||
switch section
|
|
||||||
{
|
|
||||||
case .noUpdates:
|
|
||||||
let size = CGSize(width: width, height: 44)
|
|
||||||
return size
|
|
||||||
|
|
||||||
case .updates:
|
|
||||||
let item = self.dataSource.item(at: indexPath)
|
|
||||||
|
|
||||||
if let previousHeight = self.cachedUpdateSizes[item.bundleIdentifier]
|
|
||||||
{
|
|
||||||
return previousHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
let widthConstraint = self.prototypeUpdateCell.contentView.widthAnchor.constraint(equalToConstant: width)
|
|
||||||
NSLayoutConstraint.activate([widthConstraint])
|
|
||||||
defer { NSLayoutConstraint.deactivate([widthConstraint]) }
|
|
||||||
|
|
||||||
self.dataSource.cellConfigurationHandler(self.prototypeUpdateCell, item, indexPath)
|
|
||||||
|
|
||||||
let size = self.prototypeUpdateCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
|
||||||
self.cachedUpdateSizes[item.bundleIdentifier] = size
|
|
||||||
return size
|
|
||||||
|
|
||||||
case .installedApps:
|
|
||||||
return CGSize(width: collectionView.bounds.width, height: 60)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize
|
|
||||||
{
|
|
||||||
let section = Section.allCases[section]
|
|
||||||
switch section
|
|
||||||
{
|
|
||||||
case .noUpdates: return .zero
|
|
||||||
case .updates:
|
|
||||||
let height: CGFloat = self.updatesDataSource.itemCount > maximumCollapsedUpdatesCount ? 26 : 0
|
|
||||||
return CGSize(width: collectionView.bounds.width, height: height)
|
|
||||||
|
|
||||||
case .installedApps: return CGSize(width: collectionView.bounds.width, height: 29)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets
|
|
||||||
{
|
|
||||||
let section = Section.allCases[section]
|
|
||||||
switch section
|
|
||||||
{
|
|
||||||
case .noUpdates:
|
|
||||||
guard self.updatesDataSource.itemCount == 0 else { return .zero }
|
|
||||||
return UIEdgeInsets(top: 12, left: 15, bottom: 20, right: 15)
|
|
||||||
|
|
||||||
case .updates:
|
|
||||||
guard self.updatesDataSource.itemCount > 0 else { return .zero }
|
|
||||||
return UIEdgeInsets(top: 12, left: 15, bottom: 20, right: 15)
|
|
||||||
|
|
||||||
case .installedApps: return UIEdgeInsets(top: 12, left: 0, bottom: 20, right: 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MyAppsViewController: NSFetchedResultsControllerDelegate
|
|
||||||
{
|
|
||||||
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>)
|
|
||||||
{
|
|
||||||
// Responding to NSFetchedResultsController updates before the collection view has
|
|
||||||
// been shown may throw exceptions because the collection view cannot accurately
|
|
||||||
// count the number of items before the update. However, if we manually call
|
|
||||||
// performBatchUpdates _before_ responding to updates, the collection view can get
|
|
||||||
// an accurate pre-update item count.
|
|
||||||
self.collectionView.performBatchUpdates(nil, completion: nil)
|
|
||||||
|
|
||||||
self.updatesDataSource.controllerWillChangeContent(controller)
|
|
||||||
}
|
|
||||||
|
|
||||||
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType)
|
|
||||||
{
|
|
||||||
self.updatesDataSource.controller(controller, didChange: sectionInfo, atSectionIndex: UInt(sectionIndex), for: type)
|
|
||||||
}
|
|
||||||
|
|
||||||
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?)
|
|
||||||
{
|
|
||||||
self.updatesDataSource.controller(controller, didChange: anObject, at: indexPath, for: type, newIndexPath: newIndexPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>)
|
|
||||||
{
|
|
||||||
let previousUpdateCount = self.collectionView.numberOfItems(inSection: Section.updates.rawValue)
|
|
||||||
let updateCount = Int(self.updatesDataSource.itemCount)
|
|
||||||
|
|
||||||
if previousUpdateCount == 0 && updateCount > 0
|
|
||||||
{
|
|
||||||
// Remove "No Updates Available" cell.
|
|
||||||
let change = RSTCellContentChange(type: .delete, currentIndexPath: IndexPath(item: 0, section: Section.noUpdates.rawValue), destinationIndexPath: nil)
|
|
||||||
self.collectionView.add(change)
|
|
||||||
}
|
|
||||||
else if previousUpdateCount > 0 && updateCount == 0
|
|
||||||
{
|
|
||||||
// Insert "No Updates Available" cell.
|
|
||||||
let change = RSTCellContentChange(type: .insert, currentIndexPath: nil, destinationIndexPath: IndexPath(item: 0, section: Section.noUpdates.rawValue))
|
|
||||||
self.collectionView.add(change)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.updatesDataSource.controllerDidChangeContent(controller)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MyAppsViewController: UIDocumentPickerDelegate
|
|
||||||
{
|
|
||||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL])
|
|
||||||
{
|
|
||||||
guard let fileURL = urls.first else { return }
|
|
||||||
|
|
||||||
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = true
|
|
||||||
|
|
||||||
DispatchQueue.global().async {
|
|
||||||
let temporaryDirectory = FileManager.default.uniqueTemporaryURL()
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
|
|
||||||
let unzippedApplicationURL = try FileManager.default.unzipAppBundle(at: fileURL, toDirectory: temporaryDirectory)
|
|
||||||
|
|
||||||
guard let application = ALTApplication(fileURL: unzippedApplicationURL) else { return }
|
|
||||||
|
|
||||||
self.sideloadingProgress = AppManager.shared.install(application, presentingViewController: self) { (result) in
|
|
||||||
try? FileManager.default.removeItem(at: temporaryDirectory)
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let error = result.error
|
|
||||||
{
|
|
||||||
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
|
|
||||||
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
print("Successfully installed app:", application.bundleIdentifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = false
|
|
||||||
self.sideloadingProgressView.observedProgress = nil
|
|
||||||
self.sideloadingProgressView.setHidden(true, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.sideloadingProgressView.progress = 0
|
|
||||||
self.sideloadingProgressView.isHidden = false
|
|
||||||
self.sideloadingProgressView.observedProgress = self.sideloadingProgress
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
try? FileManager.default.removeItem(at: temporaryDirectory)
|
|
||||||
|
|
||||||
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MyAppsViewController: UIViewControllerPreviewingDelegate
|
|
||||||
{
|
|
||||||
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController?
|
|
||||||
{
|
|
||||||
guard
|
|
||||||
let indexPath = self.collectionView.indexPathForItem(at: location),
|
|
||||||
let cell = self.collectionView.cellForItem(at: indexPath)
|
|
||||||
else { return nil }
|
|
||||||
|
|
||||||
let section = Section.allCases[indexPath.section]
|
|
||||||
switch section
|
|
||||||
{
|
|
||||||
case .updates:
|
|
||||||
previewingContext.sourceRect = cell.frame
|
|
||||||
|
|
||||||
let app = self.dataSource.item(at: indexPath)
|
|
||||||
guard let storeApp = app.storeApp else { return nil}
|
|
||||||
|
|
||||||
let appViewController = AppViewController.makeAppViewController(app: storeApp)
|
|
||||||
return appViewController
|
|
||||||
|
|
||||||
default: return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController)
|
|
||||||
{
|
|
||||||
let point = CGPoint(x: previewingContext.sourceRect.midX, y: previewingContext.sourceRect.midY)
|
|
||||||
guard let indexPath = self.collectionView.indexPathForItem(at: point), let cell = self.collectionView.cellForItem(at: indexPath) else { return }
|
|
||||||
|
|
||||||
self.performSegue(withIdentifier: "showUpdate", sender: cell)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
//
|
|
||||||
// UpdateCollectionViewCell.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/16/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension UpdateCollectionViewCell
|
|
||||||
{
|
|
||||||
enum Mode
|
|
||||||
{
|
|
||||||
case collapsed
|
|
||||||
case expanded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc class UpdateCollectionViewCell: UICollectionViewCell
|
|
||||||
{
|
|
||||||
var mode: Mode = .expanded {
|
|
||||||
didSet {
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@IBOutlet var appIconImageView: UIImageView!
|
|
||||||
@IBOutlet var nameLabel: UILabel!
|
|
||||||
@IBOutlet var dateLabel: UILabel!
|
|
||||||
@IBOutlet var updateButton: PillButton!
|
|
||||||
@IBOutlet var versionDescriptionTitleLabel: UILabel!
|
|
||||||
@IBOutlet var versionDescriptionTextView: CollapsingTextView!
|
|
||||||
@IBOutlet var betaBadgeView: UIImageView!
|
|
||||||
|
|
||||||
override func awakeFromNib()
|
|
||||||
{
|
|
||||||
super.awakeFromNib()
|
|
||||||
|
|
||||||
self.contentView.layer.cornerRadius = 20
|
|
||||||
self.contentView.layer.masksToBounds = true
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tintColorDidChange()
|
|
||||||
{
|
|
||||||
super.tintColorDidChange()
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes)
|
|
||||||
{
|
|
||||||
// Animates transition to new attributes.
|
|
||||||
let animator = UIViewPropertyAnimator(springTimingParameters: UISpringTimingParameters()) {
|
|
||||||
self.layoutIfNeeded()
|
|
||||||
}
|
|
||||||
animator.startAnimation()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?
|
|
||||||
{
|
|
||||||
let view = super.hitTest(point, with: event)
|
|
||||||
|
|
||||||
if view == self.versionDescriptionTextView
|
|
||||||
{
|
|
||||||
// Forward touches on the text view (but not on the nested "more" button)
|
|
||||||
// so cell selection works as expected.
|
|
||||||
return self
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension UpdateCollectionViewCell
|
|
||||||
{
|
|
||||||
func update()
|
|
||||||
{
|
|
||||||
switch self.mode
|
|
||||||
{
|
|
||||||
case .collapsed: self.versionDescriptionTextView.isCollapsed = true
|
|
||||||
case .expanded: self.versionDescriptionTextView.isCollapsed = false
|
|
||||||
}
|
|
||||||
|
|
||||||
self.versionDescriptionTitleLabel.textColor = self.tintColor
|
|
||||||
self.contentView.backgroundColor = self.tintColor.withAlphaComponent(0.1)
|
|
||||||
|
|
||||||
self.updateButton.setTitleColor(self.tintColor, for: .normal)
|
|
||||||
self.updateButton.backgroundColor = self.tintColor.withAlphaComponent(0.15)
|
|
||||||
self.updateButton.progressTintColor = self.tintColor
|
|
||||||
|
|
||||||
self.setNeedsLayout()
|
|
||||||
self.layoutIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,137 +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="UpdateCell" id="Kqf-Pv-ca3" customClass="UpdateCollectionViewCell" customModule="AltStore" customModuleProvider="target">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="133.5"/>
|
|
||||||
<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="133.5"/>
|
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
|
||||||
<subviews>
|
|
||||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="dmf-hv-bwx">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="133.5"/>
|
|
||||||
<subviews>
|
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="14" translatesAutoresizingMaskIntoConstraints="NO" id="57X-Ep-rfq">
|
|
||||||
<rect key="frame" x="20" y="20" width="340" height="93.5"/>
|
|
||||||
<subviews>
|
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="11" translatesAutoresizingMaskIntoConstraints="NO" id="H0T-dR-3In" userLabel="App Info">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="340" height="65"/>
|
|
||||||
<subviews>
|
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="jg6-wi-ngb" customClass="AppIconImageView" customModule="AltStore" customModuleProvider="target">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="65" height="65"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="height" constant="65" id="W3C-hH-1Ii"/>
|
|
||||||
<constraint firstAttribute="width" secondItem="jg6-wi-ngb" secondAttribute="height" multiplier="1:1" id="vt3-Qt-m21"/>
|
|
||||||
</constraints>
|
|
||||||
</imageView>
|
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="100" axis="vertical" alignment="top" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="2Ii-Hu-4ru">
|
|
||||||
<rect key="frame" x="76" y="14" width="172" height="37"/>
|
|
||||||
<subviews>
|
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" spacing="6" translatesAutoresizingMaskIntoConstraints="NO" id="9Zk-Mp-JI7">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="89.5" height="20.5"/>
|
|
||||||
<subviews>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="251" horizontalCompressionResistancePriority="500" text="Short" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="qmI-m4-Mra">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="42.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="4LS-dp-4VA">
|
|
||||||
<rect key="frame" x="48.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="xaB-Kc-Par">
|
|
||||||
<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="OSL-U2-BKa" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
|
|
||||||
<rect key="frame" x="259" y="17" width="81" 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" constant="81" id="3yj-p0-NuE"/>
|
|
||||||
<constraint firstAttribute="height" constant="31" id="KbP-M6-N3w"/>
|
|
||||||
</constraints>
|
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
|
|
||||||
<state key="normal" title="UPDATE"/>
|
|
||||||
</button>
|
|
||||||
</subviews>
|
|
||||||
</stackView>
|
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="firstBaseline" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="RSR-5W-7tt" userLabel="Release Notes">
|
|
||||||
<rect key="frame" x="0.0" y="79" width="340" height="14.5"/>
|
|
||||||
<subviews>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="1000" verticalHuggingPriority="251" text="What's New" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="h1u-nj-qsP">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="65" height="13.5"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="width" constant="65" id="C7Y-nh-TKJ"/>
|
|
||||||
</constraints>
|
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="11"/>
|
|
||||||
<nil key="textColor"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" text="Version Notes" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="rNs-2O-k3V" customClass="CollapsingTextView" customModule="AltStore" customModuleProvider="target">
|
|
||||||
<rect key="frame" x="75" y="-10" width="265" height="24.5"/>
|
|
||||||
<color key="textColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="13"/>
|
|
||||||
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
|
||||||
</textView>
|
|
||||||
</subviews>
|
|
||||||
</stackView>
|
|
||||||
</subviews>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstItem="H0T-dR-3In" firstAttribute="width" secondItem="57X-Ep-rfq" secondAttribute="width" id="DYI-fa-Egk"/>
|
|
||||||
<constraint firstItem="RSR-5W-7tt" firstAttribute="width" secondItem="57X-Ep-rfq" secondAttribute="width" id="d3x-mH-ODQ"/>
|
|
||||||
</constraints>
|
|
||||||
</stackView>
|
|
||||||
</subviews>
|
|
||||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="bottom" secondItem="57X-Ep-rfq" secondAttribute="bottom" constant="20" id="ArC-R2-jtc"/>
|
|
||||||
<constraint firstItem="57X-Ep-rfq" firstAttribute="leading" secondItem="mdL-JE-wCe" secondAttribute="leading" constant="20" id="PvV-gg-7us"/>
|
|
||||||
<constraint firstItem="57X-Ep-rfq" firstAttribute="top" secondItem="dmf-hv-bwx" secondAttribute="top" constant="20" id="QHM-k8-g0x"/>
|
|
||||||
<constraint firstItem="mdL-JE-wCe" firstAttribute="trailing" secondItem="57X-Ep-rfq" secondAttribute="trailing" constant="15" id="sGL-bx-qIk"/>
|
|
||||||
</constraints>
|
|
||||||
<edgeInsets key="layoutMargins" top="20" left="20" bottom="20" right="20"/>
|
|
||||||
<viewLayoutGuide key="safeArea" id="mdL-JE-wCe"/>
|
|
||||||
</view>
|
|
||||||
</subviews>
|
|
||||||
</view>
|
|
||||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstItem="dmf-hv-bwx" firstAttribute="top" secondItem="Kqf-Pv-ca3" secondAttribute="top" id="7yY-05-eHt"/>
|
|
||||||
<constraint firstAttribute="bottom" secondItem="dmf-hv-bwx" secondAttribute="bottom" id="Rrx-0k-6He"/>
|
|
||||||
<constraint firstItem="dmf-hv-bwx" firstAttribute="leading" secondItem="Kqf-Pv-ca3" secondAttribute="leading" id="W0V-sT-tXo"/>
|
|
||||||
<constraint firstAttribute="trailing" secondItem="dmf-hv-bwx" secondAttribute="trailing" id="tgy-Zi-iZF"/>
|
|
||||||
</constraints>
|
|
||||||
<viewLayoutGuide key="safeArea" id="C6r-zO-INg"/>
|
|
||||||
<connections>
|
|
||||||
<outlet property="appIconImageView" destination="jg6-wi-ngb" id="j83-Dl-GT6"/>
|
|
||||||
<outlet property="betaBadgeView" destination="4LS-dp-4VA" id="Q2Z-AG-Y19"/>
|
|
||||||
<outlet property="dateLabel" destination="xaB-Kc-Par" id="mfG-3C-r7j"/>
|
|
||||||
<outlet property="nameLabel" destination="qmI-m4-Mra" id="LQz-w7-HNb"/>
|
|
||||||
<outlet property="updateButton" destination="OSL-U2-BKa" id="WbI-96-Nel"/>
|
|
||||||
<outlet property="versionDescriptionTextView" destination="rNs-2O-k3V" id="4TC-A3-oxb"/>
|
|
||||||
<outlet property="versionDescriptionTitleLabel" destination="h1u-nj-qsP" id="dnz-Yv-BdY"/>
|
|
||||||
</connections>
|
|
||||||
<point key="canvasLocation" x="618.39999999999998" y="96.251874062968525"/>
|
|
||||||
</collectionViewCell>
|
|
||||||
</objects>
|
|
||||||
<resources>
|
|
||||||
<image name="BetaBadge" width="41" height="17"/>
|
|
||||||
</resources>
|
|
||||||
</document>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
//
|
|
||||||
// NewsCollectionViewCell.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 8/29/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class NewsCollectionViewCell: UICollectionViewCell
|
|
||||||
{
|
|
||||||
@IBOutlet var titleLabel: UILabel!
|
|
||||||
@IBOutlet var captionLabel: UILabel!
|
|
||||||
@IBOutlet var imageView: UIImageView!
|
|
||||||
|
|
||||||
override func awakeFromNib()
|
|
||||||
{
|
|
||||||
super.awakeFromNib()
|
|
||||||
|
|
||||||
self.contentView.layer.cornerRadius = 30
|
|
||||||
self.contentView.clipsToBounds = true
|
|
||||||
|
|
||||||
self.imageView.layer.cornerRadius = 30
|
|
||||||
self.imageView.clipsToBounds = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,459 +0,0 @@
|
|||||||
//
|
|
||||||
// NewsViewController.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 8/29/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import SafariServices
|
|
||||||
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
import Nuke
|
|
||||||
|
|
||||||
private class AppBannerFooterView: UICollectionReusableView
|
|
||||||
{
|
|
||||||
let bannerView = AppBannerView(frame: .zero)
|
|
||||||
let tapGestureRecognizer = UITapGestureRecognizer(target: nil, action: nil)
|
|
||||||
|
|
||||||
override init(frame: CGRect)
|
|
||||||
{
|
|
||||||
super.init(frame: frame)
|
|
||||||
|
|
||||||
self.addSubview(self.bannerView, pinningEdgesWith: UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20))
|
|
||||||
self.addGestureRecognizer(self.tapGestureRecognizer)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder aDecoder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class NewsViewController: UICollectionViewController
|
|
||||||
{
|
|
||||||
private lazy var dataSource = self.makeDataSource()
|
|
||||||
private lazy var placeholderView = RSTPlaceholderView(frame: .zero)
|
|
||||||
|
|
||||||
private var prototypeCell: NewsCollectionViewCell!
|
|
||||||
|
|
||||||
private var loadingState: LoadingState = .loading {
|
|
||||||
didSet {
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache
|
|
||||||
private var cachedCellSizes = [String: CGSize]()
|
|
||||||
|
|
||||||
override func viewDidLoad()
|
|
||||||
{
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
self.prototypeCell = NewsCollectionViewCell.instantiate(with: NewsCollectionViewCell.nib!)
|
|
||||||
self.prototypeCell.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
self.prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
|
|
||||||
self.collectionView.contentInset.bottom = 20
|
|
||||||
|
|
||||||
self.collectionView.dataSource = self.dataSource
|
|
||||||
self.collectionView.prefetchDataSource = self.dataSource
|
|
||||||
|
|
||||||
self.collectionView.register(NewsCollectionViewCell.nib, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
|
||||||
self.collectionView.register(AppBannerFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "AppBanner")
|
|
||||||
|
|
||||||
self.registerForPreviewing(with: self, sourceView: self.collectionView)
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool)
|
|
||||||
{
|
|
||||||
super.viewWillAppear(animated)
|
|
||||||
|
|
||||||
self.fetchSource()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension NewsViewController
|
|
||||||
{
|
|
||||||
func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<NewsItem, UIImage>
|
|
||||||
{
|
|
||||||
let fetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NewsItem>
|
|
||||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \NewsItem.sortIndex, ascending: false)]
|
|
||||||
|
|
||||||
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(NewsItem.date), cacheName: nil)
|
|
||||||
|
|
||||||
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<NewsItem, UIImage>(fetchedResultsController: fetchedResultsController)
|
|
||||||
dataSource.proxy = self
|
|
||||||
dataSource.cellConfigurationHandler = { (cell, newsItem, indexPath) in
|
|
||||||
let cell = cell as! NewsCollectionViewCell
|
|
||||||
cell.titleLabel.text = newsItem.title
|
|
||||||
cell.captionLabel.text = newsItem.caption
|
|
||||||
cell.contentView.backgroundColor = newsItem.tintColor
|
|
||||||
|
|
||||||
cell.imageView.image = nil
|
|
||||||
|
|
||||||
if newsItem.imageURL != nil
|
|
||||||
{
|
|
||||||
cell.imageView.isIndicatingActivity = true
|
|
||||||
cell.imageView.isHidden = false
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
cell.imageView.isIndicatingActivity = false
|
|
||||||
cell.imageView.isHidden = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dataSource.prefetchHandler = { (newsItem, indexPath, completionHandler) in
|
|
||||||
guard let imageURL = newsItem.imageURL else { return nil }
|
|
||||||
|
|
||||||
return RSTAsyncBlockOperation() { (operation) in
|
|
||||||
ImagePipeline.shared.loadImage(with: imageURL, 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! NewsCollectionViewCell
|
|
||||||
cell.imageView.isIndicatingActivity = false
|
|
||||||
cell.imageView.image = image
|
|
||||||
|
|
||||||
if let error = error
|
|
||||||
{
|
|
||||||
print("Error loading image:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dataSource.placeholderView = self.placeholderView
|
|
||||||
|
|
||||||
return dataSource
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchSource()
|
|
||||||
{
|
|
||||||
self.loadingState = .loading
|
|
||||||
|
|
||||||
AppManager.shared.fetchSource() { (result) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let source = try result.get()
|
|
||||||
try source.managedObjectContext?.save()
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.loadingState = .finished(.success(()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if self.dataSource.itemCount > 0
|
|
||||||
{
|
|
||||||
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
|
|
||||||
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.loadingState = .finished(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func update()
|
|
||||||
{
|
|
||||||
switch self.loadingState
|
|
||||||
{
|
|
||||||
case .loading:
|
|
||||||
self.placeholderView.textLabel.isHidden = true
|
|
||||||
self.placeholderView.detailTextLabel.isHidden = false
|
|
||||||
|
|
||||||
self.placeholderView.detailTextLabel.text = NSLocalizedString("Loading...", comment: "")
|
|
||||||
|
|
||||||
self.placeholderView.activityIndicatorView.startAnimating()
|
|
||||||
|
|
||||||
case .finished(.failure(let error)):
|
|
||||||
self.placeholderView.textLabel.isHidden = false
|
|
||||||
self.placeholderView.detailTextLabel.isHidden = false
|
|
||||||
|
|
||||||
self.placeholderView.textLabel.text = NSLocalizedString("Unable to Fetch News", comment: "")
|
|
||||||
self.placeholderView.detailTextLabel.text = error.localizedDescription
|
|
||||||
|
|
||||||
self.placeholderView.activityIndicatorView.stopAnimating()
|
|
||||||
|
|
||||||
case .finished(.success):
|
|
||||||
self.placeholderView.textLabel.isHidden = true
|
|
||||||
self.placeholderView.detailTextLabel.isHidden = true
|
|
||||||
|
|
||||||
self.placeholderView.activityIndicatorView.stopAnimating()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension NewsViewController
|
|
||||||
{
|
|
||||||
@objc func handleTapGesture(_ gestureRecognizer: UITapGestureRecognizer)
|
|
||||||
{
|
|
||||||
guard let footerView = gestureRecognizer.view as? UICollectionReusableView else { return }
|
|
||||||
|
|
||||||
let indexPaths = self.collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter)
|
|
||||||
|
|
||||||
guard let indexPath = indexPaths.first(where: { (indexPath) -> Bool in
|
|
||||||
let supplementaryView = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath)
|
|
||||||
return supplementaryView == footerView
|
|
||||||
}) else { return }
|
|
||||||
|
|
||||||
let item = self.dataSource.item(at: indexPath)
|
|
||||||
guard let storeApp = item.storeApp else { return }
|
|
||||||
|
|
||||||
let appViewController = AppViewController.makeAppViewController(app: storeApp)
|
|
||||||
self.navigationController?.pushViewController(appViewController, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func performAppAction(_ sender: PillButton)
|
|
||||||
{
|
|
||||||
let point = self.collectionView.convert(sender.center, from: sender.superview)
|
|
||||||
let indexPaths = self.collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter)
|
|
||||||
|
|
||||||
guard let indexPath = indexPaths.first(where: { (indexPath) -> Bool in
|
|
||||||
let supplementaryView = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath)
|
|
||||||
return supplementaryView?.frame.contains(point) ?? false
|
|
||||||
}) else { return }
|
|
||||||
|
|
||||||
let app = self.dataSource.item(at: indexPath)
|
|
||||||
guard let storeApp = app.storeApp else { return }
|
|
||||||
|
|
||||||
if let installedApp = app.storeApp?.installedApp
|
|
||||||
{
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = AppManager.shared.install(storeApp, presentingViewController: self) { (result) in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(OperationError.cancelled): break // Ignore
|
|
||||||
case .failure(let error):
|
|
||||||
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
|
|
||||||
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2)
|
|
||||||
|
|
||||||
case .success: print("Installed app:", storeApp.bundleIdentifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
UIView.performWithoutAnimation {
|
|
||||||
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
UIView.performWithoutAnimation {
|
|
||||||
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func open(_ installedApp: InstalledApp)
|
|
||||||
{
|
|
||||||
UIApplication.shared.open(installedApp.openAppURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension NewsViewController
|
|
||||||
{
|
|
||||||
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
|
||||||
{
|
|
||||||
let newsItem = self.dataSource.item(at: indexPath)
|
|
||||||
|
|
||||||
if let externalURL = newsItem.externalURL
|
|
||||||
{
|
|
||||||
let safariViewController = SFSafariViewController(url: externalURL)
|
|
||||||
safariViewController.preferredControlTintColor = newsItem.tintColor
|
|
||||||
self.present(safariViewController, animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
else if let storeApp = newsItem.storeApp
|
|
||||||
{
|
|
||||||
let appViewController = AppViewController.makeAppViewController(app: storeApp)
|
|
||||||
self.navigationController?.pushViewController(appViewController, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
|
|
||||||
{
|
|
||||||
let item = self.dataSource.item(at: indexPath)
|
|
||||||
|
|
||||||
let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "AppBanner", for: indexPath) as! AppBannerFooterView
|
|
||||||
guard let storeApp = item.storeApp else { return footerView }
|
|
||||||
|
|
||||||
footerView.bannerView.titleLabel.text = storeApp.name
|
|
||||||
footerView.bannerView.subtitleLabel.text = storeApp.developerName
|
|
||||||
footerView.bannerView.tintColor = storeApp.tintColor
|
|
||||||
footerView.bannerView.betaBadgeView.isHidden = !storeApp.isBeta
|
|
||||||
footerView.bannerView.button.addTarget(self, action: #selector(NewsViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
|
||||||
footerView.tapGestureRecognizer.addTarget(self, action: #selector(NewsViewController.handleTapGesture(_:)))
|
|
||||||
|
|
||||||
footerView.bannerView.button.isIndicatingActivity = false
|
|
||||||
|
|
||||||
if storeApp.installedApp == nil
|
|
||||||
{
|
|
||||||
footerView.bannerView.button.setTitle(NSLocalizedString("FREE", comment: ""), for: .normal)
|
|
||||||
|
|
||||||
let progress = AppManager.shared.installationProgress(for: storeApp)
|
|
||||||
footerView.bannerView.button.progress = progress
|
|
||||||
footerView.bannerView.button.isInverted = false
|
|
||||||
|
|
||||||
if Date() < storeApp.versionDate
|
|
||||||
{
|
|
||||||
footerView.bannerView.button.countdownDate = storeApp.versionDate
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
footerView.bannerView.button.countdownDate = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
footerView.bannerView.button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
|
|
||||||
footerView.bannerView.button.progress = nil
|
|
||||||
footerView.bannerView.button.isInverted = true
|
|
||||||
footerView.bannerView.button.countdownDate = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
Nuke.loadImage(with: storeApp.iconURL, into: footerView.bannerView.iconImageView)
|
|
||||||
|
|
||||||
return footerView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension NewsViewController: UICollectionViewDelegateFlowLayout
|
|
||||||
{
|
|
||||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
|
|
||||||
{
|
|
||||||
let padding = 40 as CGFloat
|
|
||||||
let width = collectionView.bounds.width - padding
|
|
||||||
|
|
||||||
let item = self.dataSource.item(at: indexPath)
|
|
||||||
|
|
||||||
if let previousSize = self.cachedCellSizes[item.identifier]
|
|
||||||
{
|
|
||||||
return previousSize
|
|
||||||
}
|
|
||||||
|
|
||||||
let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: width)
|
|
||||||
NSLayoutConstraint.activate([widthConstraint])
|
|
||||||
defer { NSLayoutConstraint.deactivate([widthConstraint]) }
|
|
||||||
|
|
||||||
self.dataSource.cellConfigurationHandler(self.prototypeCell, item, indexPath)
|
|
||||||
|
|
||||||
let size = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
|
||||||
self.cachedCellSizes[item.identifier] = size
|
|
||||||
return size
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize
|
|
||||||
{
|
|
||||||
let item = self.dataSource.item(at: IndexPath(row: 0, section: section))
|
|
||||||
|
|
||||||
if item.storeApp != nil
|
|
||||||
{
|
|
||||||
return CGSize(width: 88, height: 88)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return .zero
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets
|
|
||||||
{
|
|
||||||
var insets = UIEdgeInsets(top: 30, left: 20, bottom: 13, right: 20)
|
|
||||||
|
|
||||||
if section == 0
|
|
||||||
{
|
|
||||||
insets.top = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
return insets
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension NewsViewController: UIViewControllerPreviewingDelegate
|
|
||||||
{
|
|
||||||
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController?
|
|
||||||
{
|
|
||||||
if let indexPath = self.collectionView.indexPathForItem(at: location), let cell = self.collectionView.cellForItem(at: indexPath)
|
|
||||||
{
|
|
||||||
// Previewing news item.
|
|
||||||
|
|
||||||
previewingContext.sourceRect = cell.frame
|
|
||||||
|
|
||||||
let newsItem = self.dataSource.item(at: indexPath)
|
|
||||||
|
|
||||||
if let externalURL = newsItem.externalURL
|
|
||||||
{
|
|
||||||
let safariViewController = SFSafariViewController(url: externalURL)
|
|
||||||
safariViewController.preferredControlTintColor = newsItem.tintColor
|
|
||||||
return safariViewController
|
|
||||||
}
|
|
||||||
else if let storeApp = newsItem.storeApp
|
|
||||||
{
|
|
||||||
let appViewController = AppViewController.makeAppViewController(app: storeApp)
|
|
||||||
return appViewController
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Previewing app banner (or nothing).
|
|
||||||
|
|
||||||
let indexPaths = self.collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter)
|
|
||||||
|
|
||||||
guard let indexPath = indexPaths.first(where: { (indexPath) -> Bool in
|
|
||||||
let layoutAttributes = self.collectionView.layoutAttributesForSupplementaryElement(ofKind: UICollectionView.elementKindSectionFooter, at: indexPath)
|
|
||||||
return layoutAttributes?.frame.contains(location) ?? false
|
|
||||||
}) else { return nil }
|
|
||||||
|
|
||||||
guard let layoutAttributes = self.collectionView.layoutAttributesForSupplementaryElement(ofKind: UICollectionView.elementKindSectionFooter, at: indexPath) else { return nil }
|
|
||||||
previewingContext.sourceRect = layoutAttributes.frame
|
|
||||||
|
|
||||||
let item = self.dataSource.item(at: indexPath)
|
|
||||||
guard let storeApp = item.storeApp else { return nil }
|
|
||||||
|
|
||||||
let appViewController = AppViewController.makeAppViewController(app: storeApp)
|
|
||||||
return appViewController
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController)
|
|
||||||
{
|
|
||||||
if let safariViewController = viewControllerToCommit as? SFSafariViewController
|
|
||||||
{
|
|
||||||
self.present(safariViewController, animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.navigationController?.pushViewController(viewControllerToCommit, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
//
|
|
||||||
// Contexts.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 6/20/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CoreData
|
|
||||||
import Network
|
|
||||||
|
|
||||||
import AltSign
|
|
||||||
|
|
||||||
class AppOperationContext
|
|
||||||
{
|
|
||||||
lazy var temporaryDirectory: URL = {
|
|
||||||
let temporaryDirectory = FileManager.default.uniqueTemporaryURL()
|
|
||||||
|
|
||||||
do { try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil) }
|
|
||||||
catch { self.error = error }
|
|
||||||
|
|
||||||
return temporaryDirectory
|
|
||||||
}()
|
|
||||||
|
|
||||||
var bundleIdentifier: String
|
|
||||||
var group: OperationGroup
|
|
||||||
|
|
||||||
var app: ALTApplication?
|
|
||||||
var resignedApp: ALTApplication?
|
|
||||||
|
|
||||||
var connection: NWConnection?
|
|
||||||
|
|
||||||
var installedApp: InstalledApp? {
|
|
||||||
didSet {
|
|
||||||
self.installedAppContext = self.installedApp?.managedObjectContext
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private var installedAppContext: NSManagedObjectContext?
|
|
||||||
|
|
||||||
var isFinished = false
|
|
||||||
|
|
||||||
var error: Error? {
|
|
||||||
get {
|
|
||||||
return _error ?? self.group.error
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
_error = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private var _error: Error?
|
|
||||||
|
|
||||||
init(bundleIdentifier: String, group: OperationGroup)
|
|
||||||
{
|
|
||||||
self.bundleIdentifier = bundleIdentifier
|
|
||||||
self.group = group
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
//
|
|
||||||
// AuthenticationOperation.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 6/5/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
import AltSign
|
|
||||||
|
|
||||||
enum AuthenticationError: LocalizedError
|
|
||||||
{
|
|
||||||
case noTeam
|
|
||||||
case noCertificate
|
|
||||||
|
|
||||||
case missingPrivateKey
|
|
||||||
case missingCertificate
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .noTeam: return NSLocalizedString("Developer team could not be found.", comment: "")
|
|
||||||
case .noCertificate: return NSLocalizedString("Developer certificate could not be found.", comment: "")
|
|
||||||
case .missingPrivateKey: return NSLocalizedString("The certificate's private key could not be found.", comment: "")
|
|
||||||
case .missingCertificate: return NSLocalizedString("The certificate could not be found.", comment: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc(AuthenticationOperation)
|
|
||||||
class AuthenticationOperation: ResultOperation<ALTSigner>
|
|
||||||
{
|
|
||||||
private weak var presentingViewController: UIViewController?
|
|
||||||
|
|
||||||
private lazy var navigationController: UINavigationController = {
|
|
||||||
let navigationController = self.storyboard.instantiateViewController(withIdentifier: "navigationController") as! UINavigationController
|
|
||||||
return navigationController
|
|
||||||
}()
|
|
||||||
|
|
||||||
private lazy var storyboard = UIStoryboard(name: "Authentication", bundle: nil)
|
|
||||||
|
|
||||||
private var appleIDPassword: String?
|
|
||||||
private var shouldShowInstructions = false
|
|
||||||
|
|
||||||
init(presentingViewController: UIViewController?)
|
|
||||||
{
|
|
||||||
self.presentingViewController = presentingViewController
|
|
||||||
|
|
||||||
super.init()
|
|
||||||
|
|
||||||
self.progress.totalUnitCount = 3
|
|
||||||
}
|
|
||||||
|
|
||||||
override func main()
|
|
||||||
{
|
|
||||||
super.main()
|
|
||||||
|
|
||||||
// Sign In
|
|
||||||
self.signIn { (result) in
|
|
||||||
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
|
|
||||||
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(let error): self.finish(.failure(error))
|
|
||||||
case .success(let account):
|
|
||||||
self.progress.completedUnitCount += 1
|
|
||||||
|
|
||||||
// Fetch Team
|
|
||||||
self.fetchTeam(for: account) { (result) in
|
|
||||||
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
|
|
||||||
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(let error): self.finish(.failure(error))
|
|
||||||
case .success(let team):
|
|
||||||
self.progress.completedUnitCount += 1
|
|
||||||
|
|
||||||
// Fetch Certificate
|
|
||||||
self.fetchCertificate(for: team) { (result) in
|
|
||||||
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
|
|
||||||
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(let error): self.finish(.failure(error))
|
|
||||||
case .success(let certificate):
|
|
||||||
self.progress.completedUnitCount += 1
|
|
||||||
|
|
||||||
self.showInstructionsIfNecessary() { (didShowInstructions) in
|
|
||||||
let signer = ALTSigner(team: team, certificate: certificate)
|
|
||||||
self.finish(.success(signer))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func finish(_ result: Result<ALTSigner, Error>)
|
|
||||||
{
|
|
||||||
guard !self.isFinished else { return }
|
|
||||||
|
|
||||||
print("Finished authenticating with result:", result)
|
|
||||||
|
|
||||||
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
|
||||||
context.performAndWait {
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let signer = try result.get()
|
|
||||||
let altAccount = signer.team.account
|
|
||||||
|
|
||||||
// Account
|
|
||||||
let account = Account(altAccount, context: context)
|
|
||||||
account.isActiveAccount = true
|
|
||||||
|
|
||||||
let otherAccountsFetchRequest = Account.fetchRequest() as NSFetchRequest<Account>
|
|
||||||
otherAccountsFetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(Account.identifier), account.identifier)
|
|
||||||
|
|
||||||
let otherAccounts = try context.fetch(otherAccountsFetchRequest)
|
|
||||||
for account in otherAccounts
|
|
||||||
{
|
|
||||||
account.isActiveAccount = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Team
|
|
||||||
let team = Team(signer.team, account: account, context: context)
|
|
||||||
team.isActiveTeam = true
|
|
||||||
|
|
||||||
let otherTeamsFetchRequest = Team.fetchRequest() as NSFetchRequest<Team>
|
|
||||||
otherTeamsFetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(Team.identifier), team.identifier)
|
|
||||||
|
|
||||||
let otherTeams = try context.fetch(otherTeamsFetchRequest)
|
|
||||||
for team in otherTeams
|
|
||||||
{
|
|
||||||
team.isActiveTeam = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save
|
|
||||||
try context.save()
|
|
||||||
|
|
||||||
// Update keychain
|
|
||||||
Keychain.shared.appleIDEmailAddress = altAccount.appleID // "account" may have nil appleID since we just saved.
|
|
||||||
Keychain.shared.appleIDPassword = self.appleIDPassword
|
|
||||||
|
|
||||||
Keychain.shared.signingCertificateSerialNumber = signer.certificate.serialNumber
|
|
||||||
Keychain.shared.signingCertificatePrivateKey = signer.certificate.privateKey
|
|
||||||
|
|
||||||
super.finish(.success(signer))
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
super.finish(.failure(error))
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.navigationController.dismiss(animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension AuthenticationOperation
|
|
||||||
{
|
|
||||||
func present(_ viewController: UIViewController) -> Bool
|
|
||||||
{
|
|
||||||
guard let presentingViewController = self.presentingViewController else { return false }
|
|
||||||
|
|
||||||
self.navigationController.view.tintColor = .white
|
|
||||||
|
|
||||||
if self.navigationController.viewControllers.isEmpty
|
|
||||||
{
|
|
||||||
guard presentingViewController.presentedViewController == nil else { return false }
|
|
||||||
|
|
||||||
self.navigationController.setViewControllers([viewController], animated: false)
|
|
||||||
presentingViewController.present(self.navigationController, animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
viewController.navigationItem.leftBarButtonItem = nil
|
|
||||||
self.navigationController.pushViewController(viewController, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension AuthenticationOperation
|
|
||||||
{
|
|
||||||
func signIn(completionHandler: @escaping (Result<ALTAccount, Swift.Error>) -> Void)
|
|
||||||
{
|
|
||||||
func authenticate()
|
|
||||||
{
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
let authenticationViewController = self.storyboard.instantiateViewController(withIdentifier: "authenticationViewController") as! AuthenticationViewController
|
|
||||||
authenticationViewController.authenticationHandler = { (result) in
|
|
||||||
if let (account, password) = result
|
|
||||||
{
|
|
||||||
// We presented the Auth UI and the user signed in.
|
|
||||||
// In this case, we'll assume we should show the instructions again.
|
|
||||||
self.shouldShowInstructions = true
|
|
||||||
|
|
||||||
self.appleIDPassword = password
|
|
||||||
completionHandler(.success(account))
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
completionHandler(.failure(OperationError.cancelled))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.present(authenticationViewController)
|
|
||||||
{
|
|
||||||
completionHandler(.failure(OperationError.notAuthenticated))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let appleID = Keychain.shared.appleIDEmailAddress, let password = Keychain.shared.appleIDPassword
|
|
||||||
{
|
|
||||||
ALTAppleAPI.shared.authenticate(appleID: appleID, password: password) { (account, error) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
self.appleIDPassword = password
|
|
||||||
|
|
||||||
let account = try Result(account, error).get()
|
|
||||||
completionHandler(.success(account))
|
|
||||||
}
|
|
||||||
catch ALTAppleAPIError.incorrectCredentials
|
|
||||||
{
|
|
||||||
authenticate()
|
|
||||||
}
|
|
||||||
catch ALTAppleAPIError.appSpecificPasswordRequired
|
|
||||||
{
|
|
||||||
authenticate()
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
completionHandler(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
authenticate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchTeam(for account: ALTAccount, completionHandler: @escaping (Result<ALTTeam, Swift.Error>) -> Void)
|
|
||||||
{
|
|
||||||
func selectTeam(from teams: [ALTTeam])
|
|
||||||
{
|
|
||||||
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
|
|
||||||
{
|
|
||||||
return completionHandler(.failure(AuthenticationError.noTeam))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ALTAppleAPI.shared.fetchTeams(for: account) { (teams, error) in
|
|
||||||
switch Result(teams, error)
|
|
||||||
{
|
|
||||||
case .failure(let error): completionHandler(.failure(error))
|
|
||||||
case .success(let teams):
|
|
||||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
|
||||||
if let activeTeam = DatabaseManager.shared.activeTeam(in: context), let altTeam = teams.first(where: { $0.identifier == activeTeam.identifier })
|
|
||||||
{
|
|
||||||
completionHandler(.success(altTeam))
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
selectTeam(from: teams)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchCertificate(for team: ALTTeam, completionHandler: @escaping (Result<ALTCertificate, Swift.Error>) -> Void)
|
|
||||||
{
|
|
||||||
func requestCertificate()
|
|
||||||
{
|
|
||||||
let machineName = "AltStore - " + UIDevice.current.name
|
|
||||||
ALTAppleAPI.shared.addCertificate(machineName: machineName, to: team) { (certificate, error) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let certificate = try Result(certificate, error).get()
|
|
||||||
guard let privateKey = certificate.privateKey else { throw AuthenticationError.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 AuthenticationError.missingCertificate
|
|
||||||
}
|
|
||||||
|
|
||||||
certificate.privateKey = privateKey
|
|
||||||
completionHandler(.success(certificate))
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
completionHandler(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
completionHandler(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func replaceCertificate(from certificates: [ALTCertificate])
|
|
||||||
{
|
|
||||||
guard let certificate = certificates.first else { return completionHandler(.failure(AuthenticationError.noCertificate)) }
|
|
||||||
|
|
||||||
ALTAppleAPI.shared.revoke(certificate, for: team) { (success, error) in
|
|
||||||
if let error = error, !success
|
|
||||||
{
|
|
||||||
completionHandler(.failure(error))
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
requestCertificate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ALTAppleAPI.shared.fetchCertificates(for: team) { (certificates, error) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let certificates = try Result(certificates, error).get()
|
|
||||||
|
|
||||||
if
|
|
||||||
let serialNumber = Keychain.shared.signingCertificateSerialNumber,
|
|
||||||
let privateKey = Keychain.shared.signingCertificatePrivateKey,
|
|
||||||
let certificate = certificates.first(where: { $0.serialNumber == serialNumber })
|
|
||||||
{
|
|
||||||
certificate.privateKey = privateKey
|
|
||||||
completionHandler(.success(certificate))
|
|
||||||
}
|
|
||||||
else if certificates.isEmpty
|
|
||||||
{
|
|
||||||
requestCertificate()
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
replaceCertificate(from: certificates)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
completionHandler(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func showInstructionsIfNecessary(completionHandler: @escaping (Bool) -> Void)
|
|
||||||
{
|
|
||||||
guard self.shouldShowInstructions else { return completionHandler(false) }
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
let instructionsViewController = self.storyboard.instantiateViewController(withIdentifier: "instructionsViewController") as! InstructionsViewController
|
|
||||||
instructionsViewController.showsBottomButton = true
|
|
||||||
instructionsViewController.completionHandler = {
|
|
||||||
completionHandler(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.present(instructionsViewController)
|
|
||||||
{
|
|
||||||
completionHandler(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
//
|
|
||||||
// DownloadAppOperation.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 6/10/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
import AltSign
|
|
||||||
|
|
||||||
@objc(DownloadAppOperation)
|
|
||||||
class DownloadAppOperation: ResultOperation<ALTApplication>
|
|
||||||
{
|
|
||||||
let app: AppProtocol
|
|
||||||
let context: AppOperationContext
|
|
||||||
|
|
||||||
private let bundleIdentifier: String
|
|
||||||
private let sourceURL: URL
|
|
||||||
private let destinationURL: URL
|
|
||||||
|
|
||||||
private let session = URLSession(configuration: .default)
|
|
||||||
|
|
||||||
init(app: AppProtocol, context: AppOperationContext)
|
|
||||||
{
|
|
||||||
self.app = app
|
|
||||||
self.context = context
|
|
||||||
|
|
||||||
self.bundleIdentifier = app.bundleIdentifier
|
|
||||||
self.sourceURL = app.url
|
|
||||||
self.destinationURL = InstalledApp.fileURL(for: app)
|
|
||||||
|
|
||||||
super.init()
|
|
||||||
|
|
||||||
self.progress.totalUnitCount = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
override func main()
|
|
||||||
{
|
|
||||||
super.main()
|
|
||||||
|
|
||||||
if let error = self.context.error
|
|
||||||
{
|
|
||||||
self.finish(.failure(error))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
print("Downloading App:", self.bundleIdentifier)
|
|
||||||
|
|
||||||
func finishOperation(_ result: Result<URL, Error>)
|
|
||||||
{
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let fileURL = try result.get()
|
|
||||||
|
|
||||||
var isDirectory: ObjCBool = false
|
|
||||||
guard FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDirectory) else { throw OperationError.appNotFound }
|
|
||||||
|
|
||||||
let temporaryDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
||||||
try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
defer { try? FileManager.default.removeItem(at: temporaryDirectory) }
|
|
||||||
|
|
||||||
let appBundleURL: URL
|
|
||||||
|
|
||||||
if isDirectory.boolValue
|
|
||||||
{
|
|
||||||
// Directory, so assuming this is .app bundle.
|
|
||||||
guard Bundle(url: fileURL) != nil else { throw OperationError.invalidApp }
|
|
||||||
|
|
||||||
appBundleURL = fileURL
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// File, so assuming this is a .ipa file.
|
|
||||||
appBundleURL = try FileManager.default.unzipAppBundle(at: fileURL, toDirectory: temporaryDirectory)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let application = ALTApplication(fileURL: appBundleURL) else { throw OperationError.invalidApp }
|
|
||||||
|
|
||||||
guard ProcessInfo.processInfo.isOperatingSystemAtLeast(application.minimumiOSVersion) else { throw OperationError.iOSVersionNotSupported(application) }
|
|
||||||
|
|
||||||
try FileManager.default.copyItem(at: appBundleURL, to: self.destinationURL, shouldReplace: true)
|
|
||||||
|
|
||||||
guard let copiedApplication = ALTApplication(fileURL: self.destinationURL) else { throw OperationError.invalidApp }
|
|
||||||
self.finish(.success(copiedApplication))
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
self.finish(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.sourceURL.isFileURL
|
|
||||||
{
|
|
||||||
finishOperation(.success(self.sourceURL))
|
|
||||||
|
|
||||||
self.progress.completedUnitCount += 1
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
let downloadTask = self.session.downloadTask(with: self.sourceURL) { (fileURL, response, error) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let (fileURL, _) = try Result((fileURL, response), error).get()
|
|
||||||
finishOperation(.success(fileURL))
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
finishOperation(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.progress.addChild(downloadTask.progress, withPendingUnitCount: 1)
|
|
||||||
|
|
||||||
downloadTask.resume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
//
|
|
||||||
// FetchSourceOperation.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 7/30/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
@objc(FetchSourceOperation)
|
|
||||||
class FetchSourceOperation: ResultOperation<Source>
|
|
||||||
{
|
|
||||||
let sourceURL: URL
|
|
||||||
|
|
||||||
private let session: URLSession
|
|
||||||
|
|
||||||
private lazy var dateFormatter: ISO8601DateFormatter = {
|
|
||||||
let dateFormatter = ISO8601DateFormatter()
|
|
||||||
return dateFormatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
init(sourceURL: URL)
|
|
||||||
{
|
|
||||||
self.sourceURL = sourceURL
|
|
||||||
|
|
||||||
let configuration = URLSessionConfiguration.default
|
|
||||||
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
|
||||||
configuration.urlCache = nil
|
|
||||||
|
|
||||||
self.session = URLSession(configuration: configuration)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func main()
|
|
||||||
{
|
|
||||||
super.main()
|
|
||||||
|
|
||||||
let dataTask = self.session.dataTask(with: self.sourceURL) { (data, response, error) in
|
|
||||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let (data, _) = try Result((data, response), error).get()
|
|
||||||
|
|
||||||
let decoder = JSONDecoder()
|
|
||||||
decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in
|
|
||||||
let container = try decoder.singleValueContainer()
|
|
||||||
let text = try container.decode(String.self)
|
|
||||||
|
|
||||||
// Full ISO8601 Format.
|
|
||||||
self.dateFormatter.formatOptions = [.withFullDate, .withFullTime, .withTimeZone]
|
|
||||||
if let date = self.dateFormatter.date(from: text)
|
|
||||||
{
|
|
||||||
return date
|
|
||||||
}
|
|
||||||
|
|
||||||
// Just date portion of ISO8601.
|
|
||||||
self.dateFormatter.formatOptions = [.withFullDate]
|
|
||||||
if let date = self.dateFormatter.date(from: text)
|
|
||||||
{
|
|
||||||
return date
|
|
||||||
}
|
|
||||||
|
|
||||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Date is in invalid format.")
|
|
||||||
})
|
|
||||||
|
|
||||||
decoder.managedObjectContext = context
|
|
||||||
|
|
||||||
let source = try decoder.decode(Source.self, from: data)
|
|
||||||
self.finish(.success(source))
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
self.finish(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.progress.addChild(dataTask.progress, withPendingUnitCount: 1)
|
|
||||||
|
|
||||||
dataTask.resume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
//
|
|
||||||
// FindServerOperation.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 9/8/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
@objc(FindServerOperation)
|
|
||||||
class FindServerOperation: ResultOperation<Server>
|
|
||||||
{
|
|
||||||
let group: OperationGroup
|
|
||||||
|
|
||||||
init(group: OperationGroup)
|
|
||||||
{
|
|
||||||
self.group = group
|
|
||||||
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func main()
|
|
||||||
{
|
|
||||||
super.main()
|
|
||||||
|
|
||||||
if let error = self.group.error
|
|
||||||
{
|
|
||||||
self.finish(.failure(error))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if let server = ServerManager.shared.discoveredServers.first(where: { $0.isPreferred })
|
|
||||||
{
|
|
||||||
// Preferred server.
|
|
||||||
self.finish(.success(server))
|
|
||||||
}
|
|
||||||
else if let server = ServerManager.shared.discoveredServers.first
|
|
||||||
{
|
|
||||||
// Any available server.
|
|
||||||
self.finish(.success(server))
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// No servers.
|
|
||||||
self.finish(.failure(ConnectionError.serverNotFound))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
//
|
|
||||||
// InstallAppOperation.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 6/19/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Network
|
|
||||||
|
|
||||||
import AltKit
|
|
||||||
import AltSign
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
@objc(InstallAppOperation)
|
|
||||||
class InstallAppOperation: ResultOperation<InstalledApp>
|
|
||||||
{
|
|
||||||
let context: AppOperationContext
|
|
||||||
|
|
||||||
init(context: AppOperationContext)
|
|
||||||
{
|
|
||||||
self.context = context
|
|
||||||
|
|
||||||
super.init()
|
|
||||||
|
|
||||||
self.progress.totalUnitCount = 100
|
|
||||||
}
|
|
||||||
|
|
||||||
override func main()
|
|
||||||
{
|
|
||||||
super.main()
|
|
||||||
|
|
||||||
if let error = self.context.error
|
|
||||||
{
|
|
||||||
self.finish(.failure(error))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard
|
|
||||||
let resignedApp = self.context.resignedApp,
|
|
||||||
let connection = self.context.connection,
|
|
||||||
let server = self.context.group.server
|
|
||||||
else { return self.finish(.failure(OperationError.invalidParameters)) }
|
|
||||||
|
|
||||||
let backgroundContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
|
||||||
backgroundContext.perform {
|
|
||||||
let installedApp: InstalledApp
|
|
||||||
|
|
||||||
// Fetch + update rather than insert + resolve merge conflicts to prevent potential context-level conflicts.
|
|
||||||
if let app = InstalledApp.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(InstalledApp.bundleIdentifier), self.context.bundleIdentifier), in: backgroundContext)
|
|
||||||
{
|
|
||||||
installedApp = app
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
installedApp = InstalledApp(resignedApp: resignedApp, originalBundleIdentifier: self.context.bundleIdentifier, context: backgroundContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
installedApp.version = resignedApp.version
|
|
||||||
|
|
||||||
if let profile = resignedApp.provisioningProfile
|
|
||||||
{
|
|
||||||
installedApp.refreshedDate = profile.creationDate
|
|
||||||
installedApp.expirationDate = profile.expirationDate
|
|
||||||
}
|
|
||||||
|
|
||||||
self.context.group.beginInstallationHandler?(installedApp)
|
|
||||||
|
|
||||||
let request = BeginInstallationRequest()
|
|
||||||
server.send(request, via: connection) { (result) in
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(let error): self.finish(.failure(error))
|
|
||||||
case .success:
|
|
||||||
|
|
||||||
self.receive(from: connection, server: server) { (result) in
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .success:
|
|
||||||
backgroundContext.perform {
|
|
||||||
installedApp.refreshedDate = Date()
|
|
||||||
self.finish(.success(installedApp))
|
|
||||||
}
|
|
||||||
|
|
||||||
case .failure(let error):
|
|
||||||
self.finish(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func receive(from connection: NWConnection, server: Server, completionHandler: @escaping (Result<Void, Error>) -> Void)
|
|
||||||
{
|
|
||||||
server.receive(ServerResponse.self, from: connection) { (result) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let response = try result.get()
|
|
||||||
print(response)
|
|
||||||
|
|
||||||
if let error = response.error
|
|
||||||
{
|
|
||||||
completionHandler(.failure(error))
|
|
||||||
}
|
|
||||||
else if response.progress == 1.0
|
|
||||||
{
|
|
||||||
self.progress.completedUnitCount = self.progress.totalUnitCount
|
|
||||||
completionHandler(.success(()))
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.progress.completedUnitCount = Int64(response.progress * 100)
|
|
||||||
self.receive(from: connection, server: server, completionHandler: completionHandler)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
completionHandler(.failure(ALTServerError(error)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
//
|
|
||||||
// Operation.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 6/7/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
class ResultOperation<ResultType>: Operation
|
|
||||||
{
|
|
||||||
var resultHandler: ((Result<ResultType, Error>) -> Void)?
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
override func finish()
|
|
||||||
{
|
|
||||||
super.finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
func finish(_ result: Result<ResultType, Error>)
|
|
||||||
{
|
|
||||||
guard !self.isFinished else { return }
|
|
||||||
|
|
||||||
if self.isCancelled
|
|
||||||
{
|
|
||||||
self.resultHandler?(.failure(OperationError.cancelled))
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.resultHandler?(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
super.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Operation: RSTOperation, ProgressReporting
|
|
||||||
{
|
|
||||||
let progress = Progress.discreteProgress(totalUnitCount: 1)
|
|
||||||
|
|
||||||
private var backgroundTaskID: UIBackgroundTaskIdentifier?
|
|
||||||
|
|
||||||
override var isAsynchronous: Bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override init()
|
|
||||||
{
|
|
||||||
super.init()
|
|
||||||
|
|
||||||
self.progress.cancellationHandler = { [weak self] in self?.cancel() }
|
|
||||||
}
|
|
||||||
|
|
||||||
override func cancel()
|
|
||||||
{
|
|
||||||
super.cancel()
|
|
||||||
|
|
||||||
if !self.progress.isCancelled
|
|
||||||
{
|
|
||||||
self.progress.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func main()
|
|
||||||
{
|
|
||||||
super.main()
|
|
||||||
|
|
||||||
let name = "com.altstore." + NSStringFromClass(type(of: self))
|
|
||||||
self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
|
|
||||||
guard let backgroundTask = self?.backgroundTaskID else { return }
|
|
||||||
|
|
||||||
self?.cancel()
|
|
||||||
|
|
||||||
UIApplication.shared.endBackgroundTask(backgroundTask)
|
|
||||||
self?.backgroundTaskID = .invalid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func finish()
|
|
||||||
{
|
|
||||||
guard !self.isFinished else { return }
|
|
||||||
|
|
||||||
super.finish()
|
|
||||||
|
|
||||||
if let backgroundTaskID = self.backgroundTaskID
|
|
||||||
{
|
|
||||||
UIApplication.shared.endBackgroundTask(backgroundTaskID)
|
|
||||||
self.backgroundTaskID = .invalid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
//
|
|
||||||
// OperationError.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 6/7/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import AltSign
|
|
||||||
|
|
||||||
enum OperationError: LocalizedError
|
|
||||||
{
|
|
||||||
case unknown
|
|
||||||
case unknownResult
|
|
||||||
case cancelled
|
|
||||||
|
|
||||||
case notAuthenticated
|
|
||||||
case appNotFound
|
|
||||||
|
|
||||||
case unknownUDID
|
|
||||||
|
|
||||||
case invalidApp
|
|
||||||
case invalidParameters
|
|
||||||
|
|
||||||
case iOSVersionNotSupported(ALTApplication)
|
|
||||||
|
|
||||||
case noSources
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .unknown: return NSLocalizedString("An unknown error occured.", comment: "")
|
|
||||||
case .unknownResult: return NSLocalizedString("The operation returned an unknown result.", comment: "")
|
|
||||||
case .cancelled: return NSLocalizedString("The operation was cancelled.", comment: "")
|
|
||||||
case .notAuthenticated: return NSLocalizedString("You are not signed in.", comment: "")
|
|
||||||
case .appNotFound: return NSLocalizedString("App not found.", comment: "")
|
|
||||||
case .unknownUDID: return NSLocalizedString("Unknown device UDID.", comment: "")
|
|
||||||
case .invalidApp: return NSLocalizedString("The app is invalid.", comment: "")
|
|
||||||
case .invalidParameters: return NSLocalizedString("Invalid parameters.", comment: "")
|
|
||||||
case .noSources: return NSLocalizedString("There are no AltStore sources.", comment: "")
|
|
||||||
case .iOSVersionNotSupported(let app):
|
|
||||||
let name = app.name
|
|
||||||
|
|
||||||
var version = "iOS \(app.minimumiOSVersion.majorVersion).\(app.minimumiOSVersion.minorVersion)"
|
|
||||||
if app.minimumiOSVersion.patchVersion > 0
|
|
||||||
{
|
|
||||||
version += ".\(app.minimumiOSVersion.patchVersion)"
|
|
||||||
}
|
|
||||||
|
|
||||||
let localizedDescription = String(format: NSLocalizedString("%@ requires %@.", comment: ""), name, version)
|
|
||||||
return localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
//
|
|
||||||
// OperationGroup.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 6/20/19.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
import AltSign
|
|
||||||
|
|
||||||
class OperationGroup
|
|
||||||
{
|
|
||||||
let progress = Progress.discreteProgress(totalUnitCount: 0)
|
|
||||||
|
|
||||||
var completionHandler: ((Result<[String: Result<InstalledApp, Error>], Error>) -> Void)?
|
|
||||||
var beginInstallationHandler: ((InstalledApp) -> Void)?
|
|
||||||
|
|
||||||
var server: Server?
|
|
||||||
var signer: ALTSigner?
|
|
||||||
|
|
||||||
var error: Error?
|
|
||||||
|
|
||||||
var results = [String: Result<InstalledApp, Error>]()
|
|
||||||
|
|
||||||
private var progressByBundleIdentifier = [String: Progress]()
|
|
||||||
|
|
||||||
private let operationQueue = OperationQueue()
|
|
||||||
private let installOperationQueue = OperationQueue()
|
|
||||||
|
|
||||||
init()
|
|
||||||
{
|
|
||||||
// Enforce only one installation at a time.
|
|
||||||
self.installOperationQueue.maxConcurrentOperationCount = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancel()
|
|
||||||
{
|
|
||||||
self.operationQueue.cancelAllOperations()
|
|
||||||
self.installOperationQueue.cancelAllOperations()
|
|
||||||
}
|
|
||||||
|
|
||||||
func addOperations(_ operations: [Operation])
|
|
||||||
{
|
|
||||||
for operation in operations
|
|
||||||
{
|
|
||||||
if let installOperation = operation as? InstallAppOperation
|
|
||||||
{
|
|
||||||
if let previousOperation = self.installOperationQueue.operations.last
|
|
||||||
{
|
|
||||||
// Ensures they execute in the order they're added, since isReady is still false at this point.
|
|
||||||
installOperation.addDependency(previousOperation)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.installOperationQueue.addOperation(installOperation)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.operationQueue.addOperation(operation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func set(_ progress: Progress, for app: AppProtocol)
|
|
||||||
{
|
|
||||||
self.progressByBundleIdentifier[app.bundleIdentifier] = progress
|
|
||||||
|
|
||||||
self.progress.totalUnitCount += 1
|
|
||||||
self.progress.addChild(progress, withPendingUnitCount: 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func progress(for app: AppProtocol) -> Progress?
|
|
||||||
{
|
|
||||||
let progress = self.progressByBundleIdentifier[app.bundleIdentifier]
|
|
||||||
return progress
|
|
||||||
}
|
|
||||||
}
|
|
||||||