mirror of
https://github.com/balena-io/etcher.git
synced 2025-11-09 18:38:32 +00:00
Compare commits
907 Commits
v1.5.70
...
aethernet/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec7e0b745e | ||
|
|
d5ba1ea5e1 | ||
|
|
54d3636a22 | ||
|
|
45f6ee667d | ||
|
|
d25eda9a7d | ||
|
|
7420283249 | ||
|
|
453952440f | ||
|
|
2475d576c7 | ||
|
|
8cd6da1260 | ||
|
|
82dd4fc1d1 | ||
|
|
33fe4b2c1a | ||
|
|
b1c1188107 | ||
|
|
63b45aefae | ||
|
|
f79cb0fac5 | ||
|
|
ec42892c7c | ||
|
|
371716fe6a | ||
|
|
d5fb6bec15 | ||
|
|
c5e7bf26d7 | ||
|
|
e3072ac416 | ||
|
|
dfaf06e4cf | ||
|
|
6e24d25576 | ||
|
|
b59b171e43 | ||
|
|
28726584c2 | ||
|
|
00b151311a | ||
|
|
36c813714b | ||
|
|
2ae6764dd9 | ||
|
|
debefc9652 | ||
|
|
b068b847c7 | ||
|
|
6c410c07ce | ||
|
|
c01206c1f3 | ||
|
|
2e85fb45de | ||
|
|
67513e384d | ||
|
|
828dafa493 | ||
|
|
5c5a761222 | ||
|
|
fab10e5fc5 | ||
|
|
797345fc1c | ||
|
|
a0388a43c3 | ||
|
|
f5b0a3023b | ||
|
|
dc1d7bd1fd | ||
|
|
9d674321b6 | ||
|
|
f9c8378d6a | ||
|
|
65da751a52 | ||
|
|
72142be0de | ||
|
|
11cea7c926 | ||
|
|
8d46ee4c22 | ||
|
|
d63c09e2c2 | ||
|
|
c9e9d7d109 | ||
|
|
2412d20eb4 | ||
|
|
7f90d23a12 | ||
|
|
b9a82be29b | ||
|
|
638673ba5e | ||
|
|
898fe4f216 | ||
|
|
7e805662d1 | ||
|
|
baf59c73ac | ||
|
|
38ad9c97c6 | ||
|
|
8fc574f059 | ||
|
|
78b0f00e88 | ||
|
|
0f10f2d483 | ||
|
|
eb5f5bbb9e | ||
|
|
67d26ff790 | ||
|
|
17f2008d88 | ||
|
|
db1bf7e488 | ||
|
|
4b786b8a9f | ||
|
|
fdfa0d3258 | ||
|
|
757aa77d89 | ||
|
|
d70ea06565 | ||
|
|
f2ebd10053 | ||
|
|
cd67b442c9 | ||
|
|
852c83c4fb | ||
|
|
e3b2ee3b83 | ||
|
|
927a026b86 | ||
|
|
c851e1d54f | ||
|
|
e6fdca171f | ||
|
|
c9cfb87733 | ||
|
|
b0b7c53294 | ||
|
|
e8dc6579fe | ||
|
|
f0747abe3f | ||
|
|
32fab87340 | ||
|
|
adcd8e0325 | ||
|
|
7b5808eb2b | ||
|
|
a8f7422cf5 | ||
|
|
5ae9a26361 | ||
|
|
cf1fdb8c5f | ||
|
|
bf7ebde100 | ||
|
|
88c5fa5035 | ||
|
|
887b0dd538 | ||
|
|
364d1db56a | ||
|
|
c431222909 | ||
|
|
55a0f68b97 | ||
|
|
af2563dfc2 | ||
|
|
33f8851083 | ||
|
|
fe1f19b9fa | ||
|
|
871cf3ec0a | ||
|
|
686a5837b6 | ||
|
|
23f2dd5ce5 | ||
|
|
d5d39b395b | ||
|
|
2acad790d3 | ||
|
|
30133306d6 | ||
|
|
04a62f2ad8 | ||
|
|
17858a7d72 | ||
|
|
620307568f | ||
|
|
a349c5d9ac | ||
|
|
0d740ad12d | ||
|
|
85a3f28869 | ||
|
|
dbd5397405 | ||
|
|
85c183b9ef | ||
|
|
0d0af1d1dd | ||
|
|
ad423fc187 | ||
|
|
d8b2a7a236 | ||
|
|
13ec8cbe98 | ||
|
|
a7cae23612 | ||
|
|
86bb093f3d | ||
|
|
997e1eb2f2 | ||
|
|
34cc8b8933 | ||
|
|
f26b074811 | ||
|
|
adaa07b4b0 | ||
|
|
96f4569342 | ||
|
|
be190c6c80 | ||
|
|
809617a82d | ||
|
|
df02732002 | ||
|
|
d35f3c3049 | ||
|
|
8b047e3b14 | ||
|
|
fa41d21e27 | ||
|
|
54e6c5e2c1 | ||
|
|
43fc3dd7eb | ||
|
|
12a1340c8e | ||
|
|
cf8b5790a1 | ||
|
|
659d85a833 | ||
|
|
96c44d31c9 | ||
|
|
ba812b4f64 | ||
|
|
4087258fbd | ||
|
|
955be13129 | ||
|
|
32011c0dea | ||
|
|
b68325c71c | ||
|
|
84bce86fce | ||
|
|
d68eab1dda | ||
|
|
09cf014d14 | ||
|
|
d5bab5805f | ||
|
|
b5ab500a14 | ||
|
|
49253d37c9 | ||
|
|
97cf3b25ad | ||
|
|
99862b95a5 | ||
|
|
8b765d58e5 | ||
|
|
8f566e45b8 | ||
|
|
b8af86e30c | ||
|
|
784f193b6d | ||
|
|
3967adb1b5 | ||
|
|
0667d1110f | ||
|
|
61dd22bdf3 | ||
|
|
24eb8b05b0 | ||
|
|
6991a4950b | ||
|
|
bb169cf674 | ||
|
|
e5d0d2e262 | ||
|
|
72b4d4f4fa | ||
|
|
9b2f2eb4c3 | ||
|
|
ce52ef95a9 | ||
|
|
aa3756ad17 | ||
|
|
73081e726d | ||
|
|
d53dc4149b | ||
|
|
0d5bb4935f | ||
|
|
14aeb0060b | ||
|
|
239726f3ce | ||
|
|
4ed3002716 | ||
|
|
7286fba240 | ||
|
|
895c306fb7 | ||
|
|
f3844d56e2 | ||
|
|
540dc3150a | ||
|
|
035c8dfec3 | ||
|
|
03d6a011db | ||
|
|
27f64650f9 | ||
|
|
ccca009972 | ||
|
|
57a6ceff0e | ||
|
|
30c4baa58b | ||
|
|
a930d77064 | ||
|
|
0d1cfffa5c | ||
|
|
3c7422764c | ||
|
|
55176b9f8f | ||
|
|
156b9314b5 | ||
|
|
76d22280dc | ||
|
|
e4251a3862 | ||
|
|
831339bd2c | ||
|
|
952ea80e15 | ||
|
|
813c497e4b | ||
|
|
1b5b647135 | ||
|
|
7de99003ca | ||
|
|
e09bdd734b | ||
|
|
306e087ec6 | ||
|
|
c6b0178a87 | ||
|
|
4e581ea1ce | ||
|
|
26dc2d19e5 | ||
|
|
b99282acfb | ||
|
|
4e48724d0c | ||
|
|
448ce141d5 | ||
|
|
695f287190 | ||
|
|
4de3271e15 | ||
|
|
77b33b127d | ||
|
|
9cd13ba381 | ||
|
|
9df23c8a3f | ||
|
|
e3618b939e | ||
|
|
6a39f5869a | ||
|
|
fd472efadc | ||
|
|
7e2c2eae63 | ||
|
|
5266571ca4 | ||
|
|
797868c474 | ||
|
|
2c2a5c7c2b | ||
|
|
9e536d5337 | ||
|
|
860e680dd9 | ||
|
|
7bb52aa170 | ||
|
|
1c370f9100 | ||
|
|
ec7c772d0b | ||
|
|
cc0285a77d | ||
|
|
256d3550d1 | ||
|
|
db3a5f3b0a | ||
|
|
0e58edf113 | ||
|
|
db136926a9 | ||
|
|
d84e7211be | ||
|
|
8357cc19d2 | ||
|
|
2752b9fa95 | ||
|
|
0214be4953 | ||
|
|
a4f944e795 | ||
|
|
cd2ebf15fc | ||
|
|
7a7ea374e9 | ||
|
|
330df325f9 | ||
|
|
2fc0882b2e | ||
|
|
4dd779e010 | ||
|
|
3dc54405fe | ||
|
|
3f1aa5bac3 | ||
|
|
8f52fdb900 | ||
|
|
1b93891ed8 | ||
|
|
33adc8ecf8 | ||
|
|
0455f7ea58 | ||
|
|
ea5a167f4f | ||
|
|
8a1c4a4cc8 | ||
|
|
bd8bc81713 | ||
|
|
98a5ddf58a | ||
|
|
6223dbc541 | ||
|
|
7c56621c57 | ||
|
|
a61aa8e2be | ||
|
|
7df4f9615b | ||
|
|
5742452fdf | ||
|
|
fe09f9f862 | ||
|
|
3a4687ea0f | ||
|
|
db6490fb1b | ||
|
|
1642297101 | ||
|
|
5ecd223cfc | ||
|
|
306e40fd7b | ||
|
|
b58249b9c8 | ||
|
|
b23b4b34d0 | ||
|
|
73bc921713 | ||
|
|
f356e4c303 | ||
|
|
9888167f2e | ||
|
|
4561690478 | ||
|
|
576113febf | ||
|
|
cc139bf750 | ||
|
|
ae91958c06 | ||
|
|
33dea6267f | ||
|
|
c9a8bca96f | ||
|
|
8af376e608 | ||
|
|
9ab307df4f | ||
|
|
e8a716f8bb | ||
|
|
a40e64f6cd | ||
|
|
2e53feb38c | ||
|
|
5945ab1f50 | ||
|
|
59d67220d4 | ||
|
|
61610ded84 | ||
|
|
c87a132f40 | ||
|
|
350d4de32b | ||
|
|
f5f9025d6d | ||
|
|
549d744d04 | ||
|
|
6194460dc2 | ||
|
|
8370f638b4 | ||
|
|
ac34c51125 | ||
|
|
b241470fe1 | ||
|
|
179697040c | ||
|
|
335766ed12 | ||
|
|
4c5d052a71 | ||
|
|
86423342a8 | ||
|
|
d8b41552e3 | ||
|
|
11c65fb392 | ||
|
|
bed126506f | ||
|
|
f6aeb52b16 | ||
|
|
a5201942b8 | ||
|
|
c1f7164273 | ||
|
|
6774bf784c | ||
|
|
56ec8b4eac | ||
|
|
35868509af | ||
|
|
3ab6749f49 | ||
|
|
7a012a92bc | ||
|
|
aba01825a0 | ||
|
|
907a3308de | ||
|
|
4366bb372f | ||
|
|
a6f6cd4a19 | ||
|
|
03ee428039 | ||
|
|
8d652d064d | ||
|
|
28adc34239 | ||
|
|
120e9bf42f | ||
|
|
59f54e194b | ||
|
|
c4834e61a7 | ||
|
|
e4d02bc561 | ||
|
|
b9e54e39f7 | ||
|
|
f3c32eac65 | ||
|
|
9a303ab344 | ||
|
|
9c1b55bebc | ||
|
|
30ae4bbd86 | ||
|
|
c6126a980a | ||
|
|
ef90d048ca | ||
|
|
b938132038 | ||
|
|
3cb2e78fe7 | ||
|
|
ea9875ddf0 | ||
|
|
65dacd2ff2 | ||
|
|
a190818827 | ||
|
|
98e33b619b | ||
|
|
685ed715ac | ||
|
|
3cf3c4b398 | ||
|
|
1c2ef4b1d4 | ||
|
|
d22fc91585 | ||
|
|
0a28af5c35 | ||
|
|
0c1e5b88ef | ||
|
|
790201be90 | ||
|
|
d8d379f05e | ||
|
|
b5e9701048 | ||
|
|
292f86d6f5 | ||
|
|
76ca9934c8 | ||
|
|
37b826ee4e | ||
|
|
1e1bd3c508 | ||
|
|
00e8f11913 | ||
|
|
a3c24a26a0 | ||
|
|
4232928ad8 | ||
|
|
b165fb78da | ||
|
|
e9f6c5ead9 | ||
|
|
b2d0c1c9dd | ||
|
|
14d91400a4 | ||
|
|
d0114aece7 | ||
|
|
dff2df4aab | ||
|
|
13159f93ee | ||
|
|
3ece1fd841 | ||
|
|
f46963b6b3 | ||
|
|
b97f4e0031 | ||
|
|
e2d233d74b | ||
|
|
a7ca2e527b | ||
|
|
396a053c0a | ||
|
|
d1a3f1cb88 | ||
|
|
9f96558cdd | ||
|
|
b3bc589d70 | ||
|
|
18d2c28110 | ||
|
|
b272ef296d | ||
|
|
32ca28a3a9 | ||
|
|
4d5e5a3b0b | ||
|
|
8b3f37102d | ||
|
|
4b74253631 | ||
|
|
a81b552b95 | ||
|
|
53f53c0f75 | ||
|
|
fdaf5c69d6 | ||
|
|
061afca5d3 | ||
|
|
ccb08a48f1 | ||
|
|
a8f3d45b12 | ||
|
|
7e333caaf9 | ||
|
|
70229e8684 | ||
|
|
261700389b | ||
|
|
250aed2eb1 | ||
|
|
ed1f008fe2 | ||
|
|
e9ce270dab | ||
|
|
1ee110bc95 | ||
|
|
33dd07c675 | ||
|
|
39ccbbeeda | ||
|
|
55d2400ac7 | ||
|
|
0bdea5c54c | ||
|
|
3be372d49f | ||
|
|
d0c66b2c48 | ||
|
|
65082c4790 | ||
|
|
e87ed9beed | ||
|
|
bc5563d9c2 | ||
|
|
ad83ab5dcc | ||
|
|
0dc1cf9701 | ||
|
|
11489c6538 | ||
|
|
2619d4bc86 | ||
|
|
3730efd350 | ||
|
|
6ece32c546 | ||
|
|
fd9996a3cc | ||
|
|
f06cc89152 | ||
|
|
c1d7ab3fa9 | ||
|
|
b206483c7c | ||
|
|
c3eb8c7b56 | ||
|
|
0849d4f435 | ||
|
|
1dba3ae19b | ||
|
|
f33f2e3771 | ||
|
|
e56aaed973 | ||
|
|
a4659f038e | ||
|
|
cd462818da | ||
|
|
37769efbed | ||
|
|
0f70c4bbce | ||
|
|
48b5e8b9d9 | ||
|
|
1f138f0ecc | ||
|
|
73f67e99ca | ||
|
|
9114da2445 | ||
|
|
554bbcc780 | ||
|
|
4db2289cfd | ||
|
|
c15b56bc23 | ||
|
|
9f52dda6ae | ||
|
|
fadcefb11a | ||
|
|
361c32913c | ||
|
|
5c2042198e | ||
|
|
99df53098c | ||
|
|
aa563c87bd | ||
|
|
1188888956 | ||
|
|
f9d7991dc8 | ||
|
|
53954e81fd | ||
|
|
f82996bfd1 | ||
|
|
b74069eb41 | ||
|
|
e8c7591751 | ||
|
|
3521b61a81 | ||
|
|
93db90c725 | ||
|
|
1dc56aed14 | ||
|
|
d814202424 | ||
|
|
c54856a616 | ||
|
|
fc45df270a | ||
|
|
3cde2faed0 | ||
|
|
b4b8c89aad | ||
|
|
36d05724c0 | ||
|
|
b1e4e681d1 | ||
|
|
3987078c11 | ||
|
|
de0010eb72 | ||
|
|
1f94f44b18 | ||
|
|
fe0b45cae6 | ||
|
|
c32e485f27 | ||
|
|
409b78fc21 | ||
|
|
2f08142f5a | ||
|
|
8c4edaabba | ||
|
|
05497ce85c | ||
|
|
d3df2fe57e | ||
|
|
a0f07082f2 | ||
|
|
b7efa8e1f0 | ||
|
|
3647457bb5 | ||
|
|
2e5a39dcd8 | ||
|
|
edabacfb3a | ||
|
|
f46176fd10 | ||
|
|
2158e20380 | ||
|
|
fa593e33d1 | ||
|
|
50730bd3df | ||
|
|
4e68955981 | ||
|
|
3c0084d012 | ||
|
|
8bd11a01ae | ||
|
|
da3a22d0f6 | ||
|
|
e708212d41 | ||
|
|
a5ceba8435 | ||
|
|
446e8e1253 | ||
|
|
c69b2fa053 | ||
|
|
0597c0e908 | ||
|
|
af2b6bc8ca | ||
|
|
a2c7a542df | ||
|
|
e37ae2743f | ||
|
|
644d955f08 | ||
|
|
e7b4f09021 | ||
|
|
1e0a6a3129 | ||
|
|
ef3b8915d8 | ||
|
|
e58cfd89c5 | ||
|
|
1c52379ee3 | ||
|
|
e2c2b40690 | ||
|
|
bddb89e4a1 | ||
|
|
560ed91e2e | ||
|
|
1f8f7ad7f8 | ||
|
|
a2a0f2ef41 | ||
|
|
40e5fb2287 | ||
|
|
6c49c71b3f | ||
|
|
deb3db0fff | ||
|
|
4872fa3d6e | ||
|
|
640a7409ee | ||
|
|
a7637ad8d4 | ||
|
|
31409c61ca | ||
|
|
e74dc9eb60 | ||
|
|
06997fdf29 | ||
|
|
611e659626 | ||
|
|
e484ae9837 | ||
|
|
7e7ca9524e | ||
|
|
db09b7440d | ||
|
|
e9603505d2 | ||
|
|
0f45f6aca1 | ||
|
|
0a28a7794d | ||
|
|
7c2644ec51 | ||
|
|
ae62812c61 | ||
|
|
68e24df52b | ||
|
|
b9076d01af | ||
|
|
78a5339e3e | ||
|
|
b099770cb1 | ||
|
|
b76366a514 | ||
|
|
eeab351636 | ||
|
|
3e45691d0b | ||
|
|
f9d79521a1 | ||
|
|
14a89b3b8a | ||
|
|
8fa6e618c4 | ||
|
|
093008dee7 | ||
|
|
42838eba09 | ||
|
|
aa72c5d3bb | ||
|
|
bb04098062 | ||
|
|
dda022df37 | ||
|
|
377dfb8e22 | ||
|
|
07befd0bd1 | ||
|
|
2635a410df | ||
|
|
5e5f82c4b5 | ||
|
|
991cbf6b7f | ||
|
|
688d697a99 | ||
|
|
7894a67719 | ||
|
|
7a7ea74984 | ||
|
|
12cd8a39c1 | ||
|
|
2c07538f8f | ||
|
|
c9bfd350ed | ||
|
|
a485d2b4df | ||
|
|
8ed5ff25a5 | ||
|
|
a17a919c37 | ||
|
|
55cafb9268 | ||
|
|
92dfdc6edd | ||
|
|
fff9452509 | ||
|
|
27e560c961 | ||
|
|
34489f0d66 | ||
|
|
b7f8c8368c | ||
|
|
f383f0be6c | ||
|
|
ff08cb44f9 | ||
|
|
6cb914e969 | ||
|
|
a24be20e95 | ||
|
|
08716efbd5 | ||
|
|
24c8ede746 | ||
|
|
548475996c | ||
|
|
7f9add3f1e | ||
|
|
6eab47259e | ||
|
|
46663e3a6f | ||
|
|
9797a2152d | ||
|
|
a7c3431556 | ||
|
|
fef9cd7bec | ||
|
|
b2c4f7a250 | ||
|
|
88ae9fcbd1 | ||
|
|
bc092114c1 | ||
|
|
9f29dc8b76 | ||
|
|
5fbaa3a3db | ||
|
|
0c59168ceb | ||
|
|
540fe90609 | ||
|
|
1f44f3944f | ||
|
|
fbacb8187d | ||
|
|
ac2d4ae8f3 | ||
|
|
a3322e9fd7 | ||
|
|
281f119456 | ||
|
|
140f3452ed | ||
|
|
481be42eb5 | ||
|
|
f2a37079eb | ||
|
|
76fa698995 | ||
|
|
f8e21e2338 | ||
|
|
482c29bc2a | ||
|
|
0bf1ec4958 | ||
|
|
3b105d5a6a | ||
|
|
6d9c81da43 | ||
|
|
c2e23855b3 | ||
|
|
3f59d35fb6 | ||
|
|
44c74f33d9 | ||
|
|
512785e0a9 | ||
|
|
963fc574c3 | ||
|
|
3218fc2c83 | ||
|
|
dc9351713c | ||
|
|
e72049d6e8 | ||
|
|
170126a490 | ||
|
|
7d53d0aadc | ||
|
|
5eac622b8c | ||
|
|
175e41de8d | ||
|
|
61f4762341 | ||
|
|
7c24d1486f | ||
|
|
630f6c691c | ||
|
|
5c5273bd6c | ||
|
|
9bde38df5a | ||
|
|
391e4444d4 | ||
|
|
e5ee0f1961 | ||
|
|
c8737806c0 | ||
|
|
953f572b53 | ||
|
|
05d0f7142d | ||
|
|
ba29d76a00 | ||
|
|
692274691e | ||
|
|
394d3e0bf2 | ||
|
|
784dd03ba7 | ||
|
|
8560189a1e | ||
|
|
098ca9a9a1 | ||
|
|
3ca50a1e2d | ||
|
|
00f193541d | ||
|
|
8ce9eac704 | ||
|
|
76086a8f91 | ||
|
|
9b71772e35 | ||
|
|
72e5631167 | ||
|
|
339c7d56bd | ||
|
|
ba16995070 | ||
|
|
b32c4ee728 | ||
|
|
14e4cbf749 | ||
|
|
406955ca3e | ||
|
|
5a45f8b122 | ||
|
|
129e7e20e8 | ||
|
|
7165a8190b | ||
|
|
07fde0d73f | ||
|
|
a360370c4e | ||
|
|
92cd3d688d | ||
|
|
6554ccf0f8 | ||
|
|
9444f0e1b1 | ||
|
|
d63f5eca0d | ||
|
|
e39fed1f25 | ||
|
|
2dc359b19c | ||
|
|
7aec8a4ae2 | ||
|
|
af9d3ba9f1 | ||
|
|
b0c71b21b3 | ||
|
|
71c7fbd3a2 | ||
|
|
f8cc7c36b4 | ||
|
|
5d95fcb81f | ||
|
|
d481536a3f | ||
|
|
62b42e9254 | ||
|
|
03e3354d50 | ||
|
|
f01f1ddd7a | ||
|
|
2cb58bbbf0 | ||
|
|
2aedea3139 | ||
|
|
59e37182be | ||
|
|
52bdd02a4b | ||
|
|
b1376dfa73 | ||
|
|
37ed18c38b | ||
|
|
b7ad7bd729 | ||
|
|
b43ec4414e | ||
|
|
f05f9d33f9 | ||
|
|
fcc9c5e577 | ||
|
|
3259a8206f | ||
|
|
3fa9611971 | ||
|
|
b749c2d45a | ||
|
|
29e2e9c657 | ||
|
|
f983d88e52 | ||
|
|
1449478c5b | ||
|
|
7e7a669116 | ||
|
|
28f9954661 | ||
|
|
b7e82f7694 | ||
|
|
f0bbd1a1cd | ||
|
|
5f5c66e3f2 | ||
|
|
2fc8b07e29 | ||
|
|
bdb1690a49 | ||
|
|
10b028355f | ||
|
|
a4366556c0 | ||
|
|
9c25cc663a | ||
|
|
ba21da4f0b | ||
|
|
34349f64d5 | ||
|
|
f5c7dc932a | ||
|
|
4880275e7b | ||
|
|
6db0172a50 | ||
|
|
95ff5c98a8 | ||
|
|
e9f9f90137 | ||
|
|
0ebfecc60c | ||
|
|
afa29a0ed1 | ||
|
|
8d707dc815 | ||
|
|
5b509d147f | ||
|
|
bb6d909949 | ||
|
|
8513d63a3e | ||
|
|
d2f3345c7a | ||
|
|
aee3a0a281 | ||
|
|
4752fa6dd2 | ||
|
|
4e08cf3879 | ||
|
|
11bda8e76a | ||
|
|
e33172060f | ||
|
|
0dee6a9888 | ||
|
|
3d855dcbfc | ||
|
|
ed3b7f7971 | ||
|
|
c0a4fb16e2 | ||
|
|
688e7fff9c | ||
|
|
880e56e563 | ||
|
|
bf26d4ec95 | ||
|
|
d5df3de1d7 | ||
|
|
5d005211d4 | ||
|
|
cc08ac9236 | ||
|
|
09a6a340c9 | ||
|
|
2692104ccd | ||
|
|
b1fd539d25 | ||
|
|
33d48fe4f7 | ||
|
|
1ebc8e9362 | ||
|
|
8b5a5241f2 | ||
|
|
959b9ffbac | ||
|
|
c9cbe41f9e | ||
|
|
d62cbdc391 | ||
|
|
31bd8ce7ae | ||
|
|
c25db503e0 | ||
|
|
ac51e6aae3 | ||
|
|
72c9d616fd | ||
|
|
52f80293a2 | ||
|
|
a3a9edd41a | ||
|
|
f9cbff1eec | ||
|
|
b71482284f | ||
|
|
d90e3a816e | ||
|
|
869d875b5f | ||
|
|
fb1a360360 | ||
|
|
943765bd4d | ||
|
|
9280113350 | ||
|
|
627adb1755 | ||
|
|
ad421eae11 | ||
|
|
b0af9d535a | ||
|
|
5ab69dfb7f | ||
|
|
f1214e6ffd | ||
|
|
a09e029216 | ||
|
|
8782c70640 | ||
|
|
7099a36bdb | ||
|
|
7bd8b0c152 | ||
|
|
b1cbf54711 | ||
|
|
84f003d907 | ||
|
|
4257e696da | ||
|
|
c5c0d46ab8 | ||
|
|
b397240664 | ||
|
|
a31e27ee06 | ||
|
|
483d7b6e58 | ||
|
|
bfb6133871 | ||
|
|
917ff89d9d | ||
|
|
ef5762864f | ||
|
|
50586cdb42 | ||
|
|
82a0b8de0c | ||
|
|
6db800d6d2 | ||
|
|
b23bfc2f6e | ||
|
|
929279b35a | ||
|
|
795e4bad5f | ||
|
|
6e20b6034e | ||
|
|
6f34a27bd3 | ||
|
|
240a605977 | ||
|
|
4a6a471345 | ||
|
|
f1be4f50a3 | ||
|
|
8ef32e8081 | ||
|
|
71e02ef833 | ||
|
|
c70d7e475d | ||
|
|
0f31f05e61 | ||
|
|
7971a003cc | ||
|
|
49491b9b8c | ||
|
|
ea11f17954 | ||
|
|
ebd37b9e2f | ||
|
|
5de4fe3d23 | ||
|
|
eb47f1227a | ||
|
|
f84cde7d04 | ||
|
|
4d3eb2887c | ||
|
|
bc631612df | ||
|
|
5d8a211961 | ||
|
|
e62add6893 | ||
|
|
44fc429f64 | ||
|
|
ffe281f25d | ||
|
|
ba39ff433d | ||
|
|
795b8614ad | ||
|
|
745a2f1886 | ||
|
|
9bf58c89d4 | ||
|
|
ee62b9a4c7 | ||
|
|
e6125b893d | ||
|
|
83ed333fa5 | ||
|
|
39ed67d667 | ||
|
|
ac2e973cb0 | ||
|
|
94a0be3b05 | ||
|
|
124e8af649 | ||
|
|
f07ed68d82 | ||
|
|
8f39dbf6b1 | ||
|
|
6dde9ee6c4 | ||
|
|
dbe6fe442d | ||
|
|
1e2ac86ac6 | ||
|
|
83c5ba04cd | ||
|
|
b3f25c176b | ||
|
|
52cf6375eb | ||
|
|
82a3c37c16 | ||
|
|
d63df5a156 | ||
|
|
63ad3739fd | ||
|
|
7eddb16f2f | ||
|
|
7c4f4cacc9 | ||
|
|
dc6ad72b2d | ||
|
|
be729c87af | ||
|
|
4ee83d9da4 | ||
|
|
8b2f06442a | ||
|
|
21181f011f | ||
|
|
b4b099ecb1 | ||
|
|
166b30bb0a | ||
|
|
8eeb81f58e | ||
|
|
0b20a1eeaa | ||
|
|
d8cb8f7815 | ||
|
|
36f79593cf | ||
|
|
1014b25bf5 | ||
|
|
55dcfc1a85 | ||
|
|
9b6a628d51 | ||
|
|
8b5a42073d | ||
|
|
7991d40760 | ||
|
|
4203296414 | ||
|
|
93d319275f | ||
|
|
94d262263c | ||
|
|
ed90f21188 | ||
|
|
80e0231727 | ||
|
|
981197583a | ||
|
|
6f58344e7b | ||
|
|
07be844985 | ||
|
|
45262583e6 | ||
|
|
c113e38531 | ||
|
|
8771f311d7 | ||
|
|
fdec65e9bd | ||
|
|
f8b46dc647 | ||
|
|
847e47b5db | ||
|
|
227bad9e99 | ||
|
|
cb8168de41 | ||
|
|
c200a0c7ac | ||
|
|
81e80572d8 | ||
|
|
2aa6c83714 | ||
|
|
a22ea0b82b | ||
|
|
af64579eb2 | ||
|
|
f2705a611d | ||
|
|
990dcc9d5a | ||
|
|
c09237f0c3 | ||
|
|
571a3533fb | ||
|
|
6fcd9e1595 | ||
|
|
9caa42d257 | ||
|
|
18fdbbaabb | ||
|
|
7381c1c0cb | ||
|
|
2bdcae7209 | ||
|
|
fc694b90b6 | ||
|
|
945cd7ff8e | ||
|
|
3b32ca1e60 | ||
|
|
98611267d5 | ||
|
|
4d53002e5c | ||
|
|
f6b7b0d3d2 | ||
|
|
fbbd7ccf49 | ||
|
|
d41ce65a78 | ||
|
|
c477fd2071 | ||
|
|
7fab8395c8 | ||
|
|
7d72e0c046 | ||
|
|
9ce97be6a4 | ||
|
|
121b69b0c3 | ||
|
|
cb7cc2f276 | ||
|
|
d01849306e | ||
|
|
a4e87982a6 | ||
|
|
e1c3c80c0f | ||
|
|
fd6346ed59 | ||
|
|
2e4f7b5a8c | ||
|
|
d812d4e12e | ||
|
|
10b3f09e7e | ||
|
|
2d3776844c | ||
|
|
914a4574de | ||
|
|
2b3c84f21a | ||
|
|
f4eb1af8d0 | ||
|
|
c01fc332d2 | ||
|
|
b8fdbc3e94 | ||
|
|
3c7c55364b | ||
|
|
bff4355a1a | ||
|
|
9ea57a7df1 | ||
|
|
4c4171e7fb | ||
|
|
77ece044ad | ||
|
|
d633b36b23 | ||
|
|
2eda6601c0 | ||
|
|
6202393637 | ||
|
|
1b76044242 | ||
|
|
28648e27cf | ||
|
|
90921a74ea | ||
|
|
950b764ff1 | ||
|
|
15ba30bf8f | ||
|
|
c96654d50f | ||
|
|
b5f175d220 | ||
|
|
c535543922 | ||
|
|
9913030e6f | ||
|
|
e7f58fc7fa | ||
|
|
746ee50027 | ||
|
|
683c2da224 | ||
|
|
2671c83337 | ||
|
|
bd35c89c04 | ||
|
|
616baecafb | ||
|
|
bfe895c690 | ||
|
|
97aff2eb4c | ||
|
|
1c46ee2988 | ||
|
|
d0d4ee843d | ||
|
|
fd127da342 | ||
|
|
a8728336ca | ||
|
|
c0eb9bd1e9 | ||
|
|
c85896845f | ||
|
|
efe953d8cd | ||
|
|
b5593ef5b2 | ||
|
|
d08d2e00ee | ||
|
|
bc8908cca1 | ||
|
|
9109f0ccd5 | ||
|
|
30c2ef58cd | ||
|
|
23b295c7c1 | ||
|
|
db24ee4d37 | ||
|
|
e737a1edbd | ||
|
|
109d84302c | ||
|
|
e50974a86a | ||
|
|
ef491e1e96 | ||
|
|
f366a68159 | ||
|
|
0377faadd6 | ||
|
|
a5825373e1 | ||
|
|
fadfadd9e9 | ||
|
|
596b316d65 | ||
|
|
c1e24406d9 | ||
|
|
13dfb090b5 | ||
|
|
ddd1ff0101 | ||
|
|
b266a72726 | ||
|
|
255fae3a90 | ||
|
|
b4a60cfee2 | ||
|
|
233a2e6400 | ||
|
|
f31cb49e2a | ||
|
|
47fd12e7a4 | ||
|
|
d5eb679cf0 | ||
|
|
26d0e46367 | ||
|
|
146bfaa9de | ||
|
|
315051c14c | ||
|
|
3a7d770f6d | ||
|
|
2cd60af841 | ||
|
|
e2f5775b07 | ||
|
|
c27be733a9 | ||
|
|
54fda697ce | ||
|
|
04e0b56dd5 | ||
|
|
b71824c5e8 | ||
|
|
65293ea5e4 | ||
|
|
05c2f5bebd | ||
|
|
e8b2255be0 | ||
|
|
2c227d3475 | ||
|
|
958f7b535a | ||
|
|
9e34096139 | ||
|
|
12b5536e22 | ||
|
|
171a5b1793 | ||
|
|
b4fb82066b |
@@ -7,7 +7,6 @@ indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
8
.gitattributes
vendored
8
.gitattributes
vendored
@@ -1,3 +1,6 @@
|
||||
# default
|
||||
* text
|
||||
|
||||
# Javascript files must retain LF line-endings (to keep eslint happy)
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
@@ -27,6 +30,7 @@ Makefile text
|
||||
*.yml text
|
||||
*.patch text
|
||||
*.txt text
|
||||
*.tpl text
|
||||
CODEOWNERS text
|
||||
*.plist text
|
||||
|
||||
@@ -58,3 +62,7 @@ CODEOWNERS text
|
||||
*.ttf binary diff=hex
|
||||
xz-without-extension binary diff=hex
|
||||
wmic-output.txt binary diff=hex
|
||||
|
||||
# gitsecret
|
||||
*.secret binary
|
||||
.gitsecret/** binary
|
||||
|
||||
7
.github/ISSUE_TEMPLATE.md
vendored
7
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,6 +1,11 @@
|
||||
- **Etcher version:**
|
||||
- **Operating system and architecture:**
|
||||
- **Image flashed:**
|
||||
- **What do you think should have happened:** <!-- or a step by step reproduction process -->
|
||||
- **What happened:**
|
||||
- **Do you see any meaningful error information in the DevTools?**
|
||||
<!-- You can open DevTools by pressing `Ctrl+Shift+I` (`Ctrl+Alt+I` for Etcher before v1.3.x), or `Cmd+Opt+I` if you're on macOS. -->
|
||||
|
||||
<!-- You can open DevTools by pressing `Ctrl+Shift+I` (`Ctrl+Alt+I` for Etcher before v1.3.x), or `Cmd+Alt+I` if you're on Mac OS. -->
|
||||
<!-- issues with missing information will be labeled as not-enough-info and closed shortly -->
|
||||
<!-- please try to include as many influencing elements as possible are you root, does any other process block the device, etc. -->
|
||||
<!-- if you find a solution in the meantime thank you for sharing the fix and not just closing / abandoning your issue -->
|
||||
|
||||
221
.github/actions/publish/action.yml
vendored
Normal file
221
.github/actions/publish/action.yml
vendored
Normal file
@@ -0,0 +1,221 @@
|
||||
---
|
||||
name: package and publish GitHub (draft) release
|
||||
# https://github.com/product-os/flowzone/tree/master/.github/actions
|
||||
inputs:
|
||||
json:
|
||||
description: "JSON stringified object containing all the inputs from the calling workflow"
|
||||
required: true
|
||||
secrets:
|
||||
description: "JSON stringified object containing all the secrets from the calling workflow"
|
||||
required: true
|
||||
|
||||
# --- custom environment
|
||||
XCODE_APP_LOADER_EMAIL:
|
||||
type: string
|
||||
default: "accounts+apple@balena.io"
|
||||
NODE_VERSION:
|
||||
type: string
|
||||
default: "14.x"
|
||||
VERBOSE:
|
||||
type: string
|
||||
default: "true"
|
||||
|
||||
runs:
|
||||
# https://docs.github.com/en/actions/creating-actions/creating-a-composite-action
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Download custom source artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: custom-${{ github.event.pull_request.head.sha || github.event.head_commit.id }}-${{ runner.os }}
|
||||
path: ${{ runner.temp }}
|
||||
|
||||
- name: Extract custom source artifact
|
||||
shell: pwsh
|
||||
working-directory: .
|
||||
run: tar -xf ${{ runner.temp }}/custom.tgz
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ inputs.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Install yq
|
||||
shell: bash --noprofile --norc -eo pipefail -x {0}
|
||||
run: choco install yq
|
||||
if: runner.os == 'Windows'
|
||||
|
||||
# FIXME: resinci-deploy is not actively maintained
|
||||
# https://github.com/product-os/resinci-deploy
|
||||
- name: Checkout resinci-deploy
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: product-os/resinci-deploy
|
||||
token: ${{ fromJSON(inputs.secrets).FLOWZONE_TOKEN }}
|
||||
path: resinci-deploy
|
||||
|
||||
- name: Build and install resinci-deploy
|
||||
shell: bash --noprofile --norc -eo pipefail -x {0}
|
||||
run: |
|
||||
set -ea
|
||||
|
||||
[[ '${{ inputs.VERBOSE }}' =~ on|On|Yes|yes|true|True ]] && set -x
|
||||
|
||||
runner_os="$(echo "${RUNNER_OS}" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
rm -rf ../resinci-deploy && mv resinci-deploy ..
|
||||
|
||||
pushd ../resinci-deploy && npm ci && npm link && popd
|
||||
|
||||
if [[ $runner_os =~ linux|macos ]]; then
|
||||
chmod +x "$(dirname "$(which node)")/resinci-deploy" && which resinci-deploy
|
||||
fi
|
||||
|
||||
# Upload sourcemaps to sentry
|
||||
- name: Generate Sentry DSN
|
||||
id: sentry
|
||||
shell: bash --noprofile --norc -eo pipefail -x {0}
|
||||
run: |
|
||||
set -ea
|
||||
|
||||
[[ '${{ inputs.VERBOSE }}' =~ on|On|Yes|yes|true|True ]] && set -x
|
||||
|
||||
branch="$(echo '${{ github.event.pull_request.head.ref }}' | sed 's/[^[:alnum:]]/-/g')"
|
||||
|
||||
stdout="$(resinci-deploy store sentry \
|
||||
--branch="${branch}" \
|
||||
--name="$(jq -r '.name' package.json)" \
|
||||
--team="$(yq e '.sentry.team' repo.yml)" \
|
||||
--org="$(yq e '.sentry.org' repo.yml)" \
|
||||
--type="$(yq e '.sentry.type' repo.yml)")"
|
||||
|
||||
echo "dsn=$(echo "${stdout}" | tail -n 1)" >> $GITHUB_OUTPUT
|
||||
|
||||
env:
|
||||
SENTRY_TOKEN: ${{ fromJSON(inputs.secrets).SENTRY_AUTH_TOKEN }}
|
||||
|
||||
# https://www.electron.build/code-signing.html
|
||||
# https://github.com/Apple-Actions/import-codesign-certs
|
||||
- name: Import Apple code signing certificate
|
||||
if: runner.os == 'macOS'
|
||||
uses: apple-actions/import-codesign-certs@v1
|
||||
with:
|
||||
p12-file-base64: ${{ fromJSON(inputs.secrets).APPLE_SIGNING }}
|
||||
p12-password: ${{ fromJSON(inputs.secrets).APPLE_SIGNING_PASSWORD }}
|
||||
|
||||
- name: Import Windows code signing certificate
|
||||
if: runner.os == 'Windows'
|
||||
shell: powershell
|
||||
run: |
|
||||
Set-Content -Path ${{ runner.temp }}/certificate.base64 -Value $env:WINDOWS_CERTIFICATE
|
||||
certutil -decode ${{ runner.temp }}/certificate.base64 ${{ runner.temp }}/certificate.pfx
|
||||
Remove-Item -path ${{ runner.temp }} -include certificate.base64
|
||||
|
||||
Import-PfxCertificate `
|
||||
-FilePath ${{ runner.temp }}/certificate.pfx `
|
||||
-CertStoreLocation Cert:\CurrentUser\My `
|
||||
-Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -Force -AsPlainText)
|
||||
|
||||
Remove-Item -path ${{ runner.temp }} -include certificate.pfx
|
||||
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ fromJSON(inputs.secrets).WINDOWS_SIGNING }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ fromJSON(inputs.secrets).WINDOWS_SIGNING_PASSWORD }}
|
||||
|
||||
# ... or refactor (e.g.) https://github.com/samuelmeuli/action-electron-builder
|
||||
# https://github.com/product-os/scripts/tree/master/electron
|
||||
# https://github.com/product-os/scripts/tree/master/shared
|
||||
# https://github.com/product-os/balena-concourse/blob/master/pipelines/github-events/template.yml
|
||||
- name: Package release
|
||||
id: package_release
|
||||
shell: bash --noprofile --norc -eo pipefail -x {0}
|
||||
run: |
|
||||
set -ea
|
||||
|
||||
[[ '${{ inputs.VERBOSE }}' =~ on|On|Yes|yes|true|True ]] && set -x
|
||||
|
||||
runner_os="$(echo "${RUNNER_OS}" | tr '[:upper:]' '[:lower:]')"
|
||||
runner_arch="$(echo "${RUNNER_ARCH}" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
ELECTRON_BUILDER_ARCHITECTURE="${runner_arch}"
|
||||
APPLICATION_VERSION="$(jq -r '.version' package.json)"
|
||||
ARCHITECTURE_FLAGS="--${ELECTRON_BUILDER_ARCHITECTURE}"
|
||||
|
||||
if [[ $runner_os =~ linux ]]; then
|
||||
ELECTRON_BUILDER_OS='--linux'
|
||||
TARGETS="$(yq e .linux.target[] electron-builder.yml)"
|
||||
|
||||
elif [[ $runner_os =~ darwin|macos|osx ]]; then
|
||||
CSC_KEY_PASSWORD=${{ fromJSON(inputs.secrets).APPLE_SIGNING_PASSWORD }}
|
||||
CSC_KEYCHAIN=signing_temp
|
||||
CSC_LINK=${{ fromJSON(inputs.secrets).APPLE_SIGNING }}
|
||||
ELECTRON_BUILDER_OS='--mac'
|
||||
TARGETS="$(yq e .mac.target[] electron-builder.yml)"
|
||||
|
||||
elif [[ $runner_os =~ windows|win ]]; then
|
||||
ARCHITECTURE_FLAGS="--ia32 ${ARCHITECTURE_FLAGS}"
|
||||
CSC_KEY_PASSWORD=${{ fromJSON(inputs.secrets).WINDOWS_SIGNING_PASSWORD }}
|
||||
CSC_LINK=${{ fromJSON(inputs.secrets).WINDOWS_SIGNING }}
|
||||
ELECTRON_BUILDER_OS='--win'
|
||||
TARGETS="$(yq e .win.target[] electron-builder.yml)"
|
||||
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
npm link electron-builder
|
||||
|
||||
for target in ${TARGETS}; do
|
||||
electron-builder ${ELECTRON_BUILDER_OS} ${target} ${ARCHITECTURE_FLAGS} \
|
||||
--c.extraMetadata.analytics.sentry.token='${{ steps.sentry.outputs.dsn }}' \
|
||||
--c.extraMetadata.analytics.mixpanel.token='balena-etcher' \
|
||||
--c.extraMetadata.packageType="${target}"
|
||||
|
||||
find dist -type f -maxdepth 1
|
||||
done
|
||||
|
||||
echo "version=${APPLICATION_VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
env:
|
||||
# Apple notarization (afterSignHook.js)
|
||||
XCODE_APP_LOADER_EMAIL: ${{ inputs.XCODE_APP_LOADER_EMAIL }}
|
||||
XCODE_APP_LOADER_PASSWORD: ${{ fromJSON(inputs.secrets).XCODE_APP_LOADER_PASSWORD }}
|
||||
# https://github.blog/2020-08-03-github-actions-improvements-for-fork-and-pull-request-workflows/#improvements-for-public-repository-forks
|
||||
# https://docs.github.com/en/actions/managing-workflow-runs/approving-workflow-runs-from-public-forks#about-workflow-runs-from-public-forks
|
||||
CSC_FOR_PULL_REQUEST: true
|
||||
|
||||
# https://www.electron.build/auto-update.html#staged-rollouts
|
||||
- name: Configure staged rollout(s)
|
||||
shell: bash --noprofile --norc -eo pipefail -x {0}
|
||||
run: |
|
||||
set -ea
|
||||
|
||||
[[ '${{ inputs.VERBOSE }}' =~ on|On|Yes|yes|true|True ]] && set -x
|
||||
|
||||
percentage="$(cat < repo.yml | yq e .triggerNotification.stagingPercentage)"
|
||||
|
||||
find dist -type f -maxdepth 1 \
|
||||
-name "latest*.yml" \
|
||||
-exec yq -i e .version=\"${{ steps.package_release.outputs.version }}\" {} \;
|
||||
|
||||
find dist -type f -maxdepth 1 \
|
||||
-name "latest*.yml" \
|
||||
-exec yq -i e .stagingPercentage=\"$percentage\" {} \;
|
||||
|
||||
- name: Upload sourcemap to Sentry
|
||||
shell: bash --noprofile --norc -eo pipefail -x {0}
|
||||
run: |
|
||||
VERSION=${{ steps.package_release.outputs.version }} npm run uploadSourcemap
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ fromJSON(inputs.secrets).SENTRY_AUTH_TOKEN }}
|
||||
npm_config_SENTRY_ORG: balenaEtcher
|
||||
npm_config_SENTRY_PROJECT: balenaetcher
|
||||
npm_config_SENTRY_VERSION: ${{ steps.package_release.outputs.version }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: gh-release-${{ github.event.pull_request.head.sha || github.event.head_commit.id }}
|
||||
path: dist
|
||||
retention-days: 1
|
||||
58
.github/actions/test/action.yml
vendored
Normal file
58
.github/actions/test/action.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: test release
|
||||
# https://github.com/product-os/flowzone/tree/master/.github/actions
|
||||
inputs:
|
||||
json:
|
||||
description: "JSON stringified object containing all the inputs from the calling workflow"
|
||||
required: true
|
||||
secrets:
|
||||
description: "JSON stringified object containing all the secrets from the calling workflow"
|
||||
required: true
|
||||
|
||||
# --- custom environment
|
||||
NODE_VERSION:
|
||||
type: string
|
||||
default: "14.x"
|
||||
VERBOSE:
|
||||
type: string
|
||||
default: "true"
|
||||
|
||||
runs:
|
||||
# https://docs.github.com/en/actions/creating-actions/creating-a-composite-action
|
||||
using: "composite"
|
||||
steps:
|
||||
# https://github.com/actions/setup-node#caching-global-packages-data
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ inputs.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Test release
|
||||
shell: bash --noprofile --norc -eo pipefail -x {0}
|
||||
run: |
|
||||
set -ea
|
||||
|
||||
[[ '${{ inputs.VERBOSE }}' =~ on|On|Yes|yes|true|True ]] && set -x
|
||||
|
||||
runner_os="$(echo "${RUNNER_OS}" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
npm run flowzone-preinstall-${runner_os}
|
||||
npm ci
|
||||
npm run build
|
||||
npm run test-${runner_os}
|
||||
|
||||
env:
|
||||
# https://www.electronjs.org/docs/latest/api/environment-variables
|
||||
ELECTRON_NO_ATTACH_CONSOLE: true
|
||||
|
||||
- name: Compress custom source
|
||||
shell: pwsh
|
||||
run: tar -acf ${{ runner.temp }}/custom.tgz .
|
||||
|
||||
- name: Upload custom artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: custom-${{ github.event.pull_request.head.sha || github.event.head_commit.id }}-${{ runner.os }}
|
||||
path: ${{ runner.temp }}/custom.tgz
|
||||
retention-days: 1
|
||||
29
.github/workflows/flowzone.yml
vendored
Normal file
29
.github/workflows/flowzone.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: Flowzone
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, closed]
|
||||
branches: [main, master]
|
||||
# allow external contributions to use secrets within trusted code
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, closed]
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
flowzone:
|
||||
name: Flowzone
|
||||
uses: product-os/flowzone/.github/workflows/flowzone.yml@master
|
||||
# prevent duplicate workflows and only allow one `pull_request` or `pull_request_target` for
|
||||
# internal or external contributions respectively
|
||||
if: |
|
||||
(github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request') ||
|
||||
(github.event.pull_request.head.repo.full_name != github.repository && github.event_name == 'pull_request_target')
|
||||
secrets: inherit
|
||||
with:
|
||||
tests_run_on: '["ubuntu-20.04","macos-latest","windows-2019"]'
|
||||
restrict_custom_actions: false
|
||||
github_prerelease: true
|
||||
repo_config: true
|
||||
repo_description: "Flash OS images to SD cards & USB drives, safely and easily."
|
||||
repo_homepage: https://etcher.io/
|
||||
repo_enable_wiki: true
|
||||
13
.github/workflows/winget.yml
vendored
Normal file
13
.github/workflows/winget.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
name: Publish to WinGet
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: windows-latest # action can only be run on windows
|
||||
steps:
|
||||
- uses: vedantmgoyal2009/winget-releaser@v1
|
||||
with:
|
||||
identifier: Balena.Etcher
|
||||
installers-regex: 'balenaEtcher-Setup.*.exe$'
|
||||
token: ${{ secrets.WINGET_PAT }}
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -47,3 +47,13 @@ node_modules
|
||||
# OSX files
|
||||
|
||||
.DS_Store
|
||||
|
||||
# VSCode files
|
||||
|
||||
.vscode
|
||||
.gitsecret/keys/random_seed
|
||||
!*.secret
|
||||
secrets/APPLE_SIGNING_PASSWORD.txt
|
||||
secrets/WINDOWS_SIGNING_PASSWORD.txt
|
||||
secrets/XCODE_APP_LOADER_PASSWORD.txt
|
||||
secrets/WINDOWS_SIGNING.pfx
|
||||
|
||||
BIN
.gitsecret/keys/pubring.kbx
Normal file
BIN
.gitsecret/keys/pubring.kbx
Normal file
Binary file not shown.
BIN
.gitsecret/keys/pubring.kbx~
Normal file
BIN
.gitsecret/keys/pubring.kbx~
Normal file
Binary file not shown.
BIN
.gitsecret/keys/trustdb.gpg
Normal file
BIN
.gitsecret/keys/trustdb.gpg
Normal file
Binary file not shown.
5
.gitsecret/paths/mapping.cfg
Normal file
5
.gitsecret/paths/mapping.cfg
Normal file
@@ -0,0 +1,5 @@
|
||||
secrets/APPLE_SIGNING_PASSWORD.txt:5c9cfeb1ea5142b547bc842cc6e0b4a932641ae9811ee47abe2c3953f2a4de5d
|
||||
secrets/WINDOWS_SIGNING_PASSWORD.txt:852e431628494f2559793c39cf09c34e9406dd79bb15b90c9f88194020470568
|
||||
secrets/XCODE_APP_LOADER_PASSWORD.txt:005eb9a3c7035c77232973c9355468fc396b94e62783fb8e6dce16bce95b94a1
|
||||
secrets/WINDOWS_SIGNING.pfx:929f401db38733ffc41572539de7c0d938023af51ed06c205a72a71c1f815714
|
||||
secrets/APPLE_SIGNING.p12:61abf7b4ff2eec76ce889d71bcdd568b99a6a719b4947ac20f03966265b0946a
|
||||
@@ -1,84 +0,0 @@
|
||||
{
|
||||
"electron": {
|
||||
"npm_version": "6.7.0",
|
||||
"dependencies": {
|
||||
"linux": [
|
||||
"libudev-dev",
|
||||
"libusb-1.0-0-dev",
|
||||
"libyaml-dev",
|
||||
"libgtk-3-0",
|
||||
"libatk-bridge2.0-0",
|
||||
"libdbus-1-3",
|
||||
"libc6"
|
||||
]
|
||||
},
|
||||
"builder": {
|
||||
"appId": "io.balena.etcher",
|
||||
"copyright": "Copyright 2016-2019 Balena Ltd",
|
||||
"productName": "balenaEtcher",
|
||||
"nodeGypRebuild": true,
|
||||
"afterPack": "./afterPack.js",
|
||||
"files": [
|
||||
"build/Release/elevator.node",
|
||||
"generated",
|
||||
"lib/shared/catalina-sudo/sudo-askpass.osascript.js",
|
||||
"lib/gui/app/index.html",
|
||||
"lib/gui/css/*.css",
|
||||
"lib/gui/css/fonts/*.woff2",
|
||||
"lib/gui/assets/*.svg",
|
||||
"assets/icon.png",
|
||||
"!node_modules/**/**",
|
||||
"node_modules/**/*.js",
|
||||
"node_modules/**/*.json",
|
||||
"node_modules/**/*.node",
|
||||
"node_modules/**/*.dll",
|
||||
"node_modules/node-raspberrypi-usbboot/blobs/**",
|
||||
"node_modules/flexboxgrid/dist/flexboxgrid.css",
|
||||
"node_modules/roboto-fontface/fonts/roboto/Roboto-Thin.woff",
|
||||
"node_modules/roboto-fontface/fonts/roboto/Roboto-Light.woff",
|
||||
"node_modules/roboto-fontface/fonts/roboto/Roboto-Regular.woff",
|
||||
"node_modules/roboto-fontface/fonts/roboto/Roboto-Medium.woff",
|
||||
"node_modules/roboto-fontface/fonts/roboto/Roboto-Bold.woff",
|
||||
"node_modules/bootstrap-sass/assets/fonts/bootstrap/glyphicons-halflings-regular.woff2"
|
||||
],
|
||||
"afterSign": "./afterSignHook.js",
|
||||
"mac": {
|
||||
"asar": false,
|
||||
"category": "public.app-category.developer-tools",
|
||||
"hardenedRuntime": true,
|
||||
"entitlements": "entitlements.mac.plist",
|
||||
"entitlementsInherit": "entitlements.mac.plist"
|
||||
},
|
||||
"dmg": {
|
||||
"iconSize": 110,
|
||||
"contents": [
|
||||
{
|
||||
"x": 140,
|
||||
"y": 245
|
||||
},
|
||||
{
|
||||
"x": 415,
|
||||
"y": 245,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
],
|
||||
"window": {
|
||||
"width": 544,
|
||||
"height": 407
|
||||
}
|
||||
},
|
||||
"linux": {
|
||||
"category": "Utility",
|
||||
"packageCategory": "utils",
|
||||
"synopsis": "balenaEtcher is a powerful OS image flasher built with web technologies to ensure flashing an SDCard or USB drive is a pleasant and safe experience. It protects you from accidentally writing to your hard-drives, ensures every byte of data was written correctly and much more."
|
||||
},
|
||||
"deb": {
|
||||
"priority": "optional",
|
||||
"depends": [
|
||||
"polkit-1-auth-agent | policykit-1-gnome | polkit-kde-1"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
# sass-lint config generated by make-sass-lint-config v0.1.2
|
||||
|
||||
files:
|
||||
include: lib/gui/scss/**/*.scss
|
||||
options:
|
||||
formatter: stylish
|
||||
merge-default-rules: false
|
||||
rules:
|
||||
no-css-comments: 0
|
||||
no-important: 0
|
||||
no-qualifying-elements: 0
|
||||
placeholder-in-extend: 0
|
||||
property-sort-order: 0
|
||||
quotes:
|
||||
- 1
|
||||
- style: double
|
||||
|
||||
12860
.versionbot/CHANGELOG.yml
Normal file
12860
.versionbot/CHANGELOG.yml
Normal file
File diff suppressed because it is too large
Load Diff
1539
CHANGELOG.md
1539
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
* @thundron @zvin @jviotti
|
||||
/scripts @nazrhom
|
||||
4
FAQ.md
4
FAQ.md
@@ -37,10 +37,10 @@ modules=xwayland.so
|
||||
Sometimes, things might go wrong, and you end up with a half-flashed drive that is unusable by your operating systems, and common graphical tools might even refuse to get it back to a normal state.
|
||||
To solve these kinds of problems, we've collected [a list of fail-proof methods](https://github.com/balena-io/etcher/blob/master/docs/USER-DOCUMENTATION.md#recovering-broken-drives) to completely erase your drive in major operating systems.
|
||||
|
||||
## I receive ”No polkit authentication agent found” error in GNU/Linux
|
||||
## I receive "No polkit authentication agent found" error in GNU/Linux
|
||||
|
||||
Etcher requires an available [polkit authentication agent](https://wiki.archlinux.org/index.php/Polkit#Authentication_agents) in your system in order to show a secure password prompt dialog to perform elevation. Make sure you have one installed for the desktop environment of your choice.
|
||||
|
||||
## May I run Etcher in older macOS versions?
|
||||
|
||||
Etcher GUI is based on the [Electron](http://electron.atom.io/) framework, [which only supports macOS 10.9 and newer versions](https://github.com/electron/electron/blob/master/docs/tutorial/support.md#supported-platforms).
|
||||
Etcher GUI is based on the [Electron](http://electron.atom.io/) framework, [which only supports macOS 10.10 and newer versions](https://github.com/electron/electron/blob/master/docs/tutorial/support.md#supported-platforms).
|
||||
|
||||
89
Makefile
89
Makefile
@@ -3,18 +3,12 @@
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
RESIN_SCRIPTS ?= ./scripts/resin
|
||||
export NPM_VERSION ?= 6.7.0
|
||||
export NPM_VERSION ?= 6.14.8
|
||||
S3_BUCKET = artifacts.ci.balena-cloud.com
|
||||
|
||||
# This directory will be completely deleted by the `clean` rule
|
||||
BUILD_DIRECTORY ?= dist
|
||||
|
||||
# See http://stackoverflow.com/a/20763842/1641422
|
||||
BUILD_DIRECTORY_PARENT = $(dir $(BUILD_DIRECTORY))
|
||||
ifeq ($(wildcard $(BUILD_DIRECTORY_PARENT).),)
|
||||
$(error $(BUILD_DIRECTORY_PARENT) does not exist)
|
||||
endif
|
||||
|
||||
BUILD_TEMPORARY_DIRECTORY = $(BUILD_DIRECTORY)/.tmp
|
||||
|
||||
$(BUILD_DIRECTORY):
|
||||
@@ -23,9 +17,7 @@ $(BUILD_DIRECTORY):
|
||||
$(BUILD_TEMPORARY_DIRECTORY): | $(BUILD_DIRECTORY)
|
||||
mkdir $@
|
||||
|
||||
# See https://stackoverflow.com/a/13468229/1641422
|
||||
SHELL := /bin/bash
|
||||
PATH := $(shell pwd)/node_modules/.bin:$(PATH)
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Operating system and architecture detection
|
||||
@@ -74,6 +66,9 @@ else
|
||||
ifeq ($(shell uname -m),x86_64)
|
||||
HOST_ARCH = x64
|
||||
endif
|
||||
ifeq ($(shell uname -m),arm64)
|
||||
HOST_ARCH = aarch64
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
|
||||
@@ -93,12 +88,10 @@ TARGET_ARCH ?= $(HOST_ARCH)
|
||||
# ---------------------------------------------------------------------
|
||||
# Electron
|
||||
# ---------------------------------------------------------------------
|
||||
electron-develop: | $(BUILD_TEMPORARY_DIRECTORY)
|
||||
$(RESIN_SCRIPTS)/electron/install.sh \
|
||||
-b $(shell pwd) \
|
||||
-r $(TARGET_ARCH) \
|
||||
-s $(PLATFORM) \
|
||||
-m $(NPM_VERSION)
|
||||
electron-develop:
|
||||
git submodule update --init && \
|
||||
npm ci && \
|
||||
npm run webpack
|
||||
|
||||
electron-test:
|
||||
$(RESIN_SCRIPTS)/electron/test.sh \
|
||||
@@ -114,8 +107,7 @@ electron-build: assets/dmg/background.tiff | $(BUILD_TEMPORARY_DIRECTORY)
|
||||
-r $(TARGET_ARCH) \
|
||||
-s $(PLATFORM) \
|
||||
-v production \
|
||||
-n $(BUILD_TEMPORARY_DIRECTORY)/npm \
|
||||
-w $(BUILD_TEMPORARY_DIRECTORY)
|
||||
-n $(BUILD_TEMPORARY_DIRECTORY)/npm
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Phony targets
|
||||
@@ -125,69 +117,20 @@ TARGETS = \
|
||||
help \
|
||||
info \
|
||||
lint \
|
||||
lint-js \
|
||||
lint-sass \
|
||||
lint-cpp \
|
||||
lint-html \
|
||||
lint-spell \
|
||||
test-spectron \
|
||||
test-gui \
|
||||
test \
|
||||
sanity-checks \
|
||||
clean \
|
||||
distclean \
|
||||
webpack \
|
||||
electron-develop \
|
||||
electron-test \
|
||||
electron-build
|
||||
|
||||
webpack:
|
||||
./node_modules/.bin/webpack
|
||||
|
||||
.PHONY: $(TARGETS)
|
||||
|
||||
sass:
|
||||
npm rebuild node-sass
|
||||
node-sass lib/gui/app/scss/main.scss > lib/gui/css/main.css
|
||||
lint:
|
||||
npm run lint
|
||||
|
||||
lint-ts:
|
||||
resin-lint --typescript lib
|
||||
|
||||
lint-js:
|
||||
eslint --ignore-pattern scripts/resin/**/*.js lib tests scripts bin webpack.config.js
|
||||
|
||||
lint-sass:
|
||||
sass-lint lib/gui/scss
|
||||
|
||||
lint-cpp:
|
||||
cpplint --recursive src
|
||||
|
||||
lint-html:
|
||||
node scripts/html-lint.js
|
||||
|
||||
lint-spell:
|
||||
codespell \
|
||||
--dictionary - \
|
||||
--dictionary dictionary.txt \
|
||||
--skip *.svg *.gz,*.bz2,*.xz,*.zip,*.img,*.dmg,*.iso,*.rpi-sdcard,*.wic,.DS_Store,*.dtb,*.dtbo,*.dat,*.elf,*.bin,*.foo,xz-without-extension \
|
||||
lib tests docs Makefile *.md LICENSE
|
||||
|
||||
lint: lint-ts lint-js lint-sass lint-cpp lint-html lint-spell
|
||||
|
||||
MOCHA_OPTIONS=--recursive --reporter spec --require ts-node/register
|
||||
|
||||
# See https://github.com/electron/spectron/issues/127
|
||||
ETCHER_SPECTRON_ENTRYPOINT ?= $(shell node -e 'console.log(require("electron"))')
|
||||
test-spectron:
|
||||
ETCHER_SPECTRON_ENTRYPOINT="$(ETCHER_SPECTRON_ENTRYPOINT)" mocha $(MOCHA_OPTIONS) tests/spectron
|
||||
|
||||
test-gui:
|
||||
electron-mocha $(MOCHA_OPTIONS) --full-trace --no-sandbox --renderer tests/gui
|
||||
|
||||
test-sdk:
|
||||
electron-mocha $(MOCHA_OPTIONS) --full-trace --no-sandbox tests/shared
|
||||
|
||||
test: test-gui test-sdk test-spectron
|
||||
test:
|
||||
npm run test
|
||||
|
||||
help:
|
||||
@echo "Available targets: $(TARGETS)"
|
||||
@@ -197,17 +140,11 @@ info:
|
||||
@echo "Host arch : $(HOST_ARCH)"
|
||||
@echo "Target arch : $(TARGET_ARCH)"
|
||||
|
||||
sanity-checks:
|
||||
./scripts/ci/ensure-staged-sass.sh
|
||||
./scripts/ci/ensure-npm-dependencies-compatibility.sh
|
||||
./scripts/ci/ensure-all-file-extensions-in-gitattributes.sh
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD_DIRECTORY)
|
||||
|
||||
distclean: clean
|
||||
rm -rf node_modules
|
||||
rm -rf build
|
||||
rm -rf dist
|
||||
rm -rf generated
|
||||
rm -rf $(BUILD_TEMPORARY_DIRECTORY)
|
||||
|
||||
159
README.md
159
README.md
@@ -5,16 +5,15 @@
|
||||
Etcher is a powerful OS image flasher built with web technologies to ensure
|
||||
flashing an SDCard or USB drive is a pleasant and safe experience. It protects
|
||||
you from accidentally writing to your hard-drives, ensures every byte of data
|
||||
was written correctly and much more. It can also flash directly Raspberry Pi devices that support the usbboot protocol
|
||||
was written correctly, and much more. It can also directly flash Raspberry Pi devices that support [USB device boot mode](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#usb-device-boot-mode).
|
||||
|
||||
[](https://balena.io/etcher)
|
||||
[](https://github.com/balena-io/etcher/blob/master/LICENSE)
|
||||
[](https://david-dm.org/balena-io/etcher)
|
||||
[](https://forums.balena.io/c/etcher)
|
||||
|
||||
***
|
||||
---
|
||||
|
||||
[**Download**][etcher] | [**Support**][SUPPORT] | [**Documentation**][USER-DOCUMENTATION] | [**Contributing**][CONTRIBUTING] | [**Roadmap**][milestones]
|
||||
[**Download**][etcher] | [**Support**][support] | [**Documentation**][user-documentation] | [**Contributing**][contributing] | [**Roadmap**][milestones]
|
||||
|
||||
## Supported Operating Systems
|
||||
|
||||
@@ -22,7 +21,7 @@ was written correctly and much more. It can also flash directly Raspberry Pi dev
|
||||
- macOS 10.10 (Yosemite) and later
|
||||
- Microsoft Windows 7 and later
|
||||
|
||||
Note that Etcher will run on any platform officially supported by
|
||||
**Note**: Etcher will run on any platform officially supported by
|
||||
[Electron][electron]. Read more in their
|
||||
[documentation][electron-supported-platforms].
|
||||
|
||||
@@ -31,66 +30,118 @@ Note that Etcher will run on any platform officially supported by
|
||||
Refer to the [downloads page][etcher] for the latest pre-made
|
||||
installers for all supported operating systems.
|
||||
|
||||
## Packages
|
||||
|
||||
> [](https://cloudsmith.com) \
|
||||
Package repository hosting is graciously provided by [Cloudsmith](https://cloudsmith.com).
|
||||
Cloudsmith is the only fully hosted, cloud-native, universal package management solution, that
|
||||
enables your organization to create, store and share packages in any format, to any place, with total
|
||||
confidence.
|
||||
|
||||
#### Debian and Ubuntu based Package Repository (GNU/Linux x86/x64)
|
||||
|
||||
1. Add Etcher debian repository:
|
||||
> Detailed or alternative steps in the [instructions by Cloudsmith](https://cloudsmith.io/~balena/repos/etcher/setup/#formats-deb)
|
||||
|
||||
```sh
|
||||
echo "deb https://deb.etcher.io stable etcher" | sudo tee /etc/apt/sources.list.d/balena-etcher.list
|
||||
```
|
||||
1. Add Etcher Debian repository:
|
||||
|
||||
2. Trust Bintray.com's GPG key:
|
||||
```sh
|
||||
curl -1sLf \
|
||||
'https://dl.cloudsmith.io/public/balena/etcher/setup.deb.sh' \
|
||||
| sudo -E bash
|
||||
```
|
||||
|
||||
```sh
|
||||
sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 379CE192D401AB61
|
||||
```
|
||||
2. Update and install:
|
||||
|
||||
3. Update and install:
|
||||
|
||||
```sh
|
||||
sudo apt-get update
|
||||
sudo apt-get install balena-etcher-electron
|
||||
```
|
||||
```sh
|
||||
sudo apt-get update
|
||||
sudo apt-get install balena-etcher-electron
|
||||
```
|
||||
|
||||
##### Uninstall
|
||||
|
||||
```sh
|
||||
sudo apt-get remove balena-etcher-electron
|
||||
sudo rm /etc/apt/sources.list.d/balena-etcher.list
|
||||
sudo apt-get update
|
||||
rm /etc/apt/sources.list.d/balena-etcher.list
|
||||
apt-get clean
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
apt-get update
|
||||
```
|
||||
#### Redhat (RHEL) and Fedora based Package Repository (GNU/Linux x86/x64)
|
||||
|
||||
#### Redhat (RHEL) and Fedora-based Package Repository (GNU/Linux x86/x64)
|
||||
|
||||
> Detailed or alternative steps in the [instructions by Cloudsmith](https://cloudsmith.io/~balena/repos/etcher/setup/#formats-rpm)
|
||||
|
||||
|
||||
##### DNF
|
||||
|
||||
1. Add Etcher rpm repository:
|
||||
|
||||
```sh
|
||||
sudo wget https://balena.io/etcher/static/etcher-rpm.repo -O /etc/yum.repos.d/etcher-rpm.repo
|
||||
```
|
||||
```sh
|
||||
curl -1sLf \
|
||||
'https://dl.cloudsmith.io/public/balena/etcher/setup.rpm.sh' \
|
||||
| sudo -E bash
|
||||
```
|
||||
|
||||
2. Update and install:
|
||||
|
||||
```sh
|
||||
sudo yum install -y balena-etcher-electron
|
||||
```
|
||||
or
|
||||
```sh
|
||||
sudo dnf install -y balena-etcher-electron
|
||||
```
|
||||
```sh
|
||||
sudo dnf install -y balena-etcher-electron
|
||||
```
|
||||
|
||||
###### Uninstall
|
||||
|
||||
```sh
|
||||
rm /etc/yum.repos.d/balena-etcher.repo
|
||||
rm /etc/yum.repos.d/balena-etcher-source.repo
|
||||
```
|
||||
|
||||
##### Yum
|
||||
|
||||
1. Add Etcher rpm repository:
|
||||
|
||||
```sh
|
||||
curl -1sLf \
|
||||
'https://dl.cloudsmith.io/public/balena/etcher/setup.rpm.sh' \
|
||||
| sudo -E bash
|
||||
```
|
||||
|
||||
2. Update and install:
|
||||
|
||||
```sh
|
||||
sudo yum install -y balena-etcher-electron
|
||||
```
|
||||
|
||||
###### Uninstall
|
||||
|
||||
```sh
|
||||
sudo yum remove -y balena-etcher-electron
|
||||
rm /etc/yum.repos.d/balena-etcher.repo
|
||||
rm /etc/yum.repos.d/balena-etcher-source.repo
|
||||
```
|
||||
|
||||
#### OpenSUSE LEAP & Tumbleweed install (zypper)
|
||||
|
||||
1. Add the repo
|
||||
|
||||
```sh
|
||||
curl -1sLf \
|
||||
'https://dl.cloudsmith.io/public/balena/etcher/setup.rpm.sh' \
|
||||
| sudo -E bash
|
||||
```
|
||||
2. Update and install
|
||||
|
||||
```sh
|
||||
sudo zypper up
|
||||
sudo zypper install balena-etcher-electron
|
||||
```
|
||||
|
||||
##### Uninstall
|
||||
|
||||
```sh
|
||||
sudo yum remove -y balena-etcher-electron
|
||||
sudo rm /etc/yum.repos.d/etcher-rpm.repo
|
||||
sudo yum clean all
|
||||
sudo yum makecache fast
|
||||
```
|
||||
or
|
||||
```sh
|
||||
sudo dnf remove -y balena-etcher-electron
|
||||
sudo rm /etc/yum.repos.d/etcher-rpm.repo
|
||||
sudo dnf clean all
|
||||
sudo dnf makecache
|
||||
sudo zypper rm balena-etcher-electron
|
||||
# remove the repo
|
||||
sudo zypper rr balena-etcher
|
||||
sudo zypper rr balena-etcher-source
|
||||
```
|
||||
|
||||
#### Solus (GNU/Linux x64)
|
||||
@@ -105,20 +156,18 @@ sudo eopkg it etcher
|
||||
sudo eopkg rm etcher
|
||||
```
|
||||
|
||||
#### Brew Cask (macOS)
|
||||
#### Arch/Manjaro Linux (GNU/Linux x64)
|
||||
|
||||
Note that the Etcher Cask has to be updated manually to point to new versions,
|
||||
so it might not refer to the latest version immediately after an Etcher
|
||||
release.
|
||||
Etcher is offered through the Arch User Repository and can be installed on both Manjaro and Arch systems. You can compile it from the source code in this repository using [`balena-etcher`](https://aur.archlinux.org/packages/balena-etcher/). The following example uses a common AUR helper to install the latest release:
|
||||
|
||||
```sh
|
||||
brew cask install balenaetcher
|
||||
yay -S balena-etcher
|
||||
```
|
||||
|
||||
##### Uninstall
|
||||
|
||||
```sh
|
||||
brew cask uninstall balenaetcher
|
||||
yay -R balena-etcher
|
||||
```
|
||||
|
||||
#### Chocolatey (Windows)
|
||||
@@ -138,20 +187,22 @@ choco uninstall etcher
|
||||
|
||||
## Support
|
||||
|
||||
If you're having any problem, please [raise an issue][newissue] on GitHub and
|
||||
If you're having any problem, please [raise an issue][newissue] on GitHub, and
|
||||
the balena.io team will be happy to help.
|
||||
|
||||
## License
|
||||
|
||||
Etcher is free software, and may be redistributed under the terms specified in
|
||||
Etcher is free software and may be redistributed under the terms specified in
|
||||
the [license].
|
||||
|
||||
[etcher]: https://balena.io/etcher
|
||||
[electron]: https://electronjs.org/
|
||||
[electron-supported-platforms]: https://electronjs.org/docs/tutorial/support#supported-platforms
|
||||
[SUPPORT]: https://github.com/balena-io/etcher/blob/master/SUPPORT.md
|
||||
[CONTRIBUTING]: https://github.com/balena-io/etcher/blob/master/docs/CONTRIBUTING.md
|
||||
[USER-DOCUMENTATION]: https://github.com/balena-io/etcher/blob/master/docs/USER-DOCUMENTATION.md
|
||||
[support]: https://github.com/balena-io/etcher/blob/master/SUPPORT.md
|
||||
[contributing]: https://github.com/balena-io/etcher/blob/master/docs/CONTRIBUTING.md
|
||||
[user-documentation]: https://github.com/balena-io/etcher/blob/master/docs/USER-DOCUMENTATION.md
|
||||
[milestones]: https://github.com/balena-io/etcher/milestones
|
||||
[newissue]: https://github.com/balena-io/etcher/issues/new
|
||||
[license]: https://github.com/balena-io/etcher/blob/master/LICENSE
|
||||
|
||||
|
||||
|
||||
17
SUPPORT.md
17
SUPPORT.md
@@ -1,9 +1,16 @@
|
||||
Getting help with Etcher
|
||||
========================
|
||||
Getting help with BalenaEtcher
|
||||
===============================
|
||||
|
||||
There are various ways to get support for Etcher if you experience an issue or
|
||||
have an idea you'd like to share with us.
|
||||
|
||||
Documentation
|
||||
------
|
||||
|
||||
We have answers to a variety of frequently asked questions in the [user
|
||||
documentation][documentation] and also in the [FAQs][faq] on the Etcher website.
|
||||
|
||||
|
||||
Forums
|
||||
------
|
||||
|
||||
@@ -15,7 +22,7 @@ a look at the existing threads before opening a new one!
|
||||
Make sure to mention the following information to help us provide better
|
||||
support:
|
||||
|
||||
- The Etcher version you're running.
|
||||
- The BalenaEtcher version you're running.
|
||||
|
||||
- The operating system you're running Etcher in.
|
||||
|
||||
@@ -25,10 +32,12 @@ support:
|
||||
GitHub
|
||||
------
|
||||
|
||||
If you encounter an issue or have a suggestion, head on over to Etcher's [issue
|
||||
If you encounter an issue or have a suggestion, head on over to BalenaEtcher's [issue
|
||||
tracker][issues] and if there isn't a ticket covering it, [create
|
||||
one][new-issue].
|
||||
|
||||
[discourse]: https://forums.balena.io/c/etcher
|
||||
[issues]: https://github.com/balena-io/etcher/issues
|
||||
[new-issue]: https://github.com/balena-io/etcher/issues/new
|
||||
[documentation]: https://github.com/balena-io/etcher/blob/master/docs/USER-DOCUMENTATION.md
|
||||
[faq]: https://etcher.io
|
||||
|
||||
11
after-install.tpl
Normal file
11
after-install.tpl
Normal file
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Link to the binary
|
||||
# Must hardcode balenaEtcher directory; no variable available
|
||||
ln -sf '/opt/balenaEtcher/${executable}' '/usr/bin/${executable}'
|
||||
|
||||
# SUID chrome-sandbox for Electron 5+
|
||||
chmod 4755 '/opt/balenaEtcher/chrome-sandbox' || true
|
||||
|
||||
update-mime-database /usr/share/mime || true
|
||||
update-desktop-database /usr/share/applications || true
|
||||
10
afterPack.js
10
afterPack.js
@@ -14,12 +14,16 @@ exports.default = function(context) {
|
||||
cp.execFileSync('mv', [scriptPath, binPath])
|
||||
fs.writeFileSync(
|
||||
scriptPath,
|
||||
outdent`
|
||||
outdent({trimTrailingNewline: false})`
|
||||
#!/bin/bash
|
||||
|
||||
# Resolve symlinks. Warning, readlink -f doesn't work on MacOS/BSD
|
||||
script_dir="$(dirname "$(readlink -f "\${BASH_SOURCE[0]}")")"
|
||||
|
||||
if [[ $EUID -ne 0 ]] || [[ $ELECTRON_RUN_AS_NODE ]]; then
|
||||
"\${BASH_SOURCE%/*}"/${context.packager.executableName}.bin "$@"
|
||||
"\${script_dir}"/${context.packager.executableName}.bin "$@"
|
||||
else
|
||||
"\${BASH_SOURCE%/*}"/${context.packager.executableName}.bin "$@" --no-sandbox
|
||||
"\${script_dir}"/${context.packager.executableName}.bin "$@" --no-sandbox
|
||||
fi
|
||||
`
|
||||
)
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
'use strict'
|
||||
|
||||
const { notarize } = require('electron-notarize')
|
||||
const { ELECTRON_SKIP_NOTARIZATION } = process.env
|
||||
|
||||
async function main(context) {
|
||||
const { electronPlatformName, appOutDir } = context
|
||||
if (electronPlatformName !== 'darwin') {
|
||||
if (electronPlatformName !== 'darwin' || ELECTRON_SKIP_NOTARIZATION === 'true') {
|
||||
return
|
||||
}
|
||||
|
||||
const appName = context.packager.appInfo.productFilename
|
||||
const appleId = 'accounts+apple@balena.io'
|
||||
const appleId = process.env.XCODE_APP_LOADER_EMAIL || 'accounts+apple@balena.io'
|
||||
const appleIdPassword = process.env.XCODE_APP_LOADER_PASSWORD
|
||||
|
||||
// https://github.com/electron/notarize/blob/main/README.md
|
||||
await notarize({
|
||||
appBundleId: 'io.balena.etcher',
|
||||
appPath: `${appOutDir}/${appName}.app`,
|
||||
appleId,
|
||||
appleIdPassword: `@keychain:Application Loader: ${appleId}`
|
||||
appleIdPassword
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
BIN
assets/icon.icns
BIN
assets/icon.icns
Binary file not shown.
35
binding.gyp
35
binding.gyp
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"targets": [
|
||||
{
|
||||
"target_name": "elevator",
|
||||
"include_dirs" : [
|
||||
"src",
|
||||
"<!(node -e \"require('nan')\")"
|
||||
],
|
||||
'conditions': [
|
||||
|
||||
[ 'OS=="win"', {
|
||||
"sources": [
|
||||
"src/utils/v8utils.cpp",
|
||||
"src/os/win32/elevate.cpp",
|
||||
"src/elevator_init.cpp",
|
||||
],
|
||||
"libraries": [
|
||||
"-lShell32.lib",
|
||||
],
|
||||
} ],
|
||||
|
||||
[ 'OS=="mac"', {
|
||||
"xcode_settings": {
|
||||
"OTHER_CPLUSPLUSFLAGS": [
|
||||
"-stdlib=libc++"
|
||||
],
|
||||
"OTHER_LDFLAGS": [
|
||||
"-stdlib=libc++"
|
||||
]
|
||||
}
|
||||
} ]
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -12,12 +12,9 @@ technologies used in Etcher that you should become familiar with:
|
||||
|
||||
- [Electron][electron]
|
||||
- [NodeJS][nodejs]
|
||||
- [AngularJS][angularjs]
|
||||
- [Redux][redux]
|
||||
- [ImmutableJS][immutablejs]
|
||||
- [Bootstrap][bootstrap]
|
||||
- [Sass][sass]
|
||||
- [Flexbox Grid][flexbox-grid]
|
||||
- [Mocha][mocha]
|
||||
- [JSDoc][jsdoc]
|
||||
|
||||
@@ -66,11 +63,8 @@ be documented instead!
|
||||
[gui-dir]: https://github.com/balena-io/etcher/tree/master/lib/gui
|
||||
[electron]: http://electron.atom.io
|
||||
[nodejs]: https://nodejs.org
|
||||
[angularjs]: https://angularjs.org
|
||||
[redux]: http://redux.js.org
|
||||
[immutablejs]: http://facebook.github.io/immutable-js/
|
||||
[bootstrap]: http://getbootstrap.com
|
||||
[sass]: http://sass-lang.com
|
||||
[flexbox-grid]: http://flexboxgrid.com
|
||||
[mocha]: http://mochajs.org
|
||||
[jsdoc]: http://usejsdoc.org
|
||||
|
||||
@@ -91,7 +91,7 @@ make electron-develop
|
||||
|
||||
```sh
|
||||
# Build the GUI
|
||||
make webpack
|
||||
npm run webpack
|
||||
# Start Electron
|
||||
npm start
|
||||
```
|
||||
|
||||
@@ -159,6 +159,18 @@ pre-installed in all modern Windows versions.
|
||||
|
||||
- Run `clean`. This command will completely clean your drive by erasing any
|
||||
existent filesystem.
|
||||
|
||||
- Run `create partition primary`. This command will create a new partition.
|
||||
|
||||
- Run `active`. This command will active the partition.
|
||||
|
||||
- Run `list partition`. This command will show available partition.
|
||||
|
||||
- Run `select partition N`, where `N` corresponds to the id of the newly available partition.
|
||||
|
||||
- Run `format override quick`. This command will format the partition. You can choose a specific formatting by adding `FS=xx` where `xx` could be `NTFS or FAT or FAT32` after `format`. Example : `format FS=NTFS override quick`
|
||||
|
||||
- Run `exit` to quit diskpart.
|
||||
|
||||
### OS X
|
||||
|
||||
@@ -166,7 +178,7 @@ Run the following command in `Terminal.app`, replacing `N` by the corresponding
|
||||
disk number, which you can find by running `diskutil list`:
|
||||
|
||||
```sh
|
||||
diskutil eraseDisk free UNTITLED /dev/diskN
|
||||
diskutil eraseDisk FAT32 UNTITLED MBRFormat /dev/diskN
|
||||
```
|
||||
|
||||
### GNU/Linux
|
||||
|
||||
@@ -1,39 +1,23 @@
|
||||
# https://www.electron.build/configuration/configuration
|
||||
appId: io.balena.etcher
|
||||
copyright: Copyright 2016-2019 Balena Ltd
|
||||
copyright: Copyright 2016-2023 Balena Ltd
|
||||
productName: balenaEtcher
|
||||
npmRebuild: true
|
||||
nodeGypRebuild: true
|
||||
publish: null
|
||||
afterPack: "./afterPack.js"
|
||||
afterPack: ./afterPack.js
|
||||
afterSign: ./afterSignHook.js
|
||||
asar: false
|
||||
files:
|
||||
- build/Release/elevator.node
|
||||
- generated
|
||||
- lib/shared/catalina-sudo/sudo-askpass.osascript.js
|
||||
- lib/gui/app/index.html
|
||||
- lib/gui/css/*.css
|
||||
- lib/gui/css/fonts/*.woff2
|
||||
- lib/gui/assets/*.svg
|
||||
- assets/icon.png
|
||||
- "!node_modules/**/**"
|
||||
- "node_modules/**/*.js"
|
||||
- "node_modules/**/*.json"
|
||||
- "node_modules/**/*.node"
|
||||
- "node_modules/**/*.dll"
|
||||
- node_modules/node-raspberrypi-usbboot/blobs/**
|
||||
- node_modules/flexboxgrid/dist/flexboxgrid.css
|
||||
- node_modules/roboto-fontface/fonts/roboto/Roboto-Thin.woff
|
||||
- node_modules/roboto-fontface/fonts/roboto/Roboto-Light.woff
|
||||
- node_modules/roboto-fontface/fonts/roboto/Roboto-Regular.woff
|
||||
- node_modules/roboto-fontface/fonts/roboto/Roboto-Medium.woff
|
||||
- node_modules/roboto-fontface/fonts/roboto/Roboto-Bold.woff
|
||||
- node_modules/bootstrap-sass/assets/fonts/bootstrap/glyphicons-halflings-regular.woff2
|
||||
- lib/shared/catalina-sudo/sudo-askpass.osascript-zh.js
|
||||
- lib/shared/catalina-sudo/sudo-askpass.osascript-en.js
|
||||
mac:
|
||||
asar: false
|
||||
icon: assets/icon.icns
|
||||
category: public.app-category.developer-tools
|
||||
hardenedRuntime: true
|
||||
entitlements: "entitlements.mac.plist"
|
||||
entitlementsInherit: "entitlements.mac.plist"
|
||||
artifactName: "${productName}-${version}.${ext}"
|
||||
target:
|
||||
- dmg
|
||||
dmg:
|
||||
background: assets/dmg/background.tiff
|
||||
icon: assets/icon.icns
|
||||
@@ -50,6 +34,10 @@ dmg:
|
||||
height: 405
|
||||
win:
|
||||
icon: assets/icon.ico
|
||||
target:
|
||||
- zip
|
||||
- nsis
|
||||
- portable
|
||||
nsis:
|
||||
oneClick: true
|
||||
runAfterFinish: true
|
||||
@@ -62,17 +50,23 @@ portable:
|
||||
artifactName: "${productName}-Portable-${version}.${ext}"
|
||||
requestExecutionLevel: user
|
||||
linux:
|
||||
icon: assets/iconset
|
||||
target:
|
||||
- AppImage
|
||||
- rpm
|
||||
- deb
|
||||
category: Utility
|
||||
packageCategory: utils
|
||||
executableName: balena-etcher-electron
|
||||
executableName: balena-etcher
|
||||
synopsis: balenaEtcher is a powerful OS image flasher built with web technologies to ensure flashing an SDCard or USB drive is a pleasant and safe experience. It protects you from accidentally writing to your hard-drives, ensures every byte of data was written correctly and much more.
|
||||
icon: assets/iconset
|
||||
appImage:
|
||||
artifactName: ${productName}-${version}-${env.ELECTRON_BUILDER_ARCHITECTURE}.${ext}
|
||||
deb:
|
||||
priority: optional
|
||||
compression: bzip2
|
||||
depends:
|
||||
- gconf2
|
||||
- gconf-service
|
||||
- libappindicator1
|
||||
- gconf2
|
||||
- libasound2
|
||||
- libatk1.0-0
|
||||
- libc6
|
||||
@@ -82,6 +76,7 @@ deb:
|
||||
- libexpat1
|
||||
- libfontconfig1
|
||||
- libfreetype6
|
||||
- libgbm1
|
||||
- libgcc1
|
||||
- libgconf-2-4
|
||||
- libgdk-pixbuf2.0-0
|
||||
@@ -91,7 +86,7 @@ deb:
|
||||
- libnotify4
|
||||
- libnspr4
|
||||
- libnss3
|
||||
- libpango1.0-0
|
||||
- libpango1.0-0 | libpango-1.0-0
|
||||
- libstdc++6
|
||||
- libx11-6
|
||||
- libxcomposite1
|
||||
@@ -105,7 +100,11 @@ deb:
|
||||
- libxss1
|
||||
- libxtst6
|
||||
- polkit-1-auth-agent | policykit-1-gnome | polkit-kde-1
|
||||
afterInstall: "./after-install.tpl"
|
||||
rpm:
|
||||
depends:
|
||||
- lsb
|
||||
- libXScrnSaver
|
||||
- util-linux
|
||||
protocols:
|
||||
name: etcher
|
||||
schemes:
|
||||
- etcher
|
||||
|
||||
@@ -1,456 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @module Etcher
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
/* eslint-disable no-var */
|
||||
|
||||
var angular = require('angular')
|
||||
|
||||
/* eslint-enable no-var */
|
||||
|
||||
const electron = require('electron')
|
||||
const sdk = require('etcher-sdk')
|
||||
const _ = require('lodash')
|
||||
const uuidV4 = require('uuid/v4')
|
||||
|
||||
const EXIT_CODES = require('../../shared/exit-codes')
|
||||
const messages = require('../../shared/messages')
|
||||
const store = require('./models/store')
|
||||
const packageJSON = require('../../../package.json')
|
||||
const flashState = require('./models/flash-state')
|
||||
const settings = require('./models/settings')
|
||||
const windowProgress = require('./os/window-progress')
|
||||
const analytics = require('./modules/analytics')
|
||||
const availableDrives = require('./models/available-drives')
|
||||
const driveScanner = require('./modules/drive-scanner')
|
||||
const osDialog = require('./os/dialog')
|
||||
const exceptionReporter = require('./modules/exception-reporter')
|
||||
const updateLock = require('./modules/update-lock')
|
||||
|
||||
/* eslint-disable lodash/prefer-lodash-method,lodash/prefer-get */
|
||||
|
||||
// Enable debug information from all modules that use `debug`
|
||||
// See https://github.com/visionmedia/debug#browser-support
|
||||
//
|
||||
// Enable drivelist debugging information
|
||||
// See https://github.com/balena-io-modules/drivelist
|
||||
process.env.DRIVELIST_DEBUG = /drivelist|^\*$/i.test(process.env.DEBUG) ? '1' : ''
|
||||
window.localStorage.debug = process.env.DEBUG
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
// Promise: event.reason
|
||||
// Bluebird: event.detail.reason
|
||||
// Anything else: event
|
||||
const error = event.reason || (event.detail && event.detail.reason) || event
|
||||
analytics.logException(error)
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
// Set application session UUID
|
||||
store.dispatch({
|
||||
type: store.Actions.SET_APPLICATION_SESSION_UUID,
|
||||
data: uuidV4()
|
||||
})
|
||||
|
||||
// Set first flashing workflow UUID
|
||||
store.dispatch({
|
||||
type: store.Actions.SET_FLASHING_WORKFLOW_UUID,
|
||||
data: uuidV4()
|
||||
})
|
||||
|
||||
const applicationSessionUuid = store.getState().toJS().applicationSessionUuid
|
||||
const flashingWorkflowUuid = store.getState().toJS().flashingWorkflowUuid
|
||||
|
||||
const app = angular.module('Etcher', [
|
||||
require('angular-ui-router'),
|
||||
require('angular-ui-bootstrap'),
|
||||
require('angular-if-state'),
|
||||
|
||||
// Components
|
||||
require('./components/svg-icon'),
|
||||
require('./components/safe-webview'),
|
||||
|
||||
// Pages
|
||||
require('./pages/main/main.ts').MODULE_NAME,
|
||||
require('./components/finish/index.ts').MODULE_NAME
|
||||
])
|
||||
|
||||
app.run(() => {
|
||||
console.log([
|
||||
' _____ _ _',
|
||||
'| ___| | | |',
|
||||
'| |__ | |_ ___| |__ ___ _ __',
|
||||
'| __|| __/ __| \'_ \\ / _ \\ \'__|',
|
||||
'| |___| || (__| | | | __/ |',
|
||||
'\\____/ \\__\\___|_| |_|\\___|_|',
|
||||
'',
|
||||
'Interested in joining the Etcher team?',
|
||||
'Drop us a line at join+etcher@balena.io',
|
||||
'',
|
||||
`Version = ${packageJSON.version}, Type = ${packageJSON.packageType}`
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
app.run(() => {
|
||||
const currentVersion = packageJSON.version
|
||||
|
||||
analytics.logEvent('Application start', {
|
||||
packageType: packageJSON.packageType,
|
||||
version: currentVersion,
|
||||
applicationSessionUuid
|
||||
})
|
||||
})
|
||||
|
||||
app.run(() => {
|
||||
store.observe(() => {
|
||||
if (!flashState.isFlashing()) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentFlashState = flashState.getFlashState()
|
||||
const stateType = !currentFlashState.flashing && currentFlashState.verifying
|
||||
? `Verifying ${currentFlashState.verifying}`
|
||||
: `Flashing ${currentFlashState.flashing}`
|
||||
|
||||
// NOTE: There is usually a short time period between the `isFlashing()`
|
||||
// property being set, and the flashing actually starting, which
|
||||
// might cause some non-sense flashing state logs including
|
||||
// `undefined` values.
|
||||
analytics.logDebug(
|
||||
`${stateType} devices, ` +
|
||||
`${currentFlashState.percentage}% at ${currentFlashState.speed} MB/s ` +
|
||||
`(total ${currentFlashState.totalSpeed} MB/s) ` +
|
||||
`eta in ${currentFlashState.eta}s ` +
|
||||
`with ${currentFlashState.failed} failed devices`
|
||||
)
|
||||
|
||||
windowProgress.set(currentFlashState)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* @summary The radix used by USB ID numbers
|
||||
* @type {Number}
|
||||
* @constant
|
||||
*/
|
||||
const USB_ID_RADIX = 16
|
||||
|
||||
/**
|
||||
* @summary The expected length of a USB ID number
|
||||
* @type {Number}
|
||||
* @constant
|
||||
*/
|
||||
const USB_ID_LENGTH = 4
|
||||
|
||||
/**
|
||||
* @summary Convert a USB id (e.g. product/vendor) to a string
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @param {Number} id - USB id
|
||||
* @returns {String} string id
|
||||
*
|
||||
* @example
|
||||
* console.log(usbIdToString(2652))
|
||||
* > '0x0a5c'
|
||||
*/
|
||||
const usbIdToString = (id) => {
|
||||
return `0x${_.padStart(id.toString(USB_ID_RADIX), USB_ID_LENGTH, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Product ID of BCM2708
|
||||
* @type {Number}
|
||||
* @constant
|
||||
*/
|
||||
const USB_PRODUCT_ID_BCM2708_BOOT = 0x2763
|
||||
|
||||
/**
|
||||
* @summary Product ID of BCM2710
|
||||
* @type {Number}
|
||||
* @constant
|
||||
*/
|
||||
const USB_PRODUCT_ID_BCM2710_BOOT = 0x2764
|
||||
|
||||
/**
|
||||
* @summary Compute module descriptions
|
||||
* @type {Object}
|
||||
* @constant
|
||||
*/
|
||||
const COMPUTE_MODULE_DESCRIPTIONS = {
|
||||
[USB_PRODUCT_ID_BCM2708_BOOT]: 'Compute Module 1',
|
||||
[USB_PRODUCT_ID_BCM2710_BOOT]: 'Compute Module 3'
|
||||
}
|
||||
|
||||
app.run(($timeout) => {
|
||||
const BLACKLISTED_DRIVES = settings.has('driveBlacklist')
|
||||
? settings.get('driveBlacklist').split(',')
|
||||
: []
|
||||
|
||||
// eslint-disable-next-line require-jsdoc
|
||||
const driveIsAllowed = (drive) => {
|
||||
return !(
|
||||
BLACKLISTED_DRIVES.includes(drive.devicePath) ||
|
||||
BLACKLISTED_DRIVES.includes(drive.device) ||
|
||||
BLACKLISTED_DRIVES.includes(drive.raw)
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line require-jsdoc,consistent-return
|
||||
const prepareDrive = (drive) => {
|
||||
if (drive instanceof sdk.sourceDestination.BlockDevice) {
|
||||
return drive.drive
|
||||
} else if (drive instanceof sdk.sourceDestination.UsbbootDrive) {
|
||||
// This is a workaround etcher expecting a device string and a size
|
||||
drive.device = drive.usbDevice.portId
|
||||
drive.size = null
|
||||
drive.progress = 0
|
||||
drive.disabled = true
|
||||
drive.on('progress', (progress) => {
|
||||
updateDriveProgress(drive, progress)
|
||||
})
|
||||
return drive
|
||||
} else if (drive instanceof sdk.sourceDestination.DriverlessDevice) {
|
||||
const description = COMPUTE_MODULE_DESCRIPTIONS[drive.deviceDescriptor.idProduct] || 'Compute Module'
|
||||
return {
|
||||
device: `${usbIdToString(drive.deviceDescriptor.idVendor)}:${usbIdToString(drive.deviceDescriptor.idProduct)}`,
|
||||
displayName: 'Missing drivers',
|
||||
description,
|
||||
mountpoints: [],
|
||||
isReadOnly: false,
|
||||
isSystem: false,
|
||||
disabled: true,
|
||||
icon: 'warning',
|
||||
size: null,
|
||||
link: 'https://www.raspberrypi.org/documentation/hardware/computemodule/cm-emmc-flashing.md',
|
||||
linkCTA: 'Install',
|
||||
linkTitle: 'Install missing drivers',
|
||||
linkMessage: [
|
||||
'Would you like to download the necessary drivers from the Raspberry Pi Foundation?',
|
||||
'This will open your browser.\n\n',
|
||||
'Once opened, download and run the installer from the "Windows Installer" section to install the drivers.'
|
||||
].join(' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line require-jsdoc
|
||||
const setDrives = (drives) => {
|
||||
availableDrives.setDrives(_.values(drives))
|
||||
|
||||
// Safely trigger a digest cycle.
|
||||
// In some cases, AngularJS doesn't acknowledge that the
|
||||
// available drives list has changed, and incorrectly
|
||||
// keeps asking the user to "Connect a drive".
|
||||
$timeout()
|
||||
}
|
||||
|
||||
// eslint-disable-next-line require-jsdoc
|
||||
const getDrives = () => {
|
||||
return _.keyBy(availableDrives.getDrives() || [], 'device')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line require-jsdoc
|
||||
const addDrive = (drive) => {
|
||||
const preparedDrive = prepareDrive(drive)
|
||||
if (!driveIsAllowed(preparedDrive)) {
|
||||
return
|
||||
}
|
||||
const drives = getDrives()
|
||||
drives[preparedDrive.device] = preparedDrive
|
||||
setDrives(drives)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line require-jsdoc
|
||||
const removeDrive = (drive) => {
|
||||
const preparedDrive = prepareDrive(drive)
|
||||
const drives = getDrives()
|
||||
// eslint-disable-next-line prefer-reflect
|
||||
delete drives[preparedDrive.device]
|
||||
setDrives(drives)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line require-jsdoc
|
||||
const updateDriveProgress = (drive, progress) => {
|
||||
const drives = getDrives()
|
||||
const driveInMap = drives[drive.device]
|
||||
if (driveInMap) {
|
||||
driveInMap.progress = progress
|
||||
setDrives(drives)
|
||||
}
|
||||
}
|
||||
|
||||
driveScanner.on('attach', addDrive)
|
||||
driveScanner.on('detach', removeDrive)
|
||||
|
||||
driveScanner.on('error', (error) => {
|
||||
// Stop the drive scanning loop in case of errors,
|
||||
// otherwise we risk presenting the same error over
|
||||
// and over again to the user, while also heavily
|
||||
// spamming our error reporting service.
|
||||
driveScanner.stop()
|
||||
|
||||
return exceptionReporter.report(error)
|
||||
})
|
||||
|
||||
driveScanner.start()
|
||||
})
|
||||
|
||||
app.run(($window) => {
|
||||
let popupExists = false
|
||||
|
||||
$window.addEventListener('beforeunload', (event) => {
|
||||
if (!flashState.isFlashing() || popupExists) {
|
||||
analytics.logEvent('Close application', {
|
||||
isFlashing: flashState.isFlashing(),
|
||||
applicationSessionUuid
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Don't close window while flashing
|
||||
event.returnValue = false
|
||||
|
||||
// Don't open any more popups
|
||||
popupExists = true
|
||||
|
||||
analytics.logEvent('Close attempt while flashing', { applicationSessionUuid, flashingWorkflowUuid })
|
||||
|
||||
osDialog.showWarning({
|
||||
confirmationLabel: 'Yes, quit',
|
||||
rejectionLabel: 'Cancel',
|
||||
title: 'Are you sure you want to close Etcher?',
|
||||
description: messages.warning.exitWhileFlashing()
|
||||
}).then((confirmed) => {
|
||||
if (confirmed) {
|
||||
analytics.logEvent('Close confirmed while flashing', {
|
||||
flashInstanceUuid: flashState.getFlashUuid(),
|
||||
applicationSessionUuid,
|
||||
flashingWorkflowUuid
|
||||
})
|
||||
|
||||
// This circumvents the 'beforeunload' event unlike
|
||||
// electron.remote.app.quit() which does not.
|
||||
electron.remote.process.exit(EXIT_CODES.SUCCESS)
|
||||
}
|
||||
|
||||
analytics.logEvent('Close rejected while flashing', { applicationSessionUuid, flashingWorkflowUuid })
|
||||
popupExists = false
|
||||
}).catch(exceptionReporter.report)
|
||||
})
|
||||
|
||||
/**
|
||||
* @summary Helper fn for events
|
||||
* @function
|
||||
* @private
|
||||
* @example
|
||||
* window.addEventListener('click', extendLock)
|
||||
*/
|
||||
const extendLock = () => {
|
||||
updateLock.extend()
|
||||
}
|
||||
|
||||
$window.addEventListener('click', extendLock)
|
||||
$window.addEventListener('touchstart', extendLock)
|
||||
|
||||
// Initial update lock acquisition
|
||||
extendLock()
|
||||
})
|
||||
|
||||
app.run(($rootScope) => {
|
||||
$rootScope.$on('$stateChangeSuccess', (event, toState, toParams, fromState) => {
|
||||
// Ignore first navigation
|
||||
if (!fromState.name) {
|
||||
return
|
||||
}
|
||||
|
||||
analytics.logEvent('Navigate', {
|
||||
to: toState.name,
|
||||
from: fromState.name,
|
||||
applicationSessionUuid
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
app.config(($urlRouterProvider) => {
|
||||
$urlRouterProvider.otherwise('/main')
|
||||
})
|
||||
|
||||
app.config(($provide) => {
|
||||
$provide.decorator('$exceptionHandler', ($delegate) => {
|
||||
return (exception, cause) => {
|
||||
exceptionReporter.report(exception)
|
||||
$delegate(exception, cause)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.config(($locationProvider) => {
|
||||
// NOTE(Shou): this seems to invoke a minor perf decrease when set to true
|
||||
$locationProvider.html5Mode({
|
||||
rewriteLinks: false
|
||||
})
|
||||
})
|
||||
|
||||
app.controller('StateController', function ($rootScope, $scope) {
|
||||
const unregisterStateChange = $rootScope.$on('$stateChangeSuccess', (event, toState, toParams, fromState) => {
|
||||
this.previousName = fromState.name
|
||||
this.currentName = toState.name
|
||||
})
|
||||
|
||||
$scope.$on('$destroy', unregisterStateChange)
|
||||
|
||||
/**
|
||||
* @summary Get the previous state name
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {String} previous state name
|
||||
*
|
||||
* @example
|
||||
* if (StateController.previousName === 'main') {
|
||||
* console.log('We left the main screen!');
|
||||
* }
|
||||
*/
|
||||
this.previousName = null
|
||||
|
||||
/**
|
||||
* @summary Get the current state name
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {String} current state name
|
||||
*
|
||||
* @example
|
||||
* if (StateController.currentName === 'main') {
|
||||
* console.log('We are on the main screen!');
|
||||
* }
|
||||
*/
|
||||
this.currentName = null
|
||||
})
|
||||
|
||||
// Ensure user settings are loaded before
|
||||
// we bootstrap the Angular.js application
|
||||
angular.element(document).ready(() => {
|
||||
settings.load().then(() => {
|
||||
angular.bootstrap(document, [ 'Etcher' ])
|
||||
}).catch(exceptionReporter.report)
|
||||
})
|
||||
364
lib/gui/app/app.ts
Normal file
364
lib/gui/app/app.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as electron from 'electron';
|
||||
import * as sdk from 'etcher-sdk';
|
||||
import * as _ from 'lodash';
|
||||
import outdent from 'outdent';
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
|
||||
import * as packageJSON from '../../../package.json';
|
||||
import { DrivelistDrive, isSourceDrive } from '../../shared/drive-constraints';
|
||||
import * as EXIT_CODES from '../../shared/exit-codes';
|
||||
import * as messages from '../../shared/messages';
|
||||
import * as availableDrives from './models/available-drives';
|
||||
import * as flashState from './models/flash-state';
|
||||
import { deselectImage, getImage } from './models/selection-state';
|
||||
import * as settings from './models/settings';
|
||||
import { Actions, observe, store } from './models/store';
|
||||
import * as analytics from './modules/analytics';
|
||||
import { scanner as driveScanner } from './modules/drive-scanner';
|
||||
import * as exceptionReporter from './modules/exception-reporter';
|
||||
import * as osDialog from './os/dialog';
|
||||
import * as windowProgress from './os/window-progress';
|
||||
import MainPage from './pages/main/MainPage';
|
||||
import './css/main.css';
|
||||
import * as i18next from 'i18next';
|
||||
|
||||
window.addEventListener(
|
||||
'unhandledrejection',
|
||||
(event: PromiseRejectionEvent | any) => {
|
||||
// Promise: event.reason
|
||||
// Anything else: event
|
||||
const error = event.reason || event;
|
||||
analytics.logException(error);
|
||||
event.preventDefault();
|
||||
},
|
||||
);
|
||||
|
||||
// Set application session UUID
|
||||
store.dispatch({
|
||||
type: Actions.SET_APPLICATION_SESSION_UUID,
|
||||
data: uuidV4(),
|
||||
});
|
||||
|
||||
// Set first flashing workflow UUID
|
||||
store.dispatch({
|
||||
type: Actions.SET_FLASHING_WORKFLOW_UUID,
|
||||
data: uuidV4(),
|
||||
});
|
||||
|
||||
const applicationSessionUuid = store.getState().toJS().applicationSessionUuid;
|
||||
const flashingWorkflowUuid = store.getState().toJS().flashingWorkflowUuid;
|
||||
|
||||
console.log(outdent`
|
||||
${outdent}
|
||||
_____ _ _
|
||||
| ___| | | |
|
||||
| |__ | |_ ___| |__ ___ _ __
|
||||
| __|| __/ __| '_ \\ / _ \\ '__|
|
||||
| |___| || (__| | | | __/ |
|
||||
\\____/ \\__\\___|_| |_|\\___|_|
|
||||
|
||||
Interested in joining the Etcher team?
|
||||
Drop us a line at join+etcher@balena.io
|
||||
|
||||
Version = ${packageJSON.version}, Type = ${packageJSON.packageType}
|
||||
`);
|
||||
|
||||
const currentVersion = packageJSON.version;
|
||||
|
||||
analytics.logEvent('Application start', {
|
||||
packageType: packageJSON.packageType,
|
||||
version: currentVersion,
|
||||
});
|
||||
|
||||
const debouncedLog = _.debounce(console.log, 1000, { maxWait: 1000 });
|
||||
|
||||
function pluralize(word: string, quantity: number) {
|
||||
return `${quantity} ${word}${quantity === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
observe(() => {
|
||||
if (!flashState.isFlashing()) {
|
||||
return;
|
||||
}
|
||||
const currentFlashState = flashState.getFlashState();
|
||||
windowProgress.set(currentFlashState);
|
||||
|
||||
let eta = '';
|
||||
if (currentFlashState.eta !== undefined) {
|
||||
eta = `eta in ${currentFlashState.eta.toFixed(0)}s`;
|
||||
}
|
||||
let active = '';
|
||||
if (currentFlashState.type !== 'decompressing') {
|
||||
active = pluralize('device', currentFlashState.active);
|
||||
}
|
||||
// NOTE: There is usually a short time period between the `isFlashing()`
|
||||
// property being set, and the flashing actually starting, which
|
||||
// might cause some non-sense flashing state logs including
|
||||
// `undefined` values.
|
||||
debouncedLog(outdent({ newline: ' ' })`
|
||||
${_.capitalize(currentFlashState.type)}
|
||||
${active},
|
||||
${currentFlashState.percentage}%
|
||||
at
|
||||
${(currentFlashState.speed || 0).toFixed(2)}
|
||||
MB/s
|
||||
(total ${(currentFlashState.speed * currentFlashState.active).toFixed(2)} MB/s)
|
||||
${eta}
|
||||
with
|
||||
${pluralize('failed device', currentFlashState.failed)}
|
||||
`);
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary The radix used by USB ID numbers
|
||||
*/
|
||||
const USB_ID_RADIX = 16;
|
||||
|
||||
/**
|
||||
* @summary The expected length of a USB ID number
|
||||
*/
|
||||
const USB_ID_LENGTH = 4;
|
||||
|
||||
/**
|
||||
* @summary Convert a USB id (e.g. product/vendor) to a string
|
||||
*
|
||||
* @example
|
||||
* console.log(usbIdToString(2652))
|
||||
* > '0x0a5c'
|
||||
*/
|
||||
function usbIdToString(id: number): string {
|
||||
return `0x${_.padStart(id.toString(USB_ID_RADIX), USB_ID_LENGTH, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Product ID of BCM2708
|
||||
*/
|
||||
const USB_PRODUCT_ID_BCM2708_BOOT = 0x2763;
|
||||
|
||||
/**
|
||||
* @summary Product ID of BCM2710
|
||||
*/
|
||||
const USB_PRODUCT_ID_BCM2710_BOOT = 0x2764;
|
||||
|
||||
/**
|
||||
* @summary Compute module descriptions
|
||||
*/
|
||||
const COMPUTE_MODULE_DESCRIPTIONS: _.Dictionary<string> = {
|
||||
[USB_PRODUCT_ID_BCM2708_BOOT]: 'Compute Module 1',
|
||||
[USB_PRODUCT_ID_BCM2710_BOOT]: 'Compute Module 3',
|
||||
};
|
||||
|
||||
async function driveIsAllowed(drive: {
|
||||
devicePath: string;
|
||||
device: string;
|
||||
raw: string;
|
||||
}) {
|
||||
const driveBlacklist = (await settings.get('driveBlacklist')) || [];
|
||||
return !(
|
||||
driveBlacklist.includes(drive.devicePath) ||
|
||||
driveBlacklist.includes(drive.device) ||
|
||||
driveBlacklist.includes(drive.raw)
|
||||
);
|
||||
}
|
||||
|
||||
type Drive =
|
||||
| sdk.sourceDestination.BlockDevice
|
||||
| sdk.sourceDestination.UsbbootDrive
|
||||
| sdk.sourceDestination.DriverlessDevice;
|
||||
|
||||
function prepareDrive(drive: Drive) {
|
||||
if (drive instanceof sdk.sourceDestination.BlockDevice) {
|
||||
// @ts-ignore (BlockDevice.drive is private)
|
||||
return drive.drive;
|
||||
} else if (drive instanceof sdk.sourceDestination.UsbbootDrive) {
|
||||
// This is a workaround etcher expecting a device string and a size
|
||||
// @ts-ignore
|
||||
drive.device = drive.usbDevice.portId;
|
||||
drive.size = null;
|
||||
// @ts-ignore
|
||||
drive.progress = 0;
|
||||
drive.disabled = true;
|
||||
drive.on('progress', (progress) => {
|
||||
updateDriveProgress(drive, progress);
|
||||
});
|
||||
return drive;
|
||||
} else if (drive instanceof sdk.sourceDestination.DriverlessDevice) {
|
||||
const description =
|
||||
COMPUTE_MODULE_DESCRIPTIONS[
|
||||
drive.deviceDescriptor.idProduct.toString()
|
||||
] || 'Compute Module';
|
||||
return {
|
||||
device: `${usbIdToString(
|
||||
drive.deviceDescriptor.idVendor,
|
||||
)}:${usbIdToString(drive.deviceDescriptor.idProduct)}`,
|
||||
displayName: 'Missing drivers',
|
||||
description,
|
||||
mountpoints: [],
|
||||
isReadOnly: false,
|
||||
isSystem: false,
|
||||
disabled: true,
|
||||
icon: 'warning',
|
||||
size: null,
|
||||
link: 'https://www.raspberrypi.com/documentation/computers/compute-module.html#flashing-the-compute-module-emmc',
|
||||
linkCTA: 'Install',
|
||||
linkTitle: 'Install missing drivers',
|
||||
linkMessage: outdent`
|
||||
Would you like to download the necessary drivers from the Raspberry Pi Foundation?
|
||||
This will open your browser.
|
||||
|
||||
|
||||
Once opened, download and run the installer from the "Windows Installer" section to install the drivers
|
||||
`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function setDrives(drives: _.Dictionary<DrivelistDrive>) {
|
||||
availableDrives.setDrives(_.values(drives));
|
||||
}
|
||||
|
||||
function getDrives() {
|
||||
return _.keyBy(availableDrives.getDrives(), 'device');
|
||||
}
|
||||
|
||||
async function addDrive(drive: Drive) {
|
||||
const preparedDrive = prepareDrive(drive);
|
||||
if (!(await driveIsAllowed(preparedDrive))) {
|
||||
return;
|
||||
}
|
||||
const drives = getDrives();
|
||||
drives[preparedDrive.device] = preparedDrive;
|
||||
setDrives(drives);
|
||||
}
|
||||
|
||||
function removeDrive(drive: Drive) {
|
||||
if (
|
||||
drive instanceof sdk.sourceDestination.BlockDevice &&
|
||||
// @ts-ignore BlockDevice.drive is private
|
||||
isSourceDrive(drive.drive, getImage())
|
||||
) {
|
||||
// Deselect the image if it was on the drive that was removed.
|
||||
// This will also deselect the image if the drive mountpoints change.
|
||||
deselectImage();
|
||||
}
|
||||
const preparedDrive = prepareDrive(drive);
|
||||
const drives = getDrives();
|
||||
delete drives[preparedDrive.device];
|
||||
setDrives(drives);
|
||||
}
|
||||
|
||||
function updateDriveProgress(
|
||||
drive: sdk.sourceDestination.UsbbootDrive,
|
||||
progress: number,
|
||||
) {
|
||||
const drives = getDrives();
|
||||
// @ts-ignore
|
||||
const driveInMap = drives[drive.device];
|
||||
if (driveInMap) {
|
||||
// @ts-ignore
|
||||
drives[drive.device] = { ...driveInMap, progress };
|
||||
setDrives(drives);
|
||||
}
|
||||
}
|
||||
|
||||
driveScanner.on('attach', addDrive);
|
||||
driveScanner.on('detach', removeDrive);
|
||||
|
||||
driveScanner.on('error', (error) => {
|
||||
// Stop the drive scanning loop in case of errors,
|
||||
// otherwise we risk presenting the same error over
|
||||
// and over again to the user, while also heavily
|
||||
// spamming our error reporting service.
|
||||
driveScanner.stop();
|
||||
|
||||
return exceptionReporter.report(error);
|
||||
});
|
||||
|
||||
driveScanner.start();
|
||||
|
||||
let popupExists = false;
|
||||
|
||||
window.addEventListener('beforeunload', async (event) => {
|
||||
if (!flashState.isFlashing() || popupExists) {
|
||||
analytics.logEvent('Close application', {
|
||||
isFlashing: flashState.isFlashing(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't close window while flashing
|
||||
event.returnValue = false;
|
||||
|
||||
// Don't open any more popups
|
||||
popupExists = true;
|
||||
|
||||
analytics.logEvent('Close attempt while flashing');
|
||||
|
||||
try {
|
||||
const confirmed = await osDialog.showWarning({
|
||||
confirmationLabel: i18next.t('yesExit'),
|
||||
rejectionLabel: i18next.t('cancel'),
|
||||
title: i18next.t('reallyExit'),
|
||||
description: messages.warning.exitWhileFlashing(),
|
||||
});
|
||||
if (confirmed) {
|
||||
analytics.logEvent('Close confirmed while flashing', {
|
||||
flashInstanceUuid: flashState.getFlashUuid(),
|
||||
});
|
||||
|
||||
// This circumvents the 'beforeunload' event unlike
|
||||
// electron.remote.app.quit() which does not.
|
||||
electron.remote.process.exit(EXIT_CODES.SUCCESS);
|
||||
}
|
||||
|
||||
analytics.logEvent('Close rejected while flashing', {
|
||||
applicationSessionUuid,
|
||||
flashingWorkflowUuid,
|
||||
});
|
||||
popupExists = false;
|
||||
} catch (error: any) {
|
||||
exceptionReporter.report(error);
|
||||
}
|
||||
});
|
||||
|
||||
export async function main() {
|
||||
try {
|
||||
const { init: ledsInit } = require('./models/leds');
|
||||
await ledsInit();
|
||||
} catch (error: any) {
|
||||
exceptionReporter.report(error);
|
||||
}
|
||||
|
||||
ReactDOM.render(
|
||||
React.createElement(MainPage),
|
||||
document.getElementById('main'),
|
||||
// callback to set the correct zoomFactor for webviews as well
|
||||
async () => {
|
||||
const fullscreen = await settings.get('fullscreen');
|
||||
const width = fullscreen ? window.screen.width : window.outerWidth;
|
||||
try {
|
||||
electron.webFrame.setZoomFactor(width / settings.DEFAULT_WIDTH);
|
||||
} catch (err) {
|
||||
// noop
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
/*
|
||||
* Copyright 2019 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const React = require('react')
|
||||
const { Modal } = require('rendition')
|
||||
const {
|
||||
isDriveValid,
|
||||
getDriveImageCompatibilityStatuses,
|
||||
hasListDriveImageCompatibilityStatus,
|
||||
COMPATIBILITY_STATUS_TYPES
|
||||
} = require('../../../../shared/drive-constraints')
|
||||
const store = require('../../models/store')
|
||||
const analytics = require('../../modules/analytics')
|
||||
const availableDrives = require('../../models/available-drives')
|
||||
const selectionState = require('../../models/selection-state')
|
||||
const { bytesToClosestUnit } = require('../../../../shared/units')
|
||||
const utils = require('../../../../shared/utils')
|
||||
const { open: openExternal } = require('../../os/open-external/services/open-external')
|
||||
|
||||
/**
|
||||
* @summary Determine if we can change a drive's selection state
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @param {Object} drive - drive
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @example
|
||||
* shouldChangeDriveSelectionState(drive)
|
||||
* .then((shouldChangeDriveSelectionState) => {
|
||||
* if (shouldChangeDriveSelectionState) doSomething();
|
||||
* });
|
||||
*/
|
||||
const shouldChangeDriveSelectionState = (drive) => {
|
||||
return isDriveValid(drive, selectionState.getImage())
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Toggle a drive selection
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {Object} drive - drive
|
||||
* @returns {void}
|
||||
*
|
||||
* @example
|
||||
* toggleDrive({
|
||||
* device: '/dev/disk2',
|
||||
* size: 999999999,
|
||||
* name: 'Cruzer USB drive'
|
||||
* });
|
||||
*/
|
||||
const toggleDrive = (drive) => {
|
||||
const canChangeDriveSelectionState = shouldChangeDriveSelectionState(drive)
|
||||
|
||||
if (canChangeDriveSelectionState) {
|
||||
analytics.logEvent('Toggle drive', {
|
||||
drive,
|
||||
previouslySelected: selectionState.isCurrentDrive(availableDrives.device),
|
||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
|
||||
})
|
||||
|
||||
selectionState.toggleDrive(drive.device)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Memoized getDrives function
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {Array<Object>} - memoized list of drives
|
||||
*
|
||||
* @example
|
||||
* const drives = getDrives()
|
||||
* // Do something with drives
|
||||
*/
|
||||
const getDrives = utils.memoize(availableDrives.getDrives, _.isEqual)
|
||||
|
||||
/**
|
||||
* @summary Get a drive's compatibility status object(s)
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @description
|
||||
* Given a drive, return its compatibility status with the selected image,
|
||||
* containing the status type (ERROR, WARNING), and accompanying
|
||||
* status message.
|
||||
*
|
||||
* @returns {Object[]} list of objects containing statuses
|
||||
*
|
||||
* @example
|
||||
* const statuses = getDriveStatuses(drive);
|
||||
*
|
||||
* for ({ type, message } of statuses) {
|
||||
* // do something
|
||||
* }
|
||||
*/
|
||||
const getDriveStatuses = utils.memoize((drive) => {
|
||||
return getDriveImageCompatibilityStatuses(drive, selectionState.getImage())
|
||||
}, _.isEqual)
|
||||
|
||||
/**
|
||||
* @summary Keyboard event drive toggling
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @description
|
||||
* Keyboard-event specific entry to the toggleDrive function.
|
||||
*
|
||||
* @param {Object} drive - drive
|
||||
* @param {Object} evt - event
|
||||
*
|
||||
* @example
|
||||
* <div tabindex="1" onKeyPress="keyboardToggleDrive(drive, evt)">
|
||||
* Tab-select me and press enter or space!
|
||||
* </div>
|
||||
*/
|
||||
const keyboardToggleDrive = (drive, evt) => {
|
||||
const ENTER = 13
|
||||
const SPACE = 32
|
||||
if (_.includes([ ENTER, SPACE ], evt.keyCode)) {
|
||||
toggleDrive(drive)
|
||||
}
|
||||
}
|
||||
|
||||
const DriveSelectorModal = ({ close }) => {
|
||||
const [ confirmModal, setConfirmModal ] = React.useState({ open: false })
|
||||
const [ drives, setDrives ] = React.useState(getDrives())
|
||||
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
setDrives(availableDrives.getDrives())
|
||||
})
|
||||
return unsubscribe
|
||||
})
|
||||
|
||||
/**
|
||||
* @summary Prompt the user to install missing usbboot drivers
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {Object} drive - drive
|
||||
* @returns {void}
|
||||
*
|
||||
* @example
|
||||
* installMissingDrivers({
|
||||
* linkTitle: 'Go to example.com',
|
||||
* linkMessage: 'Examples are great, right?',
|
||||
* linkCTA: 'Call To Action',
|
||||
* link: 'https://example.com'
|
||||
* });
|
||||
*/
|
||||
const installMissingDrivers = (drive) => {
|
||||
if (drive.link) {
|
||||
analytics.logEvent('Open driver link modal', {
|
||||
url: drive.link,
|
||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
|
||||
})
|
||||
|
||||
setConfirmModal({
|
||||
open: true,
|
||||
options: {
|
||||
width: 400,
|
||||
title: drive.linkTitle,
|
||||
cancel: () => setConfirmModal({ open: false }),
|
||||
done: async (shouldContinue) => {
|
||||
try {
|
||||
if (shouldContinue) {
|
||||
openExternal(drive.link)
|
||||
} else {
|
||||
setConfirmModal({ open: false })
|
||||
}
|
||||
} catch (error) {
|
||||
analytics.logException(error)
|
||||
}
|
||||
},
|
||||
action: 'Yes, continue',
|
||||
cancelButtonProps: {
|
||||
children: 'Cancel'
|
||||
},
|
||||
children: drive.linkMessage || `Etcher will open ${drive.link} in your browser`
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Select a drive and close the modal
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {Object} drive - drive
|
||||
* @returns {void}
|
||||
*
|
||||
* @example
|
||||
* selectDriveAndClose({
|
||||
* device: '/dev/disk2',
|
||||
* size: 999999999,
|
||||
* name: 'Cruzer USB drive'
|
||||
* });
|
||||
*/
|
||||
const selectDriveAndClose = async (drive) => {
|
||||
const canChangeDriveSelectionState = await shouldChangeDriveSelectionState(drive)
|
||||
|
||||
if (canChangeDriveSelectionState) {
|
||||
selectionState.selectDrive(drive.device)
|
||||
|
||||
analytics.logEvent('Drive selected (double click)', {
|
||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
|
||||
})
|
||||
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
const hasStatus = hasListDriveImageCompatibilityStatus(selectionState.getSelectedDrives(), selectionState.getImage())
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className='modal-drive-selector-modal'
|
||||
title='Select a Drive'
|
||||
done={close}
|
||||
action='Continue'
|
||||
style={{
|
||||
padding: '20px 30px 11px 30px'
|
||||
}}
|
||||
primaryButtonProps={{
|
||||
primary: !hasStatus,
|
||||
warning: hasStatus
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<ul style={{
|
||||
height: '250px',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
padding: '0'
|
||||
}}>
|
||||
{_.map(drives, (drive, index) => {
|
||||
return (
|
||||
<li
|
||||
key={`item-${drive.displayName}`}
|
||||
className="list-group-item"
|
||||
disabled={!isDriveValid(drive, selectionState.getImage())}
|
||||
onDoubleClick={() => selectDriveAndClose(drive, close)}
|
||||
onClick={() => toggleDrive(drive)}
|
||||
>
|
||||
{drive.icon && <img className="list-group-item-section" alt="Drive device type logo"
|
||||
src={`../assets/${drive.icon}.svg`}
|
||||
width="25"
|
||||
height="30"/>}
|
||||
<div
|
||||
className="list-group-item-section list-group-item-section-expanded"
|
||||
// eslint-disable-next-line no-magic-numbers
|
||||
tabIndex={ 15 + index }
|
||||
onKeyPress={(evt) => keyboardToggleDrive(drive, evt)}>
|
||||
|
||||
<h6 className="list-group-item-heading">
|
||||
{ drive.description }
|
||||
{drive.size && <span className="word-keep"> - { bytesToClosestUnit(drive.size) }</span>}
|
||||
</h6>
|
||||
{!drive.link && <p className="list-group-item-text">
|
||||
{ drive.displayName }
|
||||
</p>}
|
||||
{drive.link && <p className="list-group-item-text">
|
||||
{ drive.displayName } - <b><a onClick={() => installMissingDrivers(drive)}>{ drive.linkCTA }</a></b>
|
||||
</p>}
|
||||
|
||||
<footer className="list-group-item-footer">
|
||||
{_.map(getDriveStatuses(drive), (status, idx) => {
|
||||
const className = {
|
||||
[COMPATIBILITY_STATUS_TYPES.WARNING]: 'label-warning',
|
||||
[COMPATIBILITY_STATUS_TYPES.ERROR]: 'label-danger'
|
||||
}
|
||||
return (
|
||||
<span key={`${drive.displayName}-status-${idx}`} className={`label ${className[status.type]}`}>
|
||||
{ status.message }
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</footer>
|
||||
{Boolean(drive.progress) && (
|
||||
<progress
|
||||
className='drive-init-progress'
|
||||
value={ drive.progress }
|
||||
max="100">
|
||||
</progress>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDriveValid(drive, selectionState.getImage()) && (
|
||||
<span className="list-group-item-section tick tick--success"
|
||||
disabled={!selectionState.isDriveSelected(drive.device)}>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
{!availableDrives.hasAvailableDrives() && <li className="list-group-item">
|
||||
<div>
|
||||
<b>Connect a drive!</b>
|
||||
<div>No removable drive detected.</div>
|
||||
</div>
|
||||
</li>}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{confirmModal.open && <Modal
|
||||
{...confirmModal.options}
|
||||
>
|
||||
</Modal>
|
||||
}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = DriveSelectorModal
|
||||
563
lib/gui/app/components/drive-selector/drive-selector.tsx
Normal file
563
lib/gui/app/components/drive-selector/drive-selector.tsx
Normal file
@@ -0,0 +1,563 @@
|
||||
/*
|
||||
* Copyright 2019 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg';
|
||||
import ChevronDownSvg from '@fortawesome/fontawesome-free/svgs/solid/chevron-down.svg';
|
||||
import * as sourceDestination from 'etcher-sdk/build/source-destination/';
|
||||
import * as React from 'react';
|
||||
import { Flex, ModalProps, Txt, Badge, Link, TableColumn } from 'rendition';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import {
|
||||
getDriveImageCompatibilityStatuses,
|
||||
isDriveValid,
|
||||
DriveStatus,
|
||||
DrivelistDrive,
|
||||
isDriveSizeLarge,
|
||||
} from '../../../../shared/drive-constraints';
|
||||
import { compatibility, warning } from '../../../../shared/messages';
|
||||
import * as prettyBytes from 'pretty-bytes';
|
||||
import { getDrives, hasAvailableDrives } from '../../models/available-drives';
|
||||
import { getImage, isDriveSelected } from '../../models/selection-state';
|
||||
import { store } from '../../models/store';
|
||||
import { logEvent, logException } from '../../modules/analytics';
|
||||
import { open as openExternal } from '../../os/open-external/services/open-external';
|
||||
import {
|
||||
Alert,
|
||||
GenericTableProps,
|
||||
Modal,
|
||||
Table,
|
||||
} from '../../styled-components';
|
||||
|
||||
import { SourceMetadata } from '../source-selector/source-selector';
|
||||
import { middleEllipsis } from '../../utils/middle-ellipsis';
|
||||
import * as i18next from 'i18next';
|
||||
|
||||
interface UsbbootDrive extends sourceDestination.UsbbootDrive {
|
||||
progress: number;
|
||||
}
|
||||
|
||||
interface DriverlessDrive {
|
||||
displayName: string; // added in app.ts
|
||||
description: string;
|
||||
link: string;
|
||||
linkTitle: string;
|
||||
linkMessage: string;
|
||||
linkCTA: string;
|
||||
}
|
||||
|
||||
type Drive = DrivelistDrive | DriverlessDrive | UsbbootDrive;
|
||||
|
||||
function isUsbbootDrive(drive: Drive): drive is UsbbootDrive {
|
||||
return (drive as UsbbootDrive).progress !== undefined;
|
||||
}
|
||||
|
||||
function isDriverlessDrive(drive: Drive): drive is DriverlessDrive {
|
||||
return (drive as DriverlessDrive).link !== undefined;
|
||||
}
|
||||
|
||||
function isDrivelistDrive(drive: Drive): drive is DrivelistDrive {
|
||||
return typeof (drive as DrivelistDrive).size === 'number';
|
||||
}
|
||||
|
||||
const DrivesTable = styled((props: GenericTableProps<Drive>) => (
|
||||
<Table<Drive> {...props} />
|
||||
))`
|
||||
[data-display='table-head'],
|
||||
[data-display='table-body'] {
|
||||
> [data-display='table-row'] > [data-display='table-cell'] {
|
||||
&:nth-child(2) {
|
||||
width: 32%;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
&:nth-child(5) {
|
||||
width: 32%;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function badgeShadeFromStatus(status: string) {
|
||||
switch (status) {
|
||||
case compatibility.containsImage():
|
||||
return 16;
|
||||
case compatibility.system():
|
||||
case compatibility.tooSmall():
|
||||
return 5;
|
||||
default:
|
||||
return 14;
|
||||
}
|
||||
}
|
||||
|
||||
const InitProgress = styled(
|
||||
({
|
||||
value,
|
||||
...props
|
||||
}: {
|
||||
value: number;
|
||||
props?: React.ProgressHTMLAttributes<Element>;
|
||||
}) => {
|
||||
return <progress max="100" value={value} {...props} />;
|
||||
},
|
||||
)`
|
||||
/* Reset the default appearance */
|
||||
appearance: none;
|
||||
|
||||
::-webkit-progress-bar {
|
||||
width: 130px;
|
||||
height: 4px;
|
||||
background-color: #dde1f0;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
::-webkit-progress-value {
|
||||
background-color: #1496e1;
|
||||
border-radius: 14px;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface DriveSelectorProps
|
||||
extends Omit<ModalProps, 'done' | 'cancel' | 'onSelect'> {
|
||||
write: boolean;
|
||||
multipleSelection: boolean;
|
||||
showWarnings?: boolean;
|
||||
cancel: (drives: DrivelistDrive[]) => void;
|
||||
done: (drives: DrivelistDrive[]) => void;
|
||||
titleLabel: string;
|
||||
emptyListLabel: string;
|
||||
emptyListIcon: JSX.Element;
|
||||
selectedList?: DrivelistDrive[];
|
||||
updateSelectedList?: () => DrivelistDrive[];
|
||||
onSelect?: (drive: DrivelistDrive) => void;
|
||||
}
|
||||
|
||||
interface DriveSelectorState {
|
||||
drives: Drive[];
|
||||
image?: SourceMetadata;
|
||||
missingDriversModal: { drive?: DriverlessDrive };
|
||||
selectedList: DrivelistDrive[];
|
||||
showSystemDrives: boolean;
|
||||
}
|
||||
|
||||
function isSystemDrive(drive: Drive) {
|
||||
return isDrivelistDrive(drive) && drive.isSystem;
|
||||
}
|
||||
|
||||
export class DriveSelector extends React.Component<
|
||||
DriveSelectorProps,
|
||||
DriveSelectorState
|
||||
> {
|
||||
private unsubscribe: (() => void) | undefined;
|
||||
tableColumns: Array<TableColumn<Drive>>;
|
||||
originalList: DrivelistDrive[];
|
||||
|
||||
constructor(props: DriveSelectorProps) {
|
||||
super(props);
|
||||
|
||||
const defaultMissingDriversModalState: { drive?: DriverlessDrive } = {};
|
||||
const selectedList = this.props.selectedList || [];
|
||||
this.originalList = [...(this.props.selectedList || [])];
|
||||
|
||||
this.state = {
|
||||
drives: getDrives(),
|
||||
image: getImage(),
|
||||
missingDriversModal: defaultMissingDriversModalState,
|
||||
selectedList,
|
||||
showSystemDrives: false,
|
||||
};
|
||||
|
||||
this.tableColumns = [
|
||||
{
|
||||
field: 'description',
|
||||
label: i18next.t('drives.name'),
|
||||
render: (description: string, drive: Drive) => {
|
||||
if (isDrivelistDrive(drive)) {
|
||||
const isLargeDrive = isDriveSizeLarge(drive);
|
||||
const hasWarnings =
|
||||
this.props.showWarnings && (isLargeDrive || drive.isSystem);
|
||||
return (
|
||||
<Flex alignItems="center">
|
||||
{hasWarnings && (
|
||||
<ExclamationTriangleSvg
|
||||
height="1em"
|
||||
fill={drive.isSystem ? '#fca321' : '#8f9297'}
|
||||
/>
|
||||
)}
|
||||
<Txt ml={(hasWarnings && 8) || 0}>
|
||||
{middleEllipsis(description, 32)}
|
||||
</Txt>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
return <Txt>{description}</Txt>;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
key: 'size',
|
||||
label: i18next.t('drives.size'),
|
||||
render: (_description: string, drive: Drive) => {
|
||||
if (isDrivelistDrive(drive) && drive.size !== null) {
|
||||
return prettyBytes(drive.size);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
key: 'link',
|
||||
label: i18next.t('drives.location'),
|
||||
render: (_description: string, drive: Drive) => {
|
||||
return (
|
||||
<Txt>
|
||||
{drive.displayName}
|
||||
{isDriverlessDrive(drive) && (
|
||||
<>
|
||||
{' '}
|
||||
-{' '}
|
||||
<b>
|
||||
<a onClick={() => this.installMissingDrivers(drive)}>
|
||||
{drive.linkCTA}
|
||||
</a>
|
||||
</b>
|
||||
</>
|
||||
)}
|
||||
</Txt>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
key: 'extra',
|
||||
// We use an empty React fragment otherwise it uses the field name as label
|
||||
label: <></>,
|
||||
render: (_description: string, drive: Drive) => {
|
||||
if (isUsbbootDrive(drive)) {
|
||||
return this.renderProgress(drive.progress);
|
||||
} else if (isDrivelistDrive(drive)) {
|
||||
return this.renderStatuses(drive);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private driveShouldBeDisabled(drive: Drive, image?: SourceMetadata) {
|
||||
return (
|
||||
isUsbbootDrive(drive) ||
|
||||
isDriverlessDrive(drive) ||
|
||||
!isDriveValid(drive, image, this.props.write) ||
|
||||
(this.props.write && drive.isReadOnly)
|
||||
);
|
||||
}
|
||||
|
||||
private getDisplayedDrives(drives: Drive[]): Drive[] {
|
||||
return drives.filter((drive) => {
|
||||
return (
|
||||
isUsbbootDrive(drive) ||
|
||||
isDriverlessDrive(drive) ||
|
||||
isDriveSelected(drive.device) ||
|
||||
this.state.showSystemDrives ||
|
||||
!drive.isSystem
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private getDisabledDrives(drives: Drive[], image?: SourceMetadata): string[] {
|
||||
return drives
|
||||
.filter((drive) => this.driveShouldBeDisabled(drive, image))
|
||||
.map((drive) => drive.displayName);
|
||||
}
|
||||
|
||||
private renderProgress(progress: number) {
|
||||
return (
|
||||
<Flex flexDirection="column">
|
||||
<Txt fontSize={12}>Initializing device</Txt>
|
||||
<InitProgress value={progress} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
private warningFromStatus(
|
||||
status: string,
|
||||
drive: { device: string; size: number },
|
||||
) {
|
||||
switch (status) {
|
||||
case compatibility.containsImage():
|
||||
return warning.sourceDrive();
|
||||
case compatibility.largeDrive():
|
||||
return warning.largeDriveSize();
|
||||
case compatibility.system():
|
||||
return warning.systemDrive();
|
||||
case compatibility.tooSmall():
|
||||
const size =
|
||||
this.state.image?.recommendedDriveSize || this.state.image?.size || 0;
|
||||
return warning.tooSmall({ size }, drive);
|
||||
}
|
||||
}
|
||||
|
||||
private renderStatuses(drive: DrivelistDrive) {
|
||||
const statuses: DriveStatus[] = getDriveImageCompatibilityStatuses(
|
||||
drive,
|
||||
this.state.image,
|
||||
this.props.write,
|
||||
).slice(0, 2);
|
||||
return (
|
||||
// the column render fn expects a single Element
|
||||
<>
|
||||
{statuses.map((status) => {
|
||||
const badgeShade = badgeShadeFromStatus(status.message);
|
||||
const warningMessage = this.warningFromStatus(status.message, {
|
||||
device: drive.device,
|
||||
size: drive.size || 0,
|
||||
});
|
||||
return (
|
||||
<Badge
|
||||
key={status.message}
|
||||
shade={badgeShade}
|
||||
mr="8px"
|
||||
tooltip={this.props.showWarnings ? warningMessage : ''}
|
||||
>
|
||||
{status.message}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
private installMissingDrivers(drive: DriverlessDrive) {
|
||||
if (drive.link) {
|
||||
logEvent('Open driver link modal', {
|
||||
url: drive.link,
|
||||
});
|
||||
this.setState({ missingDriversModal: { drive } });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubscribe = store.subscribe(() => {
|
||||
const drives = getDrives();
|
||||
const image = getImage();
|
||||
this.setState({
|
||||
drives,
|
||||
image,
|
||||
selectedList:
|
||||
(this.props.updateSelectedList && this.props.updateSelectedList()) ||
|
||||
[],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubscribe?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { cancel, done, ...props } = this.props;
|
||||
const { selectedList, drives, image, missingDriversModal } = this.state;
|
||||
|
||||
const displayedDrives = this.getDisplayedDrives(drives);
|
||||
const disabledDrives = this.getDisabledDrives(drives, image);
|
||||
const numberOfSystemDrives = drives.filter(isSystemDrive).length;
|
||||
const numberOfDisplayedSystemDrives =
|
||||
displayedDrives.filter(isSystemDrive).length;
|
||||
const numberOfHiddenSystemDrives =
|
||||
numberOfSystemDrives - numberOfDisplayedSystemDrives;
|
||||
const hasSystemDrives = selectedList.filter(isSystemDrive).length;
|
||||
const showWarnings = this.props.showWarnings && hasSystemDrives;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
titleElement={
|
||||
<Flex alignItems="baseline" mb={18}>
|
||||
<Txt fontSize={24} align="left">
|
||||
{this.props.titleLabel}
|
||||
</Txt>
|
||||
<Txt
|
||||
fontSize={11}
|
||||
ml={12}
|
||||
color="#5b82a7"
|
||||
style={{ fontWeight: 600 }}
|
||||
>
|
||||
{i18next.t('drives.find', { length: drives.length })}
|
||||
</Txt>
|
||||
</Flex>
|
||||
}
|
||||
titleDetails={<Txt fontSize={11}>{getDrives().length} found</Txt>}
|
||||
cancel={() => cancel(this.originalList)}
|
||||
done={() => done(selectedList)}
|
||||
action={i18next.t('drives.select', { select: selectedList.length })}
|
||||
primaryButtonProps={{
|
||||
primary: !showWarnings,
|
||||
warning: showWarnings,
|
||||
disabled: !hasAvailableDrives(),
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{!hasAvailableDrives() ? (
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
width="100%"
|
||||
>
|
||||
{this.props.emptyListIcon}
|
||||
<b>{this.props.emptyListLabel}</b>
|
||||
</Flex>
|
||||
) : (
|
||||
<>
|
||||
<DrivesTable
|
||||
refFn={(t) => {
|
||||
if (t !== null) {
|
||||
t.setRowSelection(selectedList);
|
||||
}
|
||||
}}
|
||||
checkedRowsNumber={selectedList.length}
|
||||
multipleSelection={this.props.multipleSelection}
|
||||
columns={this.tableColumns}
|
||||
data={displayedDrives}
|
||||
disabledRows={disabledDrives}
|
||||
getRowClass={(row: Drive) =>
|
||||
isDrivelistDrive(row) && row.isSystem ? ['system'] : []
|
||||
}
|
||||
rowKey="displayName"
|
||||
onCheck={(rows: Drive[]) => {
|
||||
let newSelection = rows.filter(isDrivelistDrive);
|
||||
if (this.props.multipleSelection) {
|
||||
if (rows.length === 0) {
|
||||
newSelection = [];
|
||||
}
|
||||
const deselecting = selectedList.filter(
|
||||
(selected) =>
|
||||
newSelection.filter(
|
||||
(row) => row.device === selected.device,
|
||||
).length === 0,
|
||||
);
|
||||
const selecting = newSelection.filter(
|
||||
(row) =>
|
||||
selectedList.filter(
|
||||
(selected) => row.device === selected.device,
|
||||
).length === 0,
|
||||
);
|
||||
deselecting.concat(selecting).forEach((row) => {
|
||||
if (this.props.onSelect) {
|
||||
this.props.onSelect(row);
|
||||
}
|
||||
});
|
||||
this.setState({
|
||||
selectedList: newSelection,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.props.onSelect) {
|
||||
this.props.onSelect(newSelection[newSelection.length - 1]);
|
||||
}
|
||||
this.setState({
|
||||
selectedList: newSelection.slice(newSelection.length - 1),
|
||||
});
|
||||
}}
|
||||
onRowClick={(row: Drive) => {
|
||||
if (
|
||||
!isDrivelistDrive(row) ||
|
||||
this.driveShouldBeDisabled(row, image)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (this.props.onSelect) {
|
||||
this.props.onSelect(row);
|
||||
}
|
||||
const index = selectedList.findIndex(
|
||||
(d) => d.device === row.device,
|
||||
);
|
||||
const newList = this.props.multipleSelection
|
||||
? [...selectedList]
|
||||
: [];
|
||||
if (index === -1) {
|
||||
newList.push(row);
|
||||
} else {
|
||||
// Deselect if selected
|
||||
newList.splice(index, 1);
|
||||
}
|
||||
this.setState({
|
||||
selectedList: newList,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{numberOfHiddenSystemDrives > 0 && (
|
||||
<Link
|
||||
mt={15}
|
||||
mb={15}
|
||||
fontSize="14px"
|
||||
onClick={() => this.setState({ showSystemDrives: true })}
|
||||
>
|
||||
<Flex alignItems="center">
|
||||
<ChevronDownSvg height="1em" fill="currentColor" />
|
||||
<Txt ml={8}>
|
||||
{i18next.t('drives.showHidden', {
|
||||
num: numberOfHiddenSystemDrives,
|
||||
})}
|
||||
</Txt>
|
||||
</Flex>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{this.props.showWarnings && hasSystemDrives ? (
|
||||
<Alert className="system-drive-alert" style={{ width: '67%' }}>
|
||||
{i18next.t('drives.systemDriveDanger')}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{missingDriversModal.drive !== undefined && (
|
||||
<Modal
|
||||
width={400}
|
||||
title={missingDriversModal.drive.linkTitle}
|
||||
cancel={() => this.setState({ missingDriversModal: {} })}
|
||||
done={() => {
|
||||
try {
|
||||
if (missingDriversModal.drive !== undefined) {
|
||||
openExternal(missingDriversModal.drive.link);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logException(error);
|
||||
} finally {
|
||||
this.setState({ missingDriversModal: {} });
|
||||
}
|
||||
}}
|
||||
action={i18next.t('yesContinue')}
|
||||
cancelButtonProps={{
|
||||
children: i18next.t('cancel'),
|
||||
}}
|
||||
children={
|
||||
missingDriversModal.drive.linkMessage ||
|
||||
i18next.t('drives.openInBrowser', {
|
||||
link: missingDriversModal.drive.link,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
.modal-drive-selector-modal .modal-content {
|
||||
width: 315px;
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
.modal-drive-selector-modal .modal-body {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.modal-drive-selector-modal .list-group-item[disabled] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-drive-selector-modal {
|
||||
|
||||
.list-group-item-footer:has(span) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.list-group-item-heading,
|
||||
.list-group-item-text {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.list-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.list-group-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
border-radius: 0;
|
||||
border-color: darken($palette-theme-light-background, 7%);
|
||||
padding: 12px 0;
|
||||
|
||||
.list-group-item-section-expanded {
|
||||
flex-grow: 1;
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
.list-group-item-section + .list-group-item-section {
|
||||
margin-left: 10px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
> .tick {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
&[disabled] .list-group-item-heading {
|
||||
color: $palette-theme-light-soft-foreground;
|
||||
}
|
||||
|
||||
.drive-init-progress {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 2.5px;
|
||||
border: none;
|
||||
border-radius: 50% 50%;
|
||||
}
|
||||
|
||||
.drive-init-progress::-webkit-progress-bar {
|
||||
background-color: $palette-theme-default-background;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.drive-init-progress::-webkit-progress-value {
|
||||
border-bottom: 1px solid darken($palette-theme-primary-background, 15);
|
||||
background-color: $palette-theme-primary-background;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.list-group-item-heading {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.list-group-item-text {
|
||||
line-height: 1;
|
||||
font-size: 11px;
|
||||
color: $palette-theme-light-soft-foreground;
|
||||
}
|
||||
|
||||
.word-keep {
|
||||
word-break: keep-all;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
/*
|
||||
* Copyright 2019 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-magic-numbers */
|
||||
|
||||
'use strict'
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const React = require('react')
|
||||
const propTypes = require('prop-types')
|
||||
const { default: styled } = require('styled-components')
|
||||
const {
|
||||
ChangeButton,
|
||||
DetailsText,
|
||||
StepButton,
|
||||
StepNameButton
|
||||
} = require('./../../styled-components')
|
||||
const { Txt } = require('rendition')
|
||||
const middleEllipsis = require('./../../utils/middle-ellipsis')
|
||||
const { bytesToClosestUnit } = require('./../../../../shared/units')
|
||||
|
||||
const TargetDetail = styled((props) => (
|
||||
<Txt.span {...props}>
|
||||
</Txt.span>
|
||||
)) `
|
||||
float: ${({ float }) => float}
|
||||
`
|
||||
|
||||
const TargetDisplayText = ({
|
||||
description,
|
||||
size,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Txt.span {...props}>
|
||||
<TargetDetail
|
||||
float='left'>
|
||||
{description}
|
||||
</TargetDetail>
|
||||
<TargetDetail
|
||||
float='right'
|
||||
>
|
||||
{size}
|
||||
</TargetDetail>
|
||||
</Txt.span>
|
||||
)
|
||||
}
|
||||
|
||||
const TargetSelector = (props) => {
|
||||
const targets = props.selection.getSelectedDrives()
|
||||
|
||||
if (targets.length === 1) {
|
||||
const target = targets[0]
|
||||
return (
|
||||
<React.Fragment>
|
||||
<StepNameButton
|
||||
plain
|
||||
tooltip={props.tooltip}
|
||||
>
|
||||
{/* eslint-disable no-magic-numbers */}
|
||||
{ middleEllipsis(target.description, 20) }
|
||||
</StepNameButton>
|
||||
{!props.flashing &&
|
||||
<ChangeButton
|
||||
plain
|
||||
mb={14}
|
||||
onClick={props.reselectDrive}
|
||||
>
|
||||
Change
|
||||
</ChangeButton>
|
||||
}
|
||||
<DetailsText>
|
||||
{ props.constraints.hasListDriveImageCompatibilityStatus(targets, props.image) &&
|
||||
<Txt.span className='glyphicon glyphicon-exclamation-sign'
|
||||
ml={2}
|
||||
tooltip={
|
||||
props.constraints.getListDriveImageCompatibilityStatuses(targets, props.image)[0].message
|
||||
}
|
||||
/>
|
||||
}
|
||||
{ bytesToClosestUnit(target.size) }
|
||||
</DetailsText>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
if (targets.length > 1) {
|
||||
const targetsTemplate = []
|
||||
for (const target of targets) {
|
||||
targetsTemplate.push((
|
||||
<DetailsText
|
||||
key={target.device}
|
||||
tooltip={
|
||||
`${target.description} ${target.displayName} ${bytesToClosestUnit(target.size)}`
|
||||
}
|
||||
px={21}
|
||||
>
|
||||
<TargetDisplayText
|
||||
description={middleEllipsis(target.description, 14)}
|
||||
size={bytesToClosestUnit(target.size)}
|
||||
>
|
||||
</TargetDisplayText>
|
||||
</DetailsText>
|
||||
))
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<StepNameButton
|
||||
plain
|
||||
tooltip={props.tooltip}
|
||||
>
|
||||
{targets.length} Targets
|
||||
</StepNameButton>
|
||||
{ !props.flashing &&
|
||||
<ChangeButton
|
||||
plain
|
||||
onClick={props.reselectDrive}
|
||||
mb={14}
|
||||
>
|
||||
Change
|
||||
</ChangeButton>
|
||||
}
|
||||
{targetsTemplate}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<StepButton
|
||||
tabindex={(targets.length > 0) ? -1 : 2 }
|
||||
disabled={props.disabled}
|
||||
onClick={props.openDriveSelector}
|
||||
>
|
||||
Select target
|
||||
</StepButton>
|
||||
)
|
||||
}
|
||||
|
||||
TargetSelector.propTypes = {
|
||||
targets: propTypes.array,
|
||||
disabled: propTypes.bool,
|
||||
openDriveSelector: propTypes.func,
|
||||
selection: propTypes.object,
|
||||
reselectDrive: propTypes.func,
|
||||
flashing: propTypes.bool,
|
||||
constraints: propTypes.object,
|
||||
show: propTypes.bool,
|
||||
tooltip: propTypes.string
|
||||
}
|
||||
|
||||
module.exports = TargetSelector
|
||||
@@ -0,0 +1,83 @@
|
||||
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg';
|
||||
import * as _ from 'lodash';
|
||||
import * as React from 'react';
|
||||
import { Badge, Flex, Txt, ModalProps } from 'rendition';
|
||||
import { Modal, ScrollableFlex } from '../../styled-components';
|
||||
import { middleEllipsis } from '../../utils/middle-ellipsis';
|
||||
|
||||
import * as prettyBytes from 'pretty-bytes';
|
||||
import { DriveWithWarnings } from '../../pages/main/Flash';
|
||||
import * as i18next from 'i18next';
|
||||
|
||||
const DriveStatusWarningModal = ({
|
||||
done,
|
||||
cancel,
|
||||
isSystem,
|
||||
drivesWithWarnings,
|
||||
}: ModalProps & {
|
||||
isSystem: boolean;
|
||||
drivesWithWarnings: DriveWithWarnings[];
|
||||
}) => {
|
||||
let warningSubtitle = i18next.t('drives.largeDriveWarning');
|
||||
let warningCta = i18next.t('drives.largeDriveWarningMsg');
|
||||
|
||||
if (isSystem) {
|
||||
warningSubtitle = i18next.t('drives.systemDriveWarning');
|
||||
warningCta = i18next.t('drives.systemDriveWarningMsg');
|
||||
}
|
||||
return (
|
||||
<Modal
|
||||
footerShadow={false}
|
||||
reverseFooterButtons={true}
|
||||
done={done}
|
||||
cancel={cancel}
|
||||
cancelButtonProps={{
|
||||
primary: false,
|
||||
warning: true,
|
||||
children: i18next.t('drives.changeTarget'),
|
||||
}}
|
||||
action={i18next.t('sure')}
|
||||
primaryButtonProps={{
|
||||
primary: false,
|
||||
outline: true,
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
width="100%"
|
||||
>
|
||||
<Flex flexDirection="column">
|
||||
<ExclamationTriangleSvg height="2em" fill="#fca321" />
|
||||
<Txt fontSize="24px" color="#fca321">
|
||||
{i18next.t('warning')}
|
||||
</Txt>
|
||||
</Flex>
|
||||
<Txt fontSize="24px">{warningSubtitle}</Txt>
|
||||
<ScrollableFlex
|
||||
flexDirection="column"
|
||||
backgroundColor="#fff5e6"
|
||||
m="2em 0"
|
||||
p="1em 2em"
|
||||
width="420px"
|
||||
maxHeight="100px"
|
||||
>
|
||||
{drivesWithWarnings.map((drive, i, array) => (
|
||||
<>
|
||||
<Flex justifyContent="space-between" alignItems="baseline">
|
||||
<strong>{middleEllipsis(drive.description, 28)}</strong>{' '}
|
||||
{drive.size && prettyBytes(drive.size) + ' '}
|
||||
<Badge shade={5}>{drive.statuses[0].message}</Badge>
|
||||
</Flex>
|
||||
{i !== array.length - 1 ? <hr style={{ width: '100%' }} /> : null}
|
||||
</>
|
||||
))}
|
||||
</ScrollableFlex>
|
||||
<Txt style={{ fontWeight: 600 }}>{warningCta}</Txt>
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DriveStatusWarningModal;
|
||||
@@ -1,57 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const React = require('react')
|
||||
const propTypes = require('prop-types')
|
||||
const SafeWebview = require('../safe-webview/safe-webview.jsx')
|
||||
const settings = require('../../models/settings')
|
||||
const analytics = require('../../modules/analytics')
|
||||
|
||||
class FeaturedProject extends React.Component {
|
||||
constructor (props) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
endpoint: null
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
return settings.load()
|
||||
.then(() => {
|
||||
const endpoint = settings.get('featuredProjectEndpoint') || 'https://assets.balena.io/etcher-featured/index.html'
|
||||
this.setState({ endpoint })
|
||||
})
|
||||
.catch(analytics.logException)
|
||||
}
|
||||
|
||||
render () {
|
||||
return (this.state.endpoint) ? (
|
||||
<SafeWebview
|
||||
src={this.state.endpoint}
|
||||
{...this.props}>
|
||||
</SafeWebview>
|
||||
) : null
|
||||
}
|
||||
}
|
||||
|
||||
FeaturedProject.propTypes = {
|
||||
onWebviewShow: propTypes.func
|
||||
}
|
||||
|
||||
module.exports = FeaturedProject
|
||||
@@ -14,122 +14,110 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as _ from 'lodash';
|
||||
import * as React from 'react';
|
||||
import * as uuidV4 from 'uuid/v4';
|
||||
import { Flex } from 'rendition';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
|
||||
import * as messages from '../../../../shared/messages';
|
||||
import * as flashState from '../../models/flash-state';
|
||||
import * as selectionState from '../../models/selection-state';
|
||||
import * as store from '../../models/store';
|
||||
import * as settings from '../../models/settings';
|
||||
import { Actions, store } from '../../models/store';
|
||||
import * as analytics from '../../modules/analytics';
|
||||
import * as updateLock from '../../modules/update-lock';
|
||||
import { open as openExternal } from '../../os/open-external/services/open-external';
|
||||
import { FlashAnother } from '../flash-another/flash-another';
|
||||
import { FlashResults } from '../flash-results/flash-results';
|
||||
import * as SVGIcon from '../svg-icon/svg-icon';
|
||||
import { FlashResults, FlashError } from '../flash-results/flash-results';
|
||||
import { SafeWebview } from '../safe-webview/safe-webview';
|
||||
|
||||
const restart = (options: any, $state: any) => {
|
||||
const {
|
||||
applicationSessionUuid,
|
||||
flashingWorkflowUuid,
|
||||
// @ts-ignore
|
||||
} = store.getState().toJS();
|
||||
if (!options.preserveImage) {
|
||||
selectionState.deselectImage();
|
||||
}
|
||||
function restart(goToMain: () => void) {
|
||||
selectionState.deselectAllDrives();
|
||||
analytics.logEvent('Restart', {
|
||||
...options,
|
||||
applicationSessionUuid,
|
||||
flashingWorkflowUuid,
|
||||
});
|
||||
|
||||
// Re-enable lock release on inactivity
|
||||
updateLock.resume();
|
||||
analytics.logEvent('Restart');
|
||||
|
||||
// Reset the flashing workflow uuid
|
||||
store.dispatch({
|
||||
type: 'SET_FLASHING_WORKFLOW_UUID',
|
||||
type: Actions.SET_FLASHING_WORKFLOW_UUID,
|
||||
data: uuidV4(),
|
||||
});
|
||||
|
||||
$state.go('main');
|
||||
};
|
||||
goToMain();
|
||||
}
|
||||
|
||||
const formattedErrors = () => {
|
||||
const errors = _.map(
|
||||
_.get(flashState.getFlashResults(), ['results', 'errors']),
|
||||
error => {
|
||||
return `${error.device}: ${error.message || error.code}`;
|
||||
},
|
||||
);
|
||||
return errors.join('\n');
|
||||
};
|
||||
|
||||
function FinishPage({ $state }: any) {
|
||||
// @ts-ignore
|
||||
const results = flashState.getFlashResults().results || {};
|
||||
const progressMessage = messages.progress;
|
||||
async function getSuccessBannerURL() {
|
||||
return (
|
||||
<div className="page-finish row around-xs">
|
||||
<div className="col-xs">
|
||||
<div className="box center">
|
||||
<FlashResults
|
||||
results={results}
|
||||
message={progressMessage}
|
||||
errors={formattedErrors}
|
||||
></FlashResults>
|
||||
(await settings.get('successBannerURL')) ??
|
||||
'https://www.balena.io/etcher/success-banner?borderTop=false&darkBackground=true'
|
||||
);
|
||||
}
|
||||
|
||||
<FlashAnother
|
||||
onClick={(options: any) => restart(options, $state)}
|
||||
></FlashAnother>
|
||||
</div>
|
||||
function FinishPage({ goToMain }: { goToMain: () => void }) {
|
||||
const [webviewShowing, setWebviewShowing] = React.useState(false);
|
||||
const [successBannerURL, setSuccessBannerURL] = React.useState('');
|
||||
(async () => {
|
||||
setSuccessBannerURL(await getSuccessBannerURL());
|
||||
})();
|
||||
const flashResults = flashState.getFlashResults();
|
||||
const errors: FlashError[] = (
|
||||
store.getState().toJS().failedDeviceErrors || []
|
||||
).map(([, error]: [string, FlashError]) => ({
|
||||
...error,
|
||||
}));
|
||||
const { averageSpeed, blockmappedSize, bytesWritten, failed, size } =
|
||||
flashState.getFlashState();
|
||||
const {
|
||||
skip,
|
||||
results = {
|
||||
bytesWritten,
|
||||
sourceMetadata: {
|
||||
size,
|
||||
blockmappedSize,
|
||||
},
|
||||
averageFlashingSpeed: averageSpeed,
|
||||
devices: { failed, successful: 0 },
|
||||
},
|
||||
} = flashResults;
|
||||
return (
|
||||
<Flex height="100%" justifyContent="space-between">
|
||||
<Flex
|
||||
width={webviewShowing ? '36.2vw' : '100vw'}
|
||||
height="100vh"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
flexDirection="column"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
boxShadow: '0 2px 15px 0 rgba(0, 0, 0, 0.2)',
|
||||
}}
|
||||
>
|
||||
<FlashResults
|
||||
image={selectionState.getImage()?.name}
|
||||
results={results}
|
||||
skip={skip}
|
||||
errors={errors}
|
||||
mb="32px"
|
||||
goToMain={goToMain}
|
||||
/>
|
||||
|
||||
<div className="box center">
|
||||
<div className="fallback-banner">
|
||||
<div className="caption caption-big">
|
||||
Thanks for using
|
||||
<span
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() =>
|
||||
openExternal(
|
||||
'https://balena.io/etcher?ref=etcher_offline_banner',
|
||||
)
|
||||
}
|
||||
>
|
||||
<SVGIcon
|
||||
paths={['../../assets/etcher.svg']}
|
||||
width="165px"
|
||||
height="auto"
|
||||
></SVGIcon>
|
||||
</span>
|
||||
</div>
|
||||
<div className="caption caption-small fallback-footer">
|
||||
made with
|
||||
<SVGIcon
|
||||
paths={['../../assets/love.svg']}
|
||||
width="auto"
|
||||
height="20px"
|
||||
></SVGIcon>
|
||||
by
|
||||
<span
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() =>
|
||||
openExternal('https://balena.io?ref=etcher_success')
|
||||
}
|
||||
>
|
||||
<SVGIcon
|
||||
paths={['../../assets/balena.svg']}
|
||||
width="auto"
|
||||
height="20px"
|
||||
></SVGIcon>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FlashAnother
|
||||
onClick={() => {
|
||||
restart(goToMain);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
{successBannerURL.length && (
|
||||
<SafeWebview
|
||||
src={successBannerURL}
|
||||
onWebviewShow={setWebviewShowing}
|
||||
style={{
|
||||
display: webviewShowing ? 'flex' : 'none',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: '63.8vw',
|
||||
height: '100vh',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* Copyright 2019 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @module Etcher.Pages.Finish
|
||||
*/
|
||||
|
||||
import * as angular from 'angular';
|
||||
import { react2angular } from 'react2angular';
|
||||
import FinishPage from './finish';
|
||||
|
||||
export const MODULE_NAME = 'Etcher.Pages.Finish';
|
||||
const Finish = angular.module(MODULE_NAME, []);
|
||||
|
||||
Finish.component('finish', react2angular(FinishPage, [], ['$state']));
|
||||
|
||||
Finish.config(($stateProvider: any) => {
|
||||
$stateProvider.state('success', {
|
||||
url: '/success',
|
||||
template: '<finish style="width:100%"></finish>',
|
||||
});
|
||||
});
|
||||
@@ -15,30 +15,18 @@
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { position, right } from 'styled-system';
|
||||
import { BaseButton, ThemedProvider } from '../../styled-components';
|
||||
|
||||
const Div = styled.div<any>`
|
||||
${position}
|
||||
${right}
|
||||
`;
|
||||
import { BaseButton } from '../../styled-components';
|
||||
import * as i18next from 'i18next';
|
||||
|
||||
export interface FlashAnotherProps {
|
||||
onClick: (options: { preserveImage: boolean }) => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const FlashAnother = (props: FlashAnotherProps) => {
|
||||
return (
|
||||
<ThemedProvider>
|
||||
<Div position="absolute" right="152px">
|
||||
<BaseButton
|
||||
primary
|
||||
onClick={props.onClick.bind(null, { preserveImage: true })}
|
||||
>
|
||||
Flash Another
|
||||
</BaseButton>
|
||||
</Div>
|
||||
</ThemedProvider>
|
||||
<BaseButton primary onClick={props.onClick}>
|
||||
{i18next.t('flash.another')}
|
||||
</BaseButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* Copyright 2019 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as angular from 'angular';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { FlashAnother } from './flash-another';
|
||||
|
||||
export const MODULE_NAME = 'Etcher.Components.FlashAnother';
|
||||
const FlashAnotherModule = angular.module(MODULE_NAME, []);
|
||||
|
||||
FlashAnotherModule.component('flashAnother', react2angular(FlashAnother));
|
||||
@@ -14,52 +14,230 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as _ from 'lodash';
|
||||
import CircleSvg from '@fortawesome/fontawesome-free/svgs/solid/circle.svg';
|
||||
import CheckCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/check-circle.svg';
|
||||
import TimesCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/times-circle.svg';
|
||||
import outdent from 'outdent';
|
||||
import * as React from 'react';
|
||||
import { Flex, FlexProps, Link, TableColumn, Txt } from 'rendition';
|
||||
import styled from 'styled-components';
|
||||
import { left, position, space, top } from 'styled-system';
|
||||
import { Underline } from '../../styled-components';
|
||||
|
||||
const Div: any = styled.div<any>`
|
||||
${position}
|
||||
${top}
|
||||
${left}
|
||||
${space}
|
||||
import { progress } from '../../../../shared/messages';
|
||||
import { bytesToMegabytes } from '../../../../shared/units';
|
||||
|
||||
import FlashSvg from '../../../assets/flash.svg';
|
||||
import { getDrives } from '../../models/available-drives';
|
||||
import { resetState } from '../../models/flash-state';
|
||||
import * as selection from '../../models/selection-state';
|
||||
import { middleEllipsis } from '../../utils/middle-ellipsis';
|
||||
import { Modal, Table } from '../../styled-components';
|
||||
import * as i18next from 'i18next';
|
||||
|
||||
const ErrorsTable = styled((props) => <Table<FlashError> {...props} />)`
|
||||
&&& [data-display='table-head'],
|
||||
&&& [data-display='table-body'] {
|
||||
> [data-display='table-row'] {
|
||||
> [data-display='table-cell'] {
|
||||
&:first-child {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const FlashResults: any = ({
|
||||
errors,
|
||||
results,
|
||||
message,
|
||||
}: {
|
||||
errors: () => string;
|
||||
results: any;
|
||||
message: any;
|
||||
const DoneIcon = (props: {
|
||||
skipped: boolean;
|
||||
color: string;
|
||||
allFailed: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Div position="absolute" left="153px" top="66px">
|
||||
<div className="inline-flex title">
|
||||
<span className="tick tick--success space-right-medium"></span>
|
||||
<h3>Flash Complete!</h3>
|
||||
</div>
|
||||
<Div className="results" mt="11px" mr="0" mb="0" ml="40px">
|
||||
<Underline tooltip={errors()}>
|
||||
{_.map(results.devices, (quantity, type) => {
|
||||
return quantity ? (
|
||||
<div
|
||||
key={type}
|
||||
className={`target-status-line target-status-${type}`}
|
||||
>
|
||||
<span className="target-status-dot"></span>
|
||||
<span className="target-status-quantity">{quantity}</span>
|
||||
<span className="target-status-message">
|
||||
{message[type](quantity)}
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</Underline>
|
||||
</Div>
|
||||
</Div>
|
||||
const svgProps = {
|
||||
width: '28px',
|
||||
fill: props.color,
|
||||
style: {
|
||||
marginTop: '-25px',
|
||||
marginLeft: '13px',
|
||||
zIndex: 1,
|
||||
},
|
||||
};
|
||||
return props.allFailed && !props.skipped ? (
|
||||
<TimesCircleSvg {...svgProps} />
|
||||
) : (
|
||||
<CheckCircleSvg {...svgProps} />
|
||||
);
|
||||
};
|
||||
|
||||
export interface FlashError extends Error {
|
||||
description: string;
|
||||
device: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
function formattedErrors(errors: FlashError[]) {
|
||||
return errors
|
||||
.map((error) => `${error.device}: ${error.message || error.code}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<FlashError>> = [
|
||||
{
|
||||
field: 'description',
|
||||
label: i18next.t('flash.target'),
|
||||
},
|
||||
{
|
||||
field: 'device',
|
||||
label: i18next.t('flash.location'),
|
||||
},
|
||||
{
|
||||
field: 'message',
|
||||
label: i18next.t('flash.error'),
|
||||
render: (message: string, { code }: FlashError) => {
|
||||
return message ?? code;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function getEffectiveSpeed(results: {
|
||||
sourceMetadata: {
|
||||
size: number;
|
||||
blockmappedSize?: number;
|
||||
};
|
||||
averageFlashingSpeed: number;
|
||||
}) {
|
||||
const flashedSize =
|
||||
results.sourceMetadata.blockmappedSize ?? results.sourceMetadata.size;
|
||||
const timeSpent = flashedSize / results.averageFlashingSpeed;
|
||||
return results.sourceMetadata.size / timeSpent;
|
||||
}
|
||||
|
||||
export function FlashResults({
|
||||
goToMain,
|
||||
image = '',
|
||||
errors,
|
||||
results,
|
||||
skip,
|
||||
...props
|
||||
}: {
|
||||
goToMain: () => void;
|
||||
image?: string;
|
||||
errors: FlashError[];
|
||||
skip: boolean;
|
||||
results: {
|
||||
sourceMetadata: {
|
||||
size: number;
|
||||
blockmappedSize?: number;
|
||||
};
|
||||
averageFlashingSpeed: number;
|
||||
devices: { failed: number; successful: number };
|
||||
};
|
||||
} & FlexProps) {
|
||||
const [showErrorsInfo, setShowErrorsInfo] = React.useState(false);
|
||||
const allFailed = !skip && results.devices.successful === 0;
|
||||
const someFailed = results.devices.failed !== 0 || errors.length !== 0;
|
||||
const effectiveSpeed = bytesToMegabytes(getEffectiveSpeed(results)).toFixed(
|
||||
1,
|
||||
);
|
||||
return (
|
||||
<Flex flexDirection="column" {...props}>
|
||||
<Flex alignItems="center" flexDirection="column">
|
||||
<Flex
|
||||
alignItems="center"
|
||||
mt="50px"
|
||||
mb="32px"
|
||||
color="#7e8085"
|
||||
flexDirection="column"
|
||||
>
|
||||
<FlashSvg width="40px" height="40px" className="disabled" />
|
||||
<DoneIcon
|
||||
skipped={skip}
|
||||
allFailed={allFailed}
|
||||
color={allFailed || someFailed ? '#c6c8c9' : '#1ac135'}
|
||||
/>
|
||||
<Txt>{middleEllipsis(image, 24)}</Txt>
|
||||
</Flex>
|
||||
<Txt fontSize={24} color="#fff" mb="17px">
|
||||
{allFailed
|
||||
? i18next.t('flash.flashFailed')
|
||||
: i18next.t('flash.flashCompleted')}
|
||||
</Txt>
|
||||
{skip ? <Txt color="#7e8085">{i18next.t('flash.skip')}</Txt> : null}
|
||||
</Flex>
|
||||
<Flex flexDirection="column" color="#7e8085">
|
||||
{results.devices.successful !== 0 ? (
|
||||
<Flex alignItems="center">
|
||||
<CircleSvg width="14px" fill="#1ac135" />
|
||||
<Txt ml="10px" color="#fff">
|
||||
{results.devices.successful}
|
||||
</Txt>
|
||||
<Txt ml="10px">
|
||||
{progress.successful(results.devices.successful)}
|
||||
</Txt>
|
||||
</Flex>
|
||||
) : null}
|
||||
{errors.length !== 0 ? (
|
||||
<Flex alignItems="center">
|
||||
<CircleSvg width="14px" fill="#ff4444" />
|
||||
<Txt ml="10px" color="#fff">
|
||||
{errors.length}
|
||||
</Txt>
|
||||
<Txt ml="10px" tooltip={formattedErrors(errors)}>
|
||||
{progress.failed(errors.length)}
|
||||
</Txt>
|
||||
<Link ml="10px" onClick={() => setShowErrorsInfo(true)}>
|
||||
{i18next.t('flash.moreInfo')}
|
||||
</Link>
|
||||
</Flex>
|
||||
) : null}
|
||||
{!allFailed && (
|
||||
<Txt
|
||||
fontSize="10px"
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
tooltip={i18next.t('flash.speedTip')}
|
||||
>
|
||||
{i18next.t('flash.speed', { speed: effectiveSpeed })}
|
||||
</Txt>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{showErrorsInfo && (
|
||||
<Modal
|
||||
titleElement={
|
||||
<Flex alignItems="baseline" mb={18}>
|
||||
<Txt fontSize={24} align="left">
|
||||
{i18next.t('failedTarget')}
|
||||
</Txt>
|
||||
</Flex>
|
||||
}
|
||||
action={i18next.t('failedRetry')}
|
||||
cancel={() => setShowErrorsInfo(false)}
|
||||
done={() => {
|
||||
setShowErrorsInfo(false);
|
||||
resetState();
|
||||
getDrives()
|
||||
.map((drive) => {
|
||||
selection.deselectDrive(drive.device);
|
||||
return drive.device;
|
||||
})
|
||||
.filter((driveDevice) =>
|
||||
errors.some((error) => error.device === driveDevice),
|
||||
)
|
||||
.forEach((driveDevice) => selection.selectDrive(driveDevice));
|
||||
goToMain();
|
||||
}}
|
||||
>
|
||||
<ErrorsTable columns={columns} data={errors} />
|
||||
</Modal>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Copyright 2019 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @module Etcher.Components.FlashResults
|
||||
*/
|
||||
|
||||
import * as angular from 'angular';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { FlashResults } from './flash-results';
|
||||
|
||||
export const MODULE_NAME = 'Etcher.Components.FlashResults';
|
||||
const FlashResultsModule = angular.module(MODULE_NAME, []);
|
||||
|
||||
FlashResultsModule.component('flashResults', react2angular(FlashResults));
|
||||
@@ -1,404 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const Bluebird = require('bluebird')
|
||||
const sdk = require('etcher-sdk')
|
||||
const _ = require('lodash')
|
||||
const path = require('path')
|
||||
const propTypes = require('prop-types')
|
||||
const React = require('react')
|
||||
const Dropzone = require('react-dropzone').default
|
||||
const errors = require('../../../../shared/errors')
|
||||
const messages = require('../../../../shared/messages')
|
||||
const supportedFormats = require('../../../../shared/supported-formats')
|
||||
const shared = require('../../../../shared/units')
|
||||
const selectionState = require('../../models/selection-state')
|
||||
const store = require('../../models/store')
|
||||
const analytics = require('../../modules/analytics')
|
||||
const exceptionReporter = require('../../modules/exception-reporter')
|
||||
const osDialog = require('../../os/dialog')
|
||||
const { replaceWindowsNetworkDriveLetter } = require('../../os/windows-network-drives')
|
||||
const {
|
||||
StepButton,
|
||||
StepNameButton,
|
||||
StepSelection,
|
||||
Footer,
|
||||
Underline,
|
||||
DetailsText,
|
||||
ChangeButton
|
||||
} = require('../../styled-components')
|
||||
const {
|
||||
Modal
|
||||
} = require('rendition')
|
||||
const middleEllipsis = require('../../utils/middle-ellipsis')
|
||||
const SVGIcon = require('../svg-icon/svg-icon.jsx')
|
||||
const { default: styled } = require('styled-components')
|
||||
|
||||
// TODO move these styles to rendition
|
||||
const ModalText = styled.p `
|
||||
a {
|
||||
color: rgb(0, 174, 239);
|
||||
|
||||
&:hover {
|
||||
color: rgb(0, 139, 191);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
/**
|
||||
* @summary Main supported extensions
|
||||
* @constant
|
||||
* @type {String[]}
|
||||
* @public
|
||||
*/
|
||||
const mainSupportedExtensions = _.intersection([
|
||||
'img',
|
||||
'iso',
|
||||
'zip'
|
||||
], supportedFormats.getAllExtensions())
|
||||
|
||||
/**
|
||||
* @summary Extra supported extensions
|
||||
* @constant
|
||||
* @type {String[]}
|
||||
* @public
|
||||
*/
|
||||
const extraSupportedExtensions = _.difference(
|
||||
supportedFormats.getAllExtensions(),
|
||||
mainSupportedExtensions
|
||||
).sort()
|
||||
|
||||
const getState = () => {
|
||||
return {
|
||||
hasImage: selectionState.hasImage(),
|
||||
imageName: selectionState.getImageName(),
|
||||
imageSize: selectionState.getImageSize()
|
||||
}
|
||||
}
|
||||
|
||||
class ImageSelector extends React.Component {
|
||||
constructor (props) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
...getState(),
|
||||
warning: null,
|
||||
showImageDetails: false
|
||||
}
|
||||
|
||||
this.openImageSelector = this.openImageSelector.bind(this)
|
||||
this.reselectImage = this.reselectImage.bind(this)
|
||||
this.handleOnDrop = this.handleOnDrop.bind(this)
|
||||
this.showSelectedImageDetails = this.showSelectedImageDetails.bind(this)
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this.unsubscribe = store.observe(() => {
|
||||
this.setState(getState())
|
||||
})
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.unsubscribe()
|
||||
}
|
||||
|
||||
reselectImage () {
|
||||
analytics.logEvent('Reselect image', {
|
||||
previousImage: selectionState.getImage(),
|
||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
|
||||
})
|
||||
|
||||
this.openImageSelector()
|
||||
}
|
||||
|
||||
selectImage (image) {
|
||||
if (!supportedFormats.isSupportedImage(image.path)) {
|
||||
const invalidImageError = errors.createUserError({
|
||||
title: 'Invalid image',
|
||||
description: messages.error.invalidImage(image)
|
||||
})
|
||||
|
||||
osDialog.showError(invalidImageError)
|
||||
analytics.logEvent('Invalid image', _.merge({
|
||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
|
||||
}, image))
|
||||
return
|
||||
}
|
||||
|
||||
Bluebird.try(() => {
|
||||
let message = null
|
||||
let title = null
|
||||
|
||||
if (supportedFormats.looksLikeWindowsImage(image.path)) {
|
||||
analytics.logEvent('Possibly Windows image', {
|
||||
image,
|
||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
|
||||
})
|
||||
message = messages.warning.looksLikeWindowsImage()
|
||||
title = 'Possible Windows image detected'
|
||||
} else if (!image.hasMBR) {
|
||||
analytics.logEvent('Missing partition table', {
|
||||
image,
|
||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
|
||||
})
|
||||
title = 'Missing partition table'
|
||||
message = messages.warning.missingPartitionTable()
|
||||
}
|
||||
|
||||
if (message) {
|
||||
this.setState({
|
||||
warning: {
|
||||
message,
|
||||
title
|
||||
}
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return false
|
||||
}).then(() => {
|
||||
selectionState.selectImage(image)
|
||||
|
||||
// An easy way so we can quickly identify if we're making use of
|
||||
// certain features without printing pages of text to DevTools.
|
||||
image.logo = Boolean(image.logo)
|
||||
image.blockMap = Boolean(image.blockMap)
|
||||
|
||||
return analytics.logEvent('Select image', {
|
||||
image,
|
||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
|
||||
})
|
||||
}).catch(exceptionReporter.report)
|
||||
}
|
||||
|
||||
async selectImageByPath (imagePath) {
|
||||
try {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
imagePath = await replaceWindowsNetworkDriveLetter(imagePath)
|
||||
} catch (error) {
|
||||
analytics.logException(error)
|
||||
}
|
||||
if (!supportedFormats.isSupportedImage(imagePath)) {
|
||||
const invalidImageError = errors.createUserError({
|
||||
title: 'Invalid image',
|
||||
description: messages.error.invalidImage(imagePath)
|
||||
})
|
||||
|
||||
osDialog.showError(invalidImageError)
|
||||
analytics.logEvent('Invalid image', { path: imagePath })
|
||||
return
|
||||
}
|
||||
|
||||
const source = new sdk.sourceDestination.File(imagePath, sdk.sourceDestination.File.OpenFlags.Read)
|
||||
try {
|
||||
const innerSource = await source.getInnerSource()
|
||||
const metadata = await innerSource.getMetadata()
|
||||
const partitionTable = await innerSource.getPartitionTable()
|
||||
if (partitionTable) {
|
||||
metadata.hasMBR = true
|
||||
metadata.partitions = partitionTable.partitions
|
||||
}
|
||||
metadata.path = imagePath
|
||||
// eslint-disable-next-line no-magic-numbers
|
||||
metadata.extension = path.extname(imagePath).slice(1)
|
||||
this.selectImage(metadata)
|
||||
} catch (error) {
|
||||
const imageError = errors.createUserError({
|
||||
title: 'Error opening image',
|
||||
description: messages.error.openImage(path.basename(imagePath), error.message)
|
||||
})
|
||||
osDialog.showError(imageError)
|
||||
analytics.logException(error)
|
||||
} finally {
|
||||
try {
|
||||
await source.close()
|
||||
} catch (error) {
|
||||
// Noop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Open image selector
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @example
|
||||
* ImageSelectionController.openImageSelector();
|
||||
*/
|
||||
openImageSelector () {
|
||||
analytics.logEvent('Open image selector', {
|
||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
|
||||
})
|
||||
|
||||
osDialog.selectImage().then((imagePath) => {
|
||||
// Avoid analytics and selection state changes
|
||||
// if no file was resolved from the dialog.
|
||||
if (!imagePath) {
|
||||
analytics.logEvent('Image selector closed', {
|
||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.selectImageByPath(imagePath)
|
||||
}).catch(exceptionReporter.report)
|
||||
}
|
||||
|
||||
handleOnDrop (acceptedFiles) {
|
||||
const [ file ] = acceptedFiles
|
||||
|
||||
if (file) {
|
||||
this.selectImageByPath(file.path)
|
||||
}
|
||||
}
|
||||
|
||||
showSelectedImageDetails () {
|
||||
analytics.logEvent('Show selected image tooltip', {
|
||||
imagePath: selectionState.getImagePath(),
|
||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
|
||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid
|
||||
})
|
||||
|
||||
this.setState({
|
||||
showImageDetails: true
|
||||
})
|
||||
}
|
||||
|
||||
// TODO add a visual change when dragging a file over the selector
|
||||
render () {
|
||||
const {
|
||||
flashing
|
||||
} = this.props
|
||||
const {
|
||||
showImageDetails
|
||||
} = this.state
|
||||
|
||||
const hasImage = selectionState.hasImage()
|
||||
|
||||
const imageBasename = hasImage ? path.basename(selectionState.getImagePath()) : ''
|
||||
const imageName = selectionState.getImageName()
|
||||
const imageSize = selectionState.getImageSize()
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="box text-center relative">
|
||||
<Dropzone multiple={false} onDrop={this.handleOnDrop}>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
<div className="center-block" {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
<SVGIcon contents={selectionState.getImageLogo()} paths={[ '../../assets/image.svg' ]} />
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
|
||||
<div className="space-vertical-large">
|
||||
{hasImage ? (
|
||||
<React.Fragment>
|
||||
<StepNameButton
|
||||
plain
|
||||
onClick={this.showSelectedImageDetails}
|
||||
tooltip={imageBasename}
|
||||
>
|
||||
{/* eslint-disable no-magic-numbers */}
|
||||
{ middleEllipsis(imageName || imageBasename, 20) }
|
||||
</StepNameButton>
|
||||
{ !flashing &&
|
||||
<ChangeButton
|
||||
plain
|
||||
mb={14}
|
||||
onClick={this.reselectImage}
|
||||
>
|
||||
Change
|
||||
</ChangeButton>
|
||||
}
|
||||
<DetailsText>
|
||||
{shared.bytesToClosestUnit(imageSize)}
|
||||
</DetailsText>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<StepSelection>
|
||||
<StepButton
|
||||
onClick={this.openImageSelector}
|
||||
>
|
||||
Select image
|
||||
</StepButton>
|
||||
<Footer>
|
||||
{ mainSupportedExtensions.join(', ') }, and{' '}
|
||||
<Underline
|
||||
tooltip={ extraSupportedExtensions.join(', ') }
|
||||
>
|
||||
many more
|
||||
</Underline>
|
||||
</Footer>
|
||||
</StepSelection>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Boolean(this.state.warning) && (
|
||||
<Modal
|
||||
title={(
|
||||
<span>
|
||||
<span style={{ color: '#d9534f' }} className="glyphicon glyphicon-exclamation-sign"></span>
|
||||
{' '}
|
||||
<span>{this.state.warning.title}</span>
|
||||
</span>
|
||||
)}
|
||||
action='Continue'
|
||||
cancel={() => {
|
||||
this.setState({ warning: null })
|
||||
this.reselectImage()
|
||||
}}
|
||||
done={() => {
|
||||
this.setState({ warning: null })
|
||||
}}
|
||||
primaryButtonProps={{ warning: true, primary: false }}
|
||||
>
|
||||
<ModalText dangerouslySetInnerHTML={{ __html: this.state.warning.message }} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showImageDetails && (
|
||||
<Modal
|
||||
title="Image File Name"
|
||||
done={() => {
|
||||
this.setState({ showImageDetails: false })
|
||||
}}
|
||||
>
|
||||
{selectionState.getImagePath()}
|
||||
</Modal>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ImageSelector.propTypes = {
|
||||
flashing: propTypes.bool
|
||||
}
|
||||
|
||||
module.exports = ImageSelector
|
||||
@@ -1,100 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const store = require('../../../models/store')
|
||||
const analytics = require('../../../modules/analytics')
|
||||
|
||||
module.exports = function ($uibModal, $q) {
|
||||
/**
|
||||
* @summary Open a modal
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {Object} options - options
|
||||
* @param {String} options.template - template contents
|
||||
* @param {String} options.controller - controller
|
||||
* @param {String} [options.size='sm'] - modal size
|
||||
* @param {Object} options.resolve - modal resolves
|
||||
* @returns {Object} modal
|
||||
*
|
||||
* @example
|
||||
* ModalService.open({
|
||||
* name: 'my modal',
|
||||
* template: require('./path/to/modal.tpl.html'),
|
||||
* controller: 'DriveSelectorController as modal',
|
||||
* });
|
||||
*/
|
||||
this.open = (options = {}) => {
|
||||
_.defaults(options, {
|
||||
size: 'sm'
|
||||
})
|
||||
|
||||
analytics.logEvent('Open modal', {
|
||||
name: options.name,
|
||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
|
||||
})
|
||||
|
||||
const modal = $uibModal.open({
|
||||
animation: true,
|
||||
template: options.template,
|
||||
controller: options.controller,
|
||||
size: options.size,
|
||||
resolve: options.resolve,
|
||||
backdrop: 'static'
|
||||
})
|
||||
|
||||
return {
|
||||
close: modal.close,
|
||||
result: $q((resolve, reject) => {
|
||||
modal.result.then((value) => {
|
||||
analytics.logEvent('Modal accepted', {
|
||||
name: options.name,
|
||||
value,
|
||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
|
||||
})
|
||||
|
||||
resolve(value)
|
||||
}).catch((error) => {
|
||||
// Bootstrap doesn't 'resolve' these but cancels the dialog
|
||||
if (error === 'escape key press') {
|
||||
analytics.logEvent('Modal rejected', {
|
||||
name: options.name,
|
||||
method: error,
|
||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
|
||||
})
|
||||
|
||||
return resolve()
|
||||
}
|
||||
|
||||
analytics.logEvent('Modal rejected', {
|
||||
name: options.name,
|
||||
value: error,
|
||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
|
||||
})
|
||||
|
||||
return reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
.modal-content {
|
||||
background-color: $palette-theme-light-background;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 auto;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
color: $palette-theme-light-soft-foreground;
|
||||
padding: 11px 20px;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: inherit;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex-grow: 1;
|
||||
color: $palette-theme-light-foreground;
|
||||
padding: 20px;
|
||||
max-height: 250px;
|
||||
overflow: auto;
|
||||
|
||||
a {
|
||||
color: $palette-theme-primary-background;
|
||||
}
|
||||
|
||||
> p {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
> p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-menu {
|
||||
display: flex;
|
||||
|
||||
> * {
|
||||
flex-basis: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// UI Bootstrap adds the `.modal-open` class to the <body>
|
||||
// element and sets its right padding to the width of the
|
||||
// window, causing the window content to overflow and get
|
||||
// pushed to the bottom.
|
||||
// The `!important` flag is needed since UI Bootstrap inlines
|
||||
// the styles programmatically to the element.
|
||||
.modal-open {
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
// Disable modal opacity
|
||||
.modal-backdrop.in {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
flex-grow: 0;
|
||||
border: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal {
|
||||
|
||||
// Center the modal using Flexbox so we can
|
||||
// freely use any height.
|
||||
display: flex !important;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.button[disabled] {
|
||||
background-color: $palette-theme-light-disabled-background;
|
||||
color: $palette-theme-light-disabled-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
margin: 0;
|
||||
position: initial;
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const React = require('react')
|
||||
const propTypes = require('prop-types')
|
||||
const Color = require('color')
|
||||
|
||||
const {
|
||||
default: styled,
|
||||
css,
|
||||
keyframes
|
||||
} = require('styled-components')
|
||||
|
||||
const { ProgressBar } = require('rendition')
|
||||
|
||||
const { colors } = require('./../../theme')
|
||||
const { StepButton, StepSelection } = require('./../../styled-components')
|
||||
|
||||
const darkenForegroundStripes = 0.18
|
||||
const desaturateForegroundStripes = 0.2
|
||||
const progressButtonStripesForegroundColor = Color(colors.primary.background)
|
||||
.darken(darkenForegroundStripes)
|
||||
.desaturate(desaturateForegroundStripes)
|
||||
.string()
|
||||
|
||||
const desaturateBackgroundStripes = 0.05
|
||||
const progressButtonStripesBackgroundColor = Color(colors.primary.background)
|
||||
.desaturate(desaturateBackgroundStripes)
|
||||
.string()
|
||||
|
||||
const ProgressButtonStripes = keyframes `
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 20px 20px;
|
||||
}
|
||||
`
|
||||
|
||||
const ProgressButtonStripesRule = css `
|
||||
${ProgressButtonStripes} 1s linear infinite;
|
||||
`
|
||||
|
||||
const FlashProgressBar = styled(ProgressBar) `
|
||||
> div {
|
||||
width: 200px;
|
||||
height: 48px;
|
||||
color: white !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
width: 200px;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
line-height: 48px;
|
||||
|
||||
background: ${Color(colors.warning.background).darken(darkenForegroundStripes).string()};
|
||||
`
|
||||
|
||||
const FlashProgressBarValidating = styled(FlashProgressBar) `
|
||||
|
||||
// Notice that we add 0.01 to certain gradient stop positions.
|
||||
// That workarounds a Chrome rendering issue where diagonal
|
||||
// lines look spiky.
|
||||
// See https://github.com/balena-io/etcher/issues/472
|
||||
|
||||
background-image: -webkit-gradient(linear, 0 0, 100% 100%,
|
||||
color-stop(0.25, ${progressButtonStripesForegroundColor}),
|
||||
color-stop(0.26, ${progressButtonStripesBackgroundColor}),
|
||||
color-stop(0.50, ${progressButtonStripesBackgroundColor}),
|
||||
color-stop(0.51, ${progressButtonStripesForegroundColor}),
|
||||
color-stop(0.75, ${progressButtonStripesForegroundColor}),
|
||||
color-stop(0.76 , ${progressButtonStripesBackgroundColor}),
|
||||
to(${progressButtonStripesBackgroundColor}));
|
||||
|
||||
background-color: white;
|
||||
|
||||
animation: ${ProgressButtonStripesRule};
|
||||
overflow: hidden;
|
||||
|
||||
background-size: 20px 20px;
|
||||
`
|
||||
|
||||
/**
|
||||
* Progress Button component
|
||||
*/
|
||||
class ProgressButton extends React.Component {
|
||||
render () {
|
||||
if (this.props.active) {
|
||||
if (this.props.striped) {
|
||||
return (
|
||||
<StepSelection>
|
||||
<FlashProgressBarValidating
|
||||
primary
|
||||
emphasized
|
||||
value= { this.props.percentage }
|
||||
>
|
||||
{ this.props.label }
|
||||
</FlashProgressBarValidating>
|
||||
</StepSelection>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<StepSelection>
|
||||
<FlashProgressBar
|
||||
warning
|
||||
emphasized
|
||||
value= { this.props.percentage }
|
||||
>
|
||||
{ this.props.label }
|
||||
</FlashProgressBar>
|
||||
</StepSelection>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<StepSelection>
|
||||
<StepButton
|
||||
onClick= { this.props.callback }
|
||||
disabled= { this.props.disabled }
|
||||
>
|
||||
{this.props.label}
|
||||
</StepButton>
|
||||
</StepSelection>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ProgressButton.propTypes = {
|
||||
striped: propTypes.bool,
|
||||
active: propTypes.bool,
|
||||
percentage: propTypes.number,
|
||||
label: propTypes.string,
|
||||
disabled: propTypes.bool,
|
||||
callback: propTypes.func
|
||||
}
|
||||
|
||||
module.exports = ProgressButton
|
||||
136
lib/gui/app/components/progress-button/progress-button.tsx
Normal file
136
lib/gui/app/components/progress-button/progress-button.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import { Flex, Button, ProgressBar, Txt } from 'rendition';
|
||||
import { default as styled } from 'styled-components';
|
||||
|
||||
import { fromFlashState } from '../../modules/progress-status';
|
||||
import { StepButton } from '../../styled-components';
|
||||
import * as i18next from 'i18next';
|
||||
|
||||
const FlashProgressBar = styled(ProgressBar)`
|
||||
> div {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
color: white !important;
|
||||
text-shadow: none !important;
|
||||
transition-duration: 0s;
|
||||
|
||||
> div {
|
||||
transition-duration: 0s;
|
||||
}
|
||||
}
|
||||
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 14px;
|
||||
font-size: 16px;
|
||||
line-height: 48px;
|
||||
|
||||
background: #2f3033;
|
||||
`;
|
||||
|
||||
interface ProgressButtonProps {
|
||||
type: 'decompressing' | 'flashing' | 'verifying';
|
||||
active: boolean;
|
||||
percentage: number;
|
||||
position: number;
|
||||
disabled: boolean;
|
||||
cancel: (type: string) => void;
|
||||
callback: () => void;
|
||||
warning?: boolean;
|
||||
}
|
||||
|
||||
const colors = {
|
||||
decompressing: '#00aeef',
|
||||
flashing: '#da60ff',
|
||||
verifying: '#1ac135',
|
||||
} as const;
|
||||
|
||||
const CancelButton = styled(({ type, onClick, ...props }) => {
|
||||
const status = type === 'verifying' ? i18next.t('skip') : i18next.t('cancel');
|
||||
return (
|
||||
<Button plain onClick={() => onClick(status)} {...props}>
|
||||
{status}
|
||||
</Button>
|
||||
);
|
||||
})`
|
||||
font-weight: 600;
|
||||
|
||||
&&& {
|
||||
width: auto;
|
||||
height: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
`;
|
||||
|
||||
export class ProgressButton extends React.PureComponent<ProgressButtonProps> {
|
||||
public render() {
|
||||
const percentage = this.props.percentage;
|
||||
const warning = this.props.warning;
|
||||
const { status, position } = fromFlashState({
|
||||
type: this.props.type,
|
||||
percentage,
|
||||
position: this.props.position,
|
||||
});
|
||||
const type = this.props.type || 'default';
|
||||
if (this.props.active) {
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
alignItems="baseline"
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
style={{
|
||||
marginTop: 42,
|
||||
marginBottom: '6px',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<Flex>
|
||||
<Txt color="#fff">{status} </Txt>
|
||||
<Txt color={colors[type]}>{position}</Txt>
|
||||
</Flex>
|
||||
{type && (
|
||||
<CancelButton
|
||||
type={type}
|
||||
onClick={this.props.cancel}
|
||||
color="#00aeef"
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
<FlashProgressBar background={colors[type]} value={percentage} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<StepButton
|
||||
primary={!warning}
|
||||
warning={warning}
|
||||
onClick={this.props.callback}
|
||||
disabled={this.props.disabled}
|
||||
style={{
|
||||
marginTop: 30,
|
||||
}}
|
||||
>
|
||||
{i18next.t('flash.flashNow')}
|
||||
</StepButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const React = require('react')
|
||||
const propTypes = require('prop-types')
|
||||
const styled = require('styled-components').default
|
||||
const { color } = require('styled-system')
|
||||
const SvgIcon = require('../svg-icon/svg-icon.jsx')
|
||||
|
||||
const Div = styled.div `
|
||||
position: absolute;
|
||||
top: 45px;
|
||||
left: 545px;
|
||||
|
||||
> span.step-name {
|
||||
justify-content: flex-start;
|
||||
|
||||
> span {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
> span:nth-child(2) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
> span:nth-child(3) {
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.svg-icon[disabled] {
|
||||
opacity: 0.4;
|
||||
}
|
||||
`
|
||||
|
||||
const Span = styled.span `
|
||||
${color}
|
||||
`
|
||||
|
||||
const ReducedFlashingInfos = (props) => {
|
||||
return (props.shouldShow) ? (
|
||||
<Div>
|
||||
<Span className="step-name">
|
||||
<SvgIcon disabled contents={[ props.imageLogo ]} paths={[ '../../assets/image.svg' ]} width='20px'></SvgIcon>
|
||||
<Span>{ props.imageName }</Span>
|
||||
<Span color='#7e8085'>{ props.imageSize }</Span>
|
||||
</Span>
|
||||
|
||||
<Span className="step-name">
|
||||
<SvgIcon disabled paths={[ '../../assets/drive.svg' ]} width='20px'></SvgIcon>
|
||||
<Span>{ props.driveTitle }</Span>
|
||||
</Span>
|
||||
</Div>
|
||||
) : null
|
||||
}
|
||||
|
||||
ReducedFlashingInfos.propTypes = {
|
||||
imageLogo: propTypes.string,
|
||||
imageName: propTypes.string,
|
||||
imageSize: propTypes.string,
|
||||
driveTitle: propTypes.string,
|
||||
shouldShow: propTypes.bool
|
||||
}
|
||||
|
||||
module.exports = ReducedFlashingInfos
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import { Flex, Txt } from 'rendition';
|
||||
|
||||
import DriveSvg from '../../../assets/drive.svg';
|
||||
import ImageSvg from '../../../assets/image.svg';
|
||||
import { SVGIcon } from '../svg-icon/svg-icon';
|
||||
import { middleEllipsis } from '../../utils/middle-ellipsis';
|
||||
|
||||
interface ReducedFlashingInfosProps {
|
||||
imageLogo?: string;
|
||||
imageName?: string;
|
||||
imageSize: string;
|
||||
driveTitle: string;
|
||||
driveLabel: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export class ReducedFlashingInfos extends React.Component<ReducedFlashingInfosProps> {
|
||||
constructor(props: ReducedFlashingInfosProps) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { imageName = '' } = this.props;
|
||||
return (
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
style={this.props.style ? this.props.style : undefined}
|
||||
>
|
||||
<Flex mb={16}>
|
||||
<SVGIcon
|
||||
disabled
|
||||
width="21px"
|
||||
height="21px"
|
||||
contents={this.props.imageLogo}
|
||||
fallback={ImageSvg}
|
||||
style={{ marginRight: '9px' }}
|
||||
/>
|
||||
<Txt
|
||||
style={{ marginRight: '9px' }}
|
||||
tooltip={{ text: imageName, placement: 'right' }}
|
||||
>
|
||||
{middleEllipsis(imageName, 16)}
|
||||
</Txt>
|
||||
<Txt color="#7e8085">{this.props.imageSize}</Txt>
|
||||
</Flex>
|
||||
|
||||
<Flex>
|
||||
<DriveSvg width="21px" height="21px" style={{ marginRight: '9px' }} />
|
||||
<Txt tooltip={{ text: this.props.driveLabel, placement: 'right' }}>
|
||||
{middleEllipsis(this.props.driveTitle, 16)}
|
||||
</Txt>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/*
|
||||
* Copyright 2018 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* @module Etcher.Components.SafeWebview
|
||||
*/
|
||||
|
||||
const angular = require('angular')
|
||||
const { react2angular } = require('react2angular')
|
||||
|
||||
const MODULE_NAME = 'Etcher.Components.SafeWebview'
|
||||
const SafeWebview = angular.module(MODULE_NAME, [])
|
||||
|
||||
SafeWebview.component(
|
||||
'safeWebview',
|
||||
react2angular(require('./safe-webview.jsx'))
|
||||
)
|
||||
|
||||
module.exports = MODULE_NAME
|
||||
@@ -1,245 +0,0 @@
|
||||
/*
|
||||
* Copyright 2017 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
/* eslint-disable jsdoc/require-example */
|
||||
|
||||
const _ = require('lodash')
|
||||
const electron = require('electron')
|
||||
const react = require('react')
|
||||
const propTypes = require('prop-types')
|
||||
const analytics = require('../../modules/analytics')
|
||||
const store = require('../../models/store')
|
||||
const settings = require('../../models/settings')
|
||||
const packageJSON = require('../../../../../package.json')
|
||||
|
||||
/**
|
||||
* @summary Electron session identifier
|
||||
* @constant
|
||||
* @private
|
||||
* @type {String}
|
||||
*/
|
||||
const ELECTRON_SESSION = 'persist:success-banner'
|
||||
|
||||
/**
|
||||
* @summary Etcher version search-parameter key
|
||||
* @constant
|
||||
* @private
|
||||
* @type {String}
|
||||
*/
|
||||
const ETCHER_VERSION_PARAM = 'etcher-version'
|
||||
|
||||
/**
|
||||
* @summary API version search-parameter key
|
||||
* @constant
|
||||
* @private
|
||||
* @type {String}
|
||||
*/
|
||||
const API_VERSION_PARAM = 'api-version'
|
||||
|
||||
/**
|
||||
* @summary Opt-out analytics search-parameter key
|
||||
* @constant
|
||||
* @private
|
||||
* @type {String}
|
||||
*/
|
||||
const OPT_OUT_ANALYTICS_PARAM = 'optOutAnalytics'
|
||||
|
||||
/**
|
||||
* @summary Webview API version
|
||||
* @constant
|
||||
* @private
|
||||
* @type {String}
|
||||
*
|
||||
* @description
|
||||
* Changing this number represents a departure from an older API and as such
|
||||
* should only be changed when truly necessary as it introduces breaking changes.
|
||||
* This version number is exposed to the banner such that it can determine what
|
||||
* features are safe to utilize.
|
||||
*
|
||||
* See `git blame -L n` where n is the line below for the history of version changes.
|
||||
*/
|
||||
const API_VERSION = 2
|
||||
|
||||
/**
|
||||
* @summary Webviews that hide/show depending on the HTTP status returned
|
||||
* @type {Object}
|
||||
* @public
|
||||
*
|
||||
* @example
|
||||
* <safe-webview src="https://etcher.io/"></safe-webview>
|
||||
*/
|
||||
class SafeWebview extends react.PureComponent {
|
||||
/**
|
||||
* @param {Object} props - React element properties
|
||||
*/
|
||||
constructor (props) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
shouldShow: true
|
||||
}
|
||||
|
||||
const url = new window.URL(props.src)
|
||||
|
||||
// We set the version GET parameters here.
|
||||
url.searchParams.set(ETCHER_VERSION_PARAM, packageJSON.version)
|
||||
url.searchParams.set(API_VERSION_PARAM, API_VERSION)
|
||||
url.searchParams.set(OPT_OUT_ANALYTICS_PARAM, !settings.get('errorReporting'))
|
||||
|
||||
this.entryHref = url.href
|
||||
|
||||
// Events steal 'this'
|
||||
this.didFailLoad = _.bind(this.didFailLoad, this)
|
||||
this.didGetResponseDetails = _.bind(this.didGetResponseDetails, this)
|
||||
|
||||
const logWebViewMessage = (event) => {
|
||||
console.log('Message from SafeWebview:', event.message)
|
||||
}
|
||||
|
||||
this.eventTuples = [
|
||||
[ 'did-fail-load', this.didFailLoad ],
|
||||
[ 'new-window', this.constructor.newWindow ],
|
||||
[ 'console-message', logWebViewMessage ]
|
||||
]
|
||||
|
||||
// Make a persistent electron session for the webview
|
||||
this.session = electron.remote.session.fromPartition(ELECTRON_SESSION, {
|
||||
|
||||
// Disable the cache for the session such that new content shows up when refreshing
|
||||
cache: false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {react.Element}
|
||||
*/
|
||||
render () {
|
||||
return react.createElement('webview', {
|
||||
ref: 'webview',
|
||||
partition: ELECTRON_SESSION,
|
||||
style: {
|
||||
flex: this.state.shouldShow ? null : '0 1',
|
||||
width: this.state.shouldShow ? null : '0',
|
||||
height: this.state.shouldShow ? null : '0'
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Add the Webview events
|
||||
*/
|
||||
componentDidMount () {
|
||||
// Events React is unaware of have to be handled manually
|
||||
_.map(this.eventTuples, (tuple) => {
|
||||
this.refs.webview.addEventListener(...tuple)
|
||||
})
|
||||
|
||||
this.session.webRequest.onCompleted(this.didGetResponseDetails)
|
||||
|
||||
// It's important that this comes after the partition setting, otherwise it will
|
||||
// use another session and we can't change it without destroying the element again
|
||||
this.refs.webview.src = this.entryHref
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Remove the Webview events
|
||||
*/
|
||||
componentWillUnmount () {
|
||||
// Events that React is unaware of have to be handled manually
|
||||
_.map(this.eventTuples, (tuple) => {
|
||||
this.refs.webview.removeEventListener(...tuple)
|
||||
})
|
||||
this.session.webRequest.onCompleted(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Set the element state to hidden
|
||||
*/
|
||||
didFailLoad () {
|
||||
this.setState({
|
||||
shouldShow: false
|
||||
})
|
||||
if (this.props.onWebviewShow) {
|
||||
this.props.onWebviewShow(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Set the element state depending on the HTTP response code
|
||||
* @param {Event} event - Event object
|
||||
*/
|
||||
didGetResponseDetails (event) {
|
||||
// This seems to pick up all requests related to the webview,
|
||||
// only care about this event if it's a request for the main frame
|
||||
if (event.resourceType === 'mainFrame') {
|
||||
const HTTP_OK = 200
|
||||
|
||||
analytics.logEvent('SafeWebview loaded', {
|
||||
event,
|
||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
|
||||
})
|
||||
|
||||
this.setState({
|
||||
shouldShow: event.statusCode === HTTP_OK
|
||||
})
|
||||
if (this.props.onWebviewShow) {
|
||||
this.props.onWebviewShow(event.statusCode === HTTP_OK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Open link in browser if it's opened as a 'foreground-tab'
|
||||
* @param {Event} event - event object
|
||||
*/
|
||||
static newWindow (event) {
|
||||
const url = new window.URL(event.url)
|
||||
|
||||
if (_.every([
|
||||
url.protocol === 'http:' || url.protocol === 'https:',
|
||||
event.disposition === 'foreground-tab',
|
||||
|
||||
// Don't open links if they're disabled by the env var
|
||||
!settings.get('disableExternalLinks')
|
||||
])) {
|
||||
electron.shell.openExternal(url.href)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SafeWebview.propTypes = {
|
||||
|
||||
/**
|
||||
* @summary The website source URL
|
||||
*/
|
||||
src: propTypes.string.isRequired,
|
||||
|
||||
/**
|
||||
* @summary Refresh the webview
|
||||
*/
|
||||
refreshNow: propTypes.bool,
|
||||
|
||||
/**
|
||||
* @summary Webview lifecycle event
|
||||
*/
|
||||
onWebviewShow: propTypes.func
|
||||
|
||||
}
|
||||
|
||||
module.exports = SafeWebview
|
||||
208
lib/gui/app/components/safe-webview/safe-webview.tsx
Normal file
208
lib/gui/app/components/safe-webview/safe-webview.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* Copyright 2017 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as electron from 'electron';
|
||||
import * as _ from 'lodash';
|
||||
import * as React from 'react';
|
||||
|
||||
import * as packageJSON from '../../../../../package.json';
|
||||
import * as settings from '../../models/settings';
|
||||
import * as analytics from '../../modules/analytics';
|
||||
|
||||
/**
|
||||
* @summary Electron session identifier
|
||||
*/
|
||||
const ELECTRON_SESSION = 'persist:success-banner';
|
||||
|
||||
/**
|
||||
* @summary Etcher version search-parameter key
|
||||
*/
|
||||
const ETCHER_VERSION_PARAM = 'etcher-version';
|
||||
|
||||
/**
|
||||
* @summary API version search-parameter key
|
||||
*/
|
||||
const API_VERSION_PARAM = 'api-version';
|
||||
|
||||
/**
|
||||
* @summary Opt-out analytics search-parameter key
|
||||
*/
|
||||
const OPT_OUT_ANALYTICS_PARAM = 'optOutAnalytics';
|
||||
|
||||
/**
|
||||
* @summary Webview API version
|
||||
*
|
||||
* @description
|
||||
* Changing this number represents a departure from an older API and as such
|
||||
* should only be changed when truly necessary as it introduces breaking changes.
|
||||
* This version number is exposed to the banner such that it can determine what
|
||||
* features are safe to utilize.
|
||||
*
|
||||
* See `git blame -L n` where n is the line below for the history of version changes.
|
||||
*/
|
||||
const API_VERSION = '2';
|
||||
|
||||
interface SafeWebviewProps {
|
||||
// The website source URL
|
||||
src: string;
|
||||
// Webview lifecycle event
|
||||
onWebviewShow?: (isWebviewShowing: boolean) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
interface SafeWebviewState {
|
||||
shouldShow: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Webviews that hide/show depending on the HTTP status returned
|
||||
*/
|
||||
export class SafeWebview extends React.PureComponent<
|
||||
SafeWebviewProps,
|
||||
SafeWebviewState
|
||||
> {
|
||||
private entryHref: string;
|
||||
private session: electron.Session;
|
||||
private webviewRef: React.RefObject<electron.WebviewTag>;
|
||||
|
||||
constructor(props: SafeWebviewProps) {
|
||||
super(props);
|
||||
this.webviewRef = React.createRef();
|
||||
this.state = {
|
||||
shouldShow: true,
|
||||
};
|
||||
const url = new window.URL(this.props.src);
|
||||
// We set the version GET parameters here.
|
||||
url.searchParams.set(ETCHER_VERSION_PARAM, packageJSON.version);
|
||||
url.searchParams.set(API_VERSION_PARAM, API_VERSION);
|
||||
url.searchParams.set(
|
||||
OPT_OUT_ANALYTICS_PARAM,
|
||||
(!settings.getSync('errorReporting')).toString(),
|
||||
);
|
||||
this.entryHref = url.href;
|
||||
// Events steal 'this'
|
||||
this.didFailLoad = _.bind(this.didFailLoad, this);
|
||||
this.didGetResponseDetails = _.bind(this.didGetResponseDetails, this);
|
||||
// Make a persistent electron session for the webview
|
||||
this.session = electron.remote.session.fromPartition(ELECTRON_SESSION, {
|
||||
// Disable the cache for the session such that new content shows up when refreshing
|
||||
cache: false,
|
||||
});
|
||||
}
|
||||
|
||||
private static logWebViewMessage(event: electron.ConsoleMessageEvent) {
|
||||
console.log('Message from SafeWebview:', event.message);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const {
|
||||
style = {
|
||||
flex: this.state.shouldShow ? undefined : '0 1',
|
||||
width: this.state.shouldShow ? undefined : '0',
|
||||
height: this.state.shouldShow ? undefined : '0',
|
||||
},
|
||||
} = this.props;
|
||||
return (
|
||||
<webview
|
||||
ref={this.webviewRef}
|
||||
partition={ELECTRON_SESSION}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Add the Webview events
|
||||
public componentDidMount() {
|
||||
// Events React is unaware of have to be handled manually
|
||||
if (this.webviewRef.current !== null) {
|
||||
this.webviewRef.current.addEventListener(
|
||||
'did-fail-load',
|
||||
this.didFailLoad,
|
||||
);
|
||||
this.webviewRef.current.addEventListener(
|
||||
'new-window',
|
||||
SafeWebview.newWindow,
|
||||
);
|
||||
this.webviewRef.current.addEventListener(
|
||||
'console-message',
|
||||
SafeWebview.logWebViewMessage,
|
||||
);
|
||||
this.session.webRequest.onCompleted(this.didGetResponseDetails);
|
||||
// It's important that this comes after the partition setting, otherwise it will
|
||||
// use another session and we can't change it without destroying the element again
|
||||
this.webviewRef.current.src = this.entryHref;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the Webview events
|
||||
public componentWillUnmount() {
|
||||
// Events that React is unaware of have to be handled manually
|
||||
if (this.webviewRef.current !== null) {
|
||||
this.webviewRef.current.removeEventListener(
|
||||
'did-fail-load',
|
||||
this.didFailLoad,
|
||||
);
|
||||
this.webviewRef.current.removeEventListener(
|
||||
'new-window',
|
||||
SafeWebview.newWindow,
|
||||
);
|
||||
this.webviewRef.current.removeEventListener(
|
||||
'console-message',
|
||||
SafeWebview.logWebViewMessage,
|
||||
);
|
||||
}
|
||||
this.session.webRequest.onCompleted(null);
|
||||
}
|
||||
|
||||
// Set the element state to hidden
|
||||
public didFailLoad() {
|
||||
this.setState({
|
||||
shouldShow: false,
|
||||
});
|
||||
if (this.props.onWebviewShow) {
|
||||
this.props.onWebviewShow(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Set the element state depending on the HTTP response code
|
||||
public didGetResponseDetails(event: electron.OnCompletedListenerDetails) {
|
||||
// This seems to pick up all requests related to the webview,
|
||||
// only care about this event if it's a request for the main frame
|
||||
if (event.resourceType === 'mainFrame') {
|
||||
const HTTP_OK = 200;
|
||||
analytics.logEvent('SafeWebview loaded', { event });
|
||||
this.setState({
|
||||
shouldShow: event.statusCode === HTTP_OK,
|
||||
});
|
||||
if (this.props.onWebviewShow) {
|
||||
this.props.onWebviewShow(event.statusCode === HTTP_OK);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open link in browser if it's opened as a 'foreground-tab'
|
||||
public static async newWindow(event: electron.NewWindowEvent) {
|
||||
const url = new window.URL(event.url);
|
||||
if (
|
||||
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
||||
event.disposition === 'foreground-tab' &&
|
||||
// Don't open links if they're disabled by the env var
|
||||
!(await settings.get('disableExternalLinks'))
|
||||
) {
|
||||
electron.shell.openExternal(url.href);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,214 +14,143 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { faGithub } from '@fortawesome/free-brands-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import GithubSvg from '@fortawesome/fontawesome-free/svgs/brands/github.svg';
|
||||
import * as _ from 'lodash';
|
||||
import * as os from 'os';
|
||||
import * as React from 'react';
|
||||
import { Badge, Checkbox, Modal } from 'rendition';
|
||||
import styled from 'styled-components';
|
||||
import { Box, Checkbox, Flex, TextWithCopy, Txt } from 'rendition';
|
||||
|
||||
import { version } from '../../../../../package.json';
|
||||
import { version, packageType } from '../../../../../package.json';
|
||||
import * as settings from '../../models/settings';
|
||||
import * as store from '../../models/store';
|
||||
import * as analytics from '../../modules/analytics';
|
||||
import { open as openExternal } from '../../os/open-external/services/open-external';
|
||||
|
||||
const { useState } = React;
|
||||
const platform = os.platform();
|
||||
|
||||
interface WarningModalProps {
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
cancel: () => void;
|
||||
done: () => void;
|
||||
}
|
||||
|
||||
const WarningModal = ({
|
||||
message,
|
||||
confirmLabel,
|
||||
cancel,
|
||||
done,
|
||||
}: WarningModalProps) => {
|
||||
return (
|
||||
<Modal
|
||||
title={confirmLabel}
|
||||
action={confirmLabel}
|
||||
cancel={cancel}
|
||||
done={done}
|
||||
style={{
|
||||
width: 420,
|
||||
height: 300,
|
||||
}}
|
||||
primaryButtonProps={{ warning: true }}
|
||||
>
|
||||
{message}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
import { Modal } from '../../styled-components';
|
||||
import * as i18next from 'i18next';
|
||||
import { etcherProInfo } from '../../utils/etcher-pro-specific';
|
||||
|
||||
interface Setting {
|
||||
name: string;
|
||||
label: string | JSX.Element;
|
||||
options?: any;
|
||||
hide?: boolean;
|
||||
}
|
||||
|
||||
const settingsList: Setting[] = [
|
||||
{
|
||||
name: 'errorReporting',
|
||||
label: 'Anonymously report errors and usage statistics to balena.io',
|
||||
},
|
||||
{
|
||||
name: 'unmountOnSuccess',
|
||||
/**
|
||||
* On Windows, "Unmounting" basically means "ejecting".
|
||||
* On top of that, Windows users are usually not even
|
||||
* familiar with the meaning of "unmount", which comes
|
||||
* from the UNIX world.
|
||||
*/
|
||||
label: `${platform === 'win32' ? 'Eject' : 'Auto-unmount'} on success`,
|
||||
},
|
||||
{
|
||||
name: 'validateWriteOnSuccess',
|
||||
label: 'Validate write on success',
|
||||
},
|
||||
{
|
||||
name: 'trim',
|
||||
label: 'Trim ext{2,3,4} partitions before writing (raw images only)',
|
||||
},
|
||||
{
|
||||
name: 'updatesEnabled',
|
||||
label: 'Auto-updates enabled',
|
||||
},
|
||||
{
|
||||
name: 'unsafeMode',
|
||||
label: (
|
||||
<span>
|
||||
Unsafe mode{' '}
|
||||
<Badge danger fontSize={12}>
|
||||
Dangerous
|
||||
</Badge>
|
||||
</span>
|
||||
),
|
||||
options: {
|
||||
description: `Are you sure you want to turn this on?
|
||||
You will be able to overwrite your system drives if you're not careful.`,
|
||||
confirmLabel: 'Enable unsafe mode',
|
||||
async function getSettingsList(): Promise<Setting[]> {
|
||||
const list: Setting[] = [
|
||||
{
|
||||
name: 'errorReporting',
|
||||
label: i18next.t('settings.errorReporting'),
|
||||
},
|
||||
hide: settings.get('disableUnsafeMode'),
|
||||
},
|
||||
];
|
||||
{
|
||||
name: 'autoBlockmapping',
|
||||
label: i18next.t('settings.trimExtPartitions'),
|
||||
},
|
||||
];
|
||||
if (['appimage', 'nsis', 'dmg'].includes(packageType)) {
|
||||
list.push({
|
||||
name: 'updatesEnabled',
|
||||
label: i18next.t('settings.autoUpdate'),
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
interface SettingsModalProps {
|
||||
toggleModal: (value: boolean) => void;
|
||||
}
|
||||
|
||||
interface Dictionary<T> {
|
||||
[key: string]: T;
|
||||
}
|
||||
const EPInfo = etcherProInfo();
|
||||
|
||||
export const SettingsModal: any = styled(
|
||||
({ toggleModal }: SettingsModalProps) => {
|
||||
const [currentSettings, setCurrentSettings]: [
|
||||
Dictionary<any>,
|
||||
React.Dispatch<React.SetStateAction<Dictionary<any>>>,
|
||||
] = useState(settings.getAll());
|
||||
const [warning, setWarning]: [
|
||||
any,
|
||||
React.Dispatch<React.SetStateAction<any>>,
|
||||
] = useState({});
|
||||
const InfoBox = (props: any) => (
|
||||
<Box fontSize={14}>
|
||||
<Txt>{props.label}</Txt>
|
||||
<TextWithCopy code text={props.value} copy={props.value} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
const toggleSetting = async (setting: string, options?: any) => {
|
||||
const value = currentSettings[setting];
|
||||
const dangerous = !_.isUndefined(options);
|
||||
|
||||
analytics.logEvent('Toggle setting', {
|
||||
setting,
|
||||
value,
|
||||
dangerous,
|
||||
// @ts-ignore
|
||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
||||
});
|
||||
|
||||
if (value || !dangerous) {
|
||||
await settings.set(setting, !value);
|
||||
setCurrentSettings({
|
||||
...currentSettings,
|
||||
[setting]: !value,
|
||||
});
|
||||
setWarning({});
|
||||
return;
|
||||
export function SettingsModal({ toggleModal }: SettingsModalProps) {
|
||||
const [settingsList, setCurrentSettingsList] = React.useState<Setting[]>([]);
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
if (settingsList.length === 0) {
|
||||
setCurrentSettingsList(await getSettingsList());
|
||||
}
|
||||
})();
|
||||
});
|
||||
const [currentSettings, setCurrentSettings] = React.useState<
|
||||
_.Dictionary<boolean>
|
||||
>({});
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
if (_.isEmpty(currentSettings)) {
|
||||
setCurrentSettings(await settings.getAll());
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// Show warning since it's a dangerous setting
|
||||
setWarning({
|
||||
setting,
|
||||
settingValue: value,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
const toggleSetting = async (setting: string) => {
|
||||
const value = currentSettings[setting];
|
||||
analytics.logEvent('Toggle setting', { setting, value });
|
||||
await settings.set(setting, !value);
|
||||
setCurrentSettings({
|
||||
...currentSettings,
|
||||
[setting]: !value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
id="settings-modal"
|
||||
title="Settings"
|
||||
done={() => toggleModal(false)}
|
||||
style={{
|
||||
width: 780,
|
||||
height: 420,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{_.map(settingsList, (setting: Setting, i: number) => {
|
||||
return setting.hide ? null : (
|
||||
<div key={setting.name}>
|
||||
<Checkbox
|
||||
toggle
|
||||
tabIndex={6 + i}
|
||||
label={setting.label}
|
||||
checked={currentSettings[setting.name]}
|
||||
onChange={() => toggleSetting(setting.name, setting.options)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<span
|
||||
onClick={() =>
|
||||
openExternal(
|
||||
'https://github.com/balena-io/etcher/blob/master/CHANGELOG.md',
|
||||
)
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faGithub} /> {version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{_.isEmpty(warning) ? null : (
|
||||
<WarningModal
|
||||
message={warning.description}
|
||||
confirmLabel={warning.confirmLabel}
|
||||
done={() => {
|
||||
settings.set(warning.setting, !warning.settingValue);
|
||||
setCurrentSettings({
|
||||
...currentSettings,
|
||||
[warning.setting]: true,
|
||||
});
|
||||
setWarning({});
|
||||
}}
|
||||
cancel={() => {
|
||||
setWarning({});
|
||||
}}
|
||||
/>
|
||||
return (
|
||||
<Modal
|
||||
titleElement={
|
||||
<Txt fontSize={24} mb={24}>
|
||||
{i18next.t('settings.settings')}
|
||||
</Txt>
|
||||
}
|
||||
done={() => toggleModal(false)}
|
||||
>
|
||||
<Flex flexDirection="column">
|
||||
{settingsList.map((setting: Setting, i: number) => {
|
||||
return (
|
||||
<Flex key={setting.name} mb={14}>
|
||||
<Checkbox
|
||||
toggle
|
||||
tabIndex={6 + i}
|
||||
label={setting.label}
|
||||
checked={currentSettings[setting.name]}
|
||||
onChange={() => toggleSetting(setting.name)}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
{EPInfo !== undefined && (
|
||||
<Flex flexDirection="column">
|
||||
<Txt fontSize={24}>{i18next.t('settings.systemInformation')}</Txt>
|
||||
{EPInfo.get_serial() === undefined ? (
|
||||
<InfoBox label="UUID" value={EPInfo.uuid} />
|
||||
) : (
|
||||
<InfoBox label="Serial" value={EPInfo.get_serial()} />
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
)`
|
||||
> div:nth-child(3) {
|
||||
justify-content: center;
|
||||
}
|
||||
`;
|
||||
<Flex
|
||||
mt={18}
|
||||
alignItems="center"
|
||||
color="#00aeef"
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
}}
|
||||
onClick={() =>
|
||||
openExternal(
|
||||
'https://github.com/balena-io/etcher/blob/master/CHANGELOG.md',
|
||||
)
|
||||
}
|
||||
>
|
||||
<GithubSvg
|
||||
height="1em"
|
||||
fill="currentColor"
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Txt style={{ borderBottom: '1px solid #00aeef' }}>{version}</Txt>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
879
lib/gui/app/components/source-selector/source-selector.tsx
Normal file
879
lib/gui/app/components/source-selector/source-selector.tsx
Normal file
@@ -0,0 +1,879 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import CopySvg from '@fortawesome/fontawesome-free/svgs/solid/copy.svg';
|
||||
import FileSvg from '@fortawesome/fontawesome-free/svgs/solid/file.svg';
|
||||
import LinkSvg from '@fortawesome/fontawesome-free/svgs/solid/link.svg';
|
||||
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg';
|
||||
import ChevronDownSvg from '@fortawesome/fontawesome-free/svgs/solid/chevron-down.svg';
|
||||
import ChevronRightSvg from '@fortawesome/fontawesome-free/svgs/solid/chevron-right.svg';
|
||||
import { sourceDestination } from 'etcher-sdk';
|
||||
import { ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
import * as _ from 'lodash';
|
||||
import { GPTPartition, MBRPartition } from 'partitioninfo';
|
||||
import * as path from 'path';
|
||||
import * as prettyBytes from 'pretty-bytes';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Flex,
|
||||
ButtonProps,
|
||||
Modal as SmallModal,
|
||||
Txt,
|
||||
Card as BaseCard,
|
||||
Input,
|
||||
Spinner,
|
||||
Link,
|
||||
} from 'rendition';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import * as errors from '../../../../shared/errors';
|
||||
import * as messages from '../../../../shared/messages';
|
||||
import * as supportedFormats from '../../../../shared/supported-formats';
|
||||
import * as selectionState from '../../models/selection-state';
|
||||
import { observe } from '../../models/store';
|
||||
import * as analytics from '../../modules/analytics';
|
||||
import * as exceptionReporter from '../../modules/exception-reporter';
|
||||
import * as osDialog from '../../os/dialog';
|
||||
import { replaceWindowsNetworkDriveLetter } from '../../os/windows-network-drives';
|
||||
import {
|
||||
ChangeButton,
|
||||
DetailsText,
|
||||
Modal,
|
||||
StepButton,
|
||||
StepNameButton,
|
||||
ScrollableFlex,
|
||||
} from '../../styled-components';
|
||||
import { colors } from '../../theme';
|
||||
import { middleEllipsis } from '../../utils/middle-ellipsis';
|
||||
import { SVGIcon } from '../svg-icon/svg-icon';
|
||||
|
||||
import ImageSvg from '../../../assets/image.svg';
|
||||
import SrcSvg from '../../../assets/src.svg';
|
||||
import { DriveSelector } from '../drive-selector/drive-selector';
|
||||
import { DrivelistDrive } from '../../../../shared/drive-constraints';
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import { isJson } from '../../../../shared/utils';
|
||||
import * as i18next from 'i18next';
|
||||
|
||||
const recentUrlImagesKey = 'recentUrlImages';
|
||||
|
||||
function normalizeRecentUrlImages(urls: any[]): URL[] {
|
||||
if (!Array.isArray(urls)) {
|
||||
urls = [];
|
||||
}
|
||||
urls = urls
|
||||
.map((url) => {
|
||||
try {
|
||||
return new URL(url);
|
||||
} catch (error: any) {
|
||||
// Invalid URL, skip
|
||||
}
|
||||
})
|
||||
.filter((url) => url !== undefined);
|
||||
urls = _.uniqBy(urls, (url) => url.href);
|
||||
return urls.slice(urls.length - 5);
|
||||
}
|
||||
|
||||
function getRecentUrlImages(): URL[] {
|
||||
let urls = [];
|
||||
try {
|
||||
urls = JSON.parse(localStorage.getItem(recentUrlImagesKey) || '[]');
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
return normalizeRecentUrlImages(urls);
|
||||
}
|
||||
|
||||
function setRecentUrlImages(urls: URL[]) {
|
||||
const normalized = normalizeRecentUrlImages(urls.map((url: URL) => url.href));
|
||||
localStorage.setItem(recentUrlImagesKey, JSON.stringify(normalized));
|
||||
}
|
||||
|
||||
const isURL = (imagePath: string) =>
|
||||
imagePath.startsWith('https://') || imagePath.startsWith('http://');
|
||||
|
||||
const Card = styled(BaseCard)`
|
||||
hr {
|
||||
margin: 5px 0;
|
||||
}
|
||||
`;
|
||||
|
||||
// TODO move these styles to rendition
|
||||
const ModalText = styled.p`
|
||||
a {
|
||||
color: rgb(0, 174, 239);
|
||||
|
||||
&:hover {
|
||||
color: rgb(0, 139, 191);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function getState() {
|
||||
const image = selectionState.getImage();
|
||||
return {
|
||||
hasImage: selectionState.hasImage(),
|
||||
imageName: image?.name,
|
||||
imageSize: image?.size,
|
||||
};
|
||||
}
|
||||
|
||||
function isString(value: any): value is string {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
|
||||
const URLSelector = ({
|
||||
done,
|
||||
cancel,
|
||||
}: {
|
||||
done: (imageURL: string, auth?: Authentication) => void;
|
||||
cancel: () => void;
|
||||
}) => {
|
||||
const [imageURL, setImageURL] = React.useState('');
|
||||
const [recentImages, setRecentImages] = React.useState<URL[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [showBasicAuth, setShowBasicAuth] = React.useState(false);
|
||||
const [username, setUsername] = React.useState('');
|
||||
const [password, setPassword] = React.useState('');
|
||||
React.useEffect(() => {
|
||||
const fetchRecentUrlImages = async () => {
|
||||
const recentUrlImages: URL[] = await getRecentUrlImages();
|
||||
setRecentImages(recentUrlImages);
|
||||
};
|
||||
fetchRecentUrlImages();
|
||||
}, []);
|
||||
return (
|
||||
<Modal
|
||||
cancel={cancel}
|
||||
primaryButtonProps={{
|
||||
disabled: loading || !imageURL,
|
||||
}}
|
||||
action={loading ? <Spinner /> : i18next.t('ok')}
|
||||
done={async () => {
|
||||
setLoading(true);
|
||||
const urlStrings = recentImages.map((url: URL) => url.href);
|
||||
const normalizedRecentUrls = normalizeRecentUrlImages([
|
||||
...urlStrings,
|
||||
imageURL,
|
||||
]);
|
||||
setRecentUrlImages(normalizedRecentUrls);
|
||||
const auth = username ? { username, password } : undefined;
|
||||
await done(imageURL, auth);
|
||||
}}
|
||||
>
|
||||
<Flex flexDirection="column">
|
||||
<Flex mb={15} style={{ width: '100%' }} flexDirection="column">
|
||||
<Txt mb="10px" fontSize="24px">
|
||||
{i18next.t('source.useSourceURL')}
|
||||
</Txt>
|
||||
<Input
|
||||
value={imageURL}
|
||||
placeholder={i18next.t('source.enterValidURL')}
|
||||
type="text"
|
||||
onChange={(evt: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setImageURL(evt.target.value)
|
||||
}
|
||||
/>
|
||||
<Link
|
||||
mt={15}
|
||||
mb={15}
|
||||
fontSize="14px"
|
||||
onClick={() => {
|
||||
if (showBasicAuth) {
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
}
|
||||
setShowBasicAuth(!showBasicAuth);
|
||||
}}
|
||||
>
|
||||
<Flex alignItems="center">
|
||||
{showBasicAuth && (
|
||||
<ChevronDownSvg height="1em" fill="currentColor" />
|
||||
)}
|
||||
{!showBasicAuth && (
|
||||
<ChevronRightSvg height="1em" fill="currentColor" />
|
||||
)}
|
||||
<Txt ml={8}>{i18next.t('source.auth')}</Txt>
|
||||
</Flex>
|
||||
</Link>
|
||||
{showBasicAuth && (
|
||||
<React.Fragment>
|
||||
<Input
|
||||
mb={15}
|
||||
value={username}
|
||||
placeholder={i18next.t('source.username')}
|
||||
type="text"
|
||||
onChange={(evt: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setUsername(evt.target.value)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
value={password}
|
||||
placeholder={i18next.t('source.password')}
|
||||
type="password"
|
||||
onChange={(evt: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setPassword(evt.target.value)
|
||||
}
|
||||
/>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Flex>
|
||||
{recentImages.length > 0 && (
|
||||
<Flex flexDirection="column" height="78.6%">
|
||||
<Txt fontSize={18}>Recent</Txt>
|
||||
<ScrollableFlex flexDirection="column">
|
||||
<Card
|
||||
p="10px 15px"
|
||||
rows={recentImages
|
||||
.map((recent) => (
|
||||
<Txt
|
||||
key={recent.href}
|
||||
onClick={() => {
|
||||
setImageURL(recent.href);
|
||||
}}
|
||||
style={{
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
>
|
||||
{recent.pathname.split('/').pop()} - {recent.href}
|
||||
</Txt>
|
||||
))
|
||||
.reverse()}
|
||||
/>
|
||||
</ScrollableFlex>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface Flow {
|
||||
icon?: JSX.Element;
|
||||
onClick: (evt: React.MouseEvent) => void;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const FlowSelector = styled(
|
||||
({ flow, ...props }: { flow: Flow } & ButtonProps) => (
|
||||
<StepButton
|
||||
plain={!props.primary}
|
||||
primary={props.primary}
|
||||
onClick={(evt: React.MouseEvent<Element, MouseEvent>) =>
|
||||
flow.onClick(evt)
|
||||
}
|
||||
icon={flow.icon}
|
||||
{...props}
|
||||
>
|
||||
{flow.label}
|
||||
</StepButton>
|
||||
),
|
||||
)`
|
||||
border-radius: 24px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
|
||||
:enabled:focus,
|
||||
:enabled:focus svg {
|
||||
color: ${colors.primary.foreground} !important;
|
||||
}
|
||||
|
||||
:enabled:hover {
|
||||
background-color: ${colors.primary.background};
|
||||
color: ${colors.primary.foreground};
|
||||
font-weight: 600;
|
||||
|
||||
svg {
|
||||
color: ${colors.primary.foreground} !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export type Source =
|
||||
| typeof sourceDestination.File
|
||||
| typeof sourceDestination.BlockDevice
|
||||
| typeof sourceDestination.Http;
|
||||
|
||||
export interface SourceMetadata extends sourceDestination.Metadata {
|
||||
hasMBR?: boolean;
|
||||
partitions?: MBRPartition[] | GPTPartition[];
|
||||
path: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
SourceType: Source;
|
||||
drive?: DrivelistDrive;
|
||||
extension?: string;
|
||||
archiveExtension?: string;
|
||||
auth?: Authentication;
|
||||
}
|
||||
|
||||
interface SourceSelectorProps {
|
||||
flashing: boolean;
|
||||
}
|
||||
|
||||
interface SourceSelectorState {
|
||||
hasImage: boolean;
|
||||
imageName?: string;
|
||||
imageSize?: number;
|
||||
warning: { message: string; title: string | null } | null;
|
||||
showImageDetails: boolean;
|
||||
showURLSelector: boolean;
|
||||
showDriveSelector: boolean;
|
||||
defaultFlowActive: boolean;
|
||||
imageSelectorOpen: boolean;
|
||||
imageLoading: boolean;
|
||||
}
|
||||
|
||||
interface Authentication {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class SourceSelector extends React.Component<
|
||||
SourceSelectorProps,
|
||||
SourceSelectorState
|
||||
> {
|
||||
private unsubscribe: (() => void) | undefined;
|
||||
|
||||
constructor(props: SourceSelectorProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
...getState(),
|
||||
warning: null,
|
||||
showImageDetails: false,
|
||||
showURLSelector: false,
|
||||
showDriveSelector: false,
|
||||
defaultFlowActive: true,
|
||||
imageSelectorOpen: false,
|
||||
imageLoading: false,
|
||||
};
|
||||
|
||||
// Bind `this` since it's used in an event's callback
|
||||
this.onSelectImage = this.onSelectImage.bind(this);
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
this.unsubscribe = observe(() => {
|
||||
this.setState(getState());
|
||||
});
|
||||
ipcRenderer.on('select-image', this.onSelectImage);
|
||||
ipcRenderer.send('source-selector-ready');
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.unsubscribe?.();
|
||||
ipcRenderer.removeListener('select-image', this.onSelectImage);
|
||||
}
|
||||
|
||||
private async onSelectImage(_event: IpcRendererEvent, imagePath: string) {
|
||||
this.setState({ imageLoading: true });
|
||||
await this.selectSource(
|
||||
imagePath,
|
||||
isURL(this.normalizeImagePath(imagePath))
|
||||
? sourceDestination.Http
|
||||
: sourceDestination.File,
|
||||
).promise;
|
||||
this.setState({ imageLoading: false });
|
||||
}
|
||||
|
||||
private async createSource(
|
||||
selected: string,
|
||||
SourceType: Source,
|
||||
auth?: Authentication,
|
||||
) {
|
||||
try {
|
||||
selected = await replaceWindowsNetworkDriveLetter(selected);
|
||||
} catch (error: any) {
|
||||
analytics.logException(error);
|
||||
}
|
||||
|
||||
if (isJson(decodeURIComponent(selected))) {
|
||||
const config: AxiosRequestConfig = JSON.parse(
|
||||
decodeURIComponent(selected),
|
||||
);
|
||||
return new sourceDestination.Http({
|
||||
url: config.url!,
|
||||
axiosInstance: axios.create(_.omit(config, ['url'])),
|
||||
});
|
||||
}
|
||||
|
||||
if (SourceType === sourceDestination.File) {
|
||||
return new sourceDestination.File({
|
||||
path: selected,
|
||||
});
|
||||
}
|
||||
|
||||
return new sourceDestination.Http({ url: selected, auth });
|
||||
}
|
||||
|
||||
public normalizeImagePath(imgPath: string) {
|
||||
const decodedPath = decodeURIComponent(imgPath);
|
||||
if (isJson(decodedPath)) {
|
||||
return JSON.parse(decodedPath).url ?? decodedPath;
|
||||
}
|
||||
return decodedPath;
|
||||
}
|
||||
|
||||
private reselectSource() {
|
||||
analytics.logEvent('Reselect image', {
|
||||
previousImage: selectionState.getImage(),
|
||||
});
|
||||
|
||||
selectionState.deselectImage();
|
||||
}
|
||||
|
||||
private selectSource(
|
||||
selected: string | DrivelistDrive,
|
||||
SourceType: Source,
|
||||
auth?: Authentication,
|
||||
): { promise: Promise<void>; cancel: () => void } {
|
||||
let cancelled = false;
|
||||
return {
|
||||
cancel: () => {
|
||||
cancelled = true;
|
||||
},
|
||||
promise: (async () => {
|
||||
const sourcePath = isString(selected) ? selected : selected.device;
|
||||
let source;
|
||||
let metadata: SourceMetadata | undefined;
|
||||
if (isString(selected)) {
|
||||
if (
|
||||
SourceType === sourceDestination.Http &&
|
||||
!isURL(this.normalizeImagePath(selected))
|
||||
) {
|
||||
this.handleError(
|
||||
i18next.t('source.unsupportedProtocol'),
|
||||
selected,
|
||||
messages.error.unsupportedProtocol(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (supportedFormats.looksLikeWindowsImage(selected)) {
|
||||
analytics.logEvent('Possibly Windows image', { image: selected });
|
||||
this.setState({
|
||||
warning: {
|
||||
message: messages.warning.looksLikeWindowsImage(),
|
||||
title: i18next.t('source.windowsImage'),
|
||||
},
|
||||
});
|
||||
}
|
||||
source = await this.createSource(selected, SourceType, auth);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const innerSource = await source.getInnerSource();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
metadata = await this.getMetadata(innerSource, selected);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
metadata.SourceType = SourceType;
|
||||
|
||||
if (!metadata.hasMBR && this.state.warning === null) {
|
||||
analytics.logEvent('Missing partition table', { metadata });
|
||||
this.setState({
|
||||
warning: {
|
||||
message: messages.warning.missingPartitionTable(),
|
||||
title: i18next.t('source.partitionTable'),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.handleError(
|
||||
i18next.t('source.errorOpen'),
|
||||
sourcePath,
|
||||
messages.error.openSource(sourcePath, error.message),
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await source.close();
|
||||
} catch (error: any) {
|
||||
// Noop
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (selected.partitionTableType === null) {
|
||||
analytics.logEvent('Missing partition table', { selected });
|
||||
this.setState({
|
||||
warning: {
|
||||
message: messages.warning.driveMissingPartitionTable(),
|
||||
title: i18next.t('source.partitionTable'),
|
||||
},
|
||||
});
|
||||
}
|
||||
metadata = {
|
||||
path: selected.device,
|
||||
displayName: selected.displayName,
|
||||
description: selected.displayName,
|
||||
size: selected.size as SourceMetadata['size'],
|
||||
SourceType: sourceDestination.BlockDevice,
|
||||
drive: selected,
|
||||
};
|
||||
}
|
||||
|
||||
if (metadata !== undefined) {
|
||||
metadata.auth = auth;
|
||||
selectionState.selectSource(metadata);
|
||||
analytics.logEvent('Select image', {
|
||||
// An easy way so we can quickly identify if we're making use of
|
||||
// certain features without printing pages of text to DevTools.
|
||||
image: {
|
||||
...metadata,
|
||||
logo: Boolean(metadata.logo),
|
||||
blockMap: Boolean(metadata.blockMap),
|
||||
},
|
||||
});
|
||||
}
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
private handleError(
|
||||
title: string,
|
||||
sourcePath: string,
|
||||
description: string,
|
||||
error?: Error,
|
||||
) {
|
||||
const imageError = errors.createUserError({
|
||||
title,
|
||||
description,
|
||||
});
|
||||
osDialog.showError(imageError);
|
||||
if (error) {
|
||||
analytics.logException(error);
|
||||
return;
|
||||
}
|
||||
analytics.logEvent(title, { path: sourcePath });
|
||||
}
|
||||
|
||||
private async getMetadata(
|
||||
source: sourceDestination.SourceDestination,
|
||||
selected: string | DrivelistDrive,
|
||||
) {
|
||||
const metadata = (await source.getMetadata()) as SourceMetadata;
|
||||
const partitionTable = await source.getPartitionTable();
|
||||
if (partitionTable) {
|
||||
metadata.hasMBR = true;
|
||||
metadata.partitions = partitionTable.partitions;
|
||||
} else {
|
||||
metadata.hasMBR = false;
|
||||
}
|
||||
if (isString(selected)) {
|
||||
metadata.extension = path.extname(selected).slice(1);
|
||||
metadata.path = selected;
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private async openImageSelector() {
|
||||
analytics.logEvent('Open image selector');
|
||||
this.setState({ imageSelectorOpen: true });
|
||||
|
||||
try {
|
||||
const imagePath = await osDialog.selectImage();
|
||||
// Avoid analytics and selection state changes
|
||||
// if no file was resolved from the dialog.
|
||||
if (!imagePath) {
|
||||
analytics.logEvent('Image selector closed');
|
||||
return;
|
||||
}
|
||||
await this.selectSource(imagePath, sourceDestination.File).promise;
|
||||
} catch (error: any) {
|
||||
exceptionReporter.report(error);
|
||||
} finally {
|
||||
this.setState({ imageSelectorOpen: false });
|
||||
}
|
||||
}
|
||||
|
||||
private async onDrop(event: React.DragEvent<HTMLDivElement>) {
|
||||
const [file] = event.dataTransfer.files;
|
||||
if (file) {
|
||||
await this.selectSource(file.path, sourceDestination.File).promise;
|
||||
}
|
||||
}
|
||||
|
||||
private openURLSelector() {
|
||||
analytics.logEvent('Open image URL selector');
|
||||
|
||||
this.setState({
|
||||
showURLSelector: true,
|
||||
});
|
||||
}
|
||||
|
||||
private openDriveSelector() {
|
||||
analytics.logEvent('Open drive selector');
|
||||
|
||||
this.setState({
|
||||
showDriveSelector: true,
|
||||
});
|
||||
}
|
||||
|
||||
private onDragOver(event: React.DragEvent<HTMLDivElement>) {
|
||||
// Needed to get onDrop events on div elements
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
private onDragEnter(event: React.DragEvent<HTMLDivElement>) {
|
||||
// Needed to get onDrop events on div elements
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
private showSelectedImageDetails() {
|
||||
analytics.logEvent('Show selected image tooltip', {
|
||||
imagePath: selectionState.getImage()?.path,
|
||||
});
|
||||
|
||||
this.setState({
|
||||
showImageDetails: true,
|
||||
});
|
||||
}
|
||||
|
||||
private setDefaultFlowActive(defaultFlowActive: boolean) {
|
||||
this.setState({ defaultFlowActive });
|
||||
}
|
||||
|
||||
private closeModal() {
|
||||
this.setState({
|
||||
showDriveSelector: false,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO add a visual change when dragging a file over the selector
|
||||
public render() {
|
||||
const { flashing } = this.props;
|
||||
const {
|
||||
showImageDetails,
|
||||
showURLSelector,
|
||||
showDriveSelector,
|
||||
imageLoading,
|
||||
} = this.state;
|
||||
const selectionImage = selectionState.getImage();
|
||||
let image: SourceMetadata | DrivelistDrive =
|
||||
selectionImage !== undefined ? selectionImage : ({} as SourceMetadata);
|
||||
|
||||
image = image.drive ?? image;
|
||||
|
||||
let cancelURLSelection = () => {
|
||||
// noop
|
||||
};
|
||||
image.name = image.description || image.name;
|
||||
const imagePath = image.path || image.displayName || '';
|
||||
const imageBasename = path.basename(imagePath);
|
||||
const imageName = image.name || '';
|
||||
const imageSize = image.size;
|
||||
const imageLogo = image.logo || '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
onDrop={(evt: React.DragEvent<HTMLDivElement>) => this.onDrop(evt)}
|
||||
onDragEnter={(evt: React.DragEvent<HTMLDivElement>) =>
|
||||
this.onDragEnter(evt)
|
||||
}
|
||||
onDragOver={(evt: React.DragEvent<HTMLDivElement>) =>
|
||||
this.onDragOver(evt)
|
||||
}
|
||||
>
|
||||
<SVGIcon
|
||||
contents={imageLogo}
|
||||
fallback={ImageSvg}
|
||||
style={{
|
||||
marginBottom: 30,
|
||||
}}
|
||||
/>
|
||||
|
||||
{selectionImage !== undefined || imageLoading ? (
|
||||
<>
|
||||
<StepNameButton
|
||||
plain
|
||||
onClick={() => this.showSelectedImageDetails()}
|
||||
tooltip={imageName || imageBasename}
|
||||
>
|
||||
<Spinner show={imageLoading}>
|
||||
{middleEllipsis(imageName || imageBasename, 20)}
|
||||
</Spinner>
|
||||
</StepNameButton>
|
||||
{!flashing && !imageLoading && (
|
||||
<ChangeButton
|
||||
plain
|
||||
mb={14}
|
||||
onClick={() => this.reselectSource()}
|
||||
>
|
||||
{i18next.t('cancel')}
|
||||
</ChangeButton>
|
||||
)}
|
||||
{!_.isNil(imageSize) && !imageLoading && (
|
||||
<DetailsText>{prettyBytes(imageSize)}</DetailsText>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FlowSelector
|
||||
disabled={this.state.imageSelectorOpen}
|
||||
primary={this.state.defaultFlowActive}
|
||||
key="Flash from file"
|
||||
flow={{
|
||||
onClick: () => this.openImageSelector(),
|
||||
label: i18next.t('source.fromFile'),
|
||||
icon: <FileSvg height="1em" fill="currentColor" />,
|
||||
}}
|
||||
onMouseEnter={() => this.setDefaultFlowActive(false)}
|
||||
onMouseLeave={() => this.setDefaultFlowActive(true)}
|
||||
/>
|
||||
<FlowSelector
|
||||
key="Flash from URL"
|
||||
flow={{
|
||||
onClick: () => this.openURLSelector(),
|
||||
label: i18next.t('source.fromURL'),
|
||||
icon: <LinkSvg height="1em" fill="currentColor" />,
|
||||
}}
|
||||
onMouseEnter={() => this.setDefaultFlowActive(false)}
|
||||
onMouseLeave={() => this.setDefaultFlowActive(true)}
|
||||
/>
|
||||
<FlowSelector
|
||||
key="Clone drive"
|
||||
flow={{
|
||||
onClick: () => this.openDriveSelector(),
|
||||
label: i18next.t('source.clone'),
|
||||
icon: <CopySvg height="1em" fill="currentColor" />,
|
||||
}}
|
||||
onMouseEnter={() => this.setDefaultFlowActive(false)}
|
||||
onMouseLeave={() => this.setDefaultFlowActive(true)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{this.state.warning != null && (
|
||||
<SmallModal
|
||||
style={{
|
||||
boxShadow: '0 3px 7px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
titleElement={
|
||||
<span>
|
||||
<ExclamationTriangleSvg fill="#fca321" height="1em" />{' '}
|
||||
<span>{this.state.warning.title}</span>
|
||||
</span>
|
||||
}
|
||||
action={i18next.t('continue')}
|
||||
cancel={() => {
|
||||
this.setState({ warning: null });
|
||||
this.reselectSource();
|
||||
}}
|
||||
done={() => {
|
||||
this.setState({ warning: null });
|
||||
}}
|
||||
primaryButtonProps={{ warning: true, primary: false }}
|
||||
>
|
||||
<ModalText
|
||||
dangerouslySetInnerHTML={{ __html: this.state.warning.message }}
|
||||
/>
|
||||
</SmallModal>
|
||||
)}
|
||||
|
||||
{showImageDetails && (
|
||||
<SmallModal
|
||||
title={i18next.t('source.image')}
|
||||
done={() => {
|
||||
this.setState({ showImageDetails: false });
|
||||
}}
|
||||
>
|
||||
<Txt.p>
|
||||
<Txt.span bold>{i18next.t('source.name')}</Txt.span>
|
||||
<Txt.span>{imageName || imageBasename}</Txt.span>
|
||||
</Txt.p>
|
||||
<Txt.p>
|
||||
<Txt.span bold>{i18next.t('source.path')}</Txt.span>
|
||||
<Txt.span>{imagePath}</Txt.span>
|
||||
</Txt.p>
|
||||
</SmallModal>
|
||||
)}
|
||||
|
||||
{showURLSelector && (
|
||||
<URLSelector
|
||||
cancel={() => {
|
||||
cancelURLSelection();
|
||||
this.setState({
|
||||
showURLSelector: false,
|
||||
});
|
||||
}}
|
||||
done={async (imageURL: string, auth?: Authentication) => {
|
||||
// Avoid analytics and selection state changes
|
||||
// if no file was resolved from the dialog.
|
||||
if (!imageURL) {
|
||||
analytics.logEvent('URL selector closed');
|
||||
} else {
|
||||
let promise;
|
||||
({ promise, cancel: cancelURLSelection } = this.selectSource(
|
||||
imageURL,
|
||||
sourceDestination.Http,
|
||||
auth,
|
||||
));
|
||||
await promise;
|
||||
}
|
||||
this.setState({
|
||||
showURLSelector: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDriveSelector && (
|
||||
<DriveSelector
|
||||
write={false}
|
||||
multipleSelection={false}
|
||||
titleLabel={i18next.t('source.selectSource')}
|
||||
emptyListLabel={i18next.t('source.plugSource')}
|
||||
emptyListIcon={<SrcSvg width="40px" />}
|
||||
cancel={(originalList) => {
|
||||
if (originalList.length) {
|
||||
const originalSource = originalList[0];
|
||||
if (selectionImage?.drive?.device !== originalSource.device) {
|
||||
this.selectSource(
|
||||
originalSource,
|
||||
sourceDestination.BlockDevice,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
selectionState.deselectImage();
|
||||
}
|
||||
this.closeModal();
|
||||
}}
|
||||
done={() => this.closeModal()}
|
||||
onSelect={(drive) => {
|
||||
if (drive) {
|
||||
if (
|
||||
selectionState.getImage()?.drive?.device === drive?.device
|
||||
) {
|
||||
return selectionState.deselectImage();
|
||||
}
|
||||
this.selectSource(drive, sourceDestination.BlockDevice);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
|
||||
svg-icon {
|
||||
display: inline-block;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
/*
|
||||
* Copyright 2018 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* @module Etcher.Components.SVGIcon
|
||||
*/
|
||||
|
||||
const _ = require('lodash')
|
||||
const react = require('react')
|
||||
const propTypes = require('prop-types')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const analytics = require('../../modules/analytics')
|
||||
const domParser = new window.DOMParser()
|
||||
|
||||
const DEFAULT_SIZE = '40px'
|
||||
|
||||
/**
|
||||
* @summary Try to parse SVG contents and return it data encoded
|
||||
*
|
||||
* @param {String} contents - SVG XML contents
|
||||
* @returns {String|null}
|
||||
*
|
||||
* @example
|
||||
* const encodedSVG = tryParseSVGContents('<svg><path></path></svg>')
|
||||
*
|
||||
* img.src = encodedSVG
|
||||
*/
|
||||
const tryParseSVGContents = (contents) => {
|
||||
const doc = domParser.parseFromString(contents, 'image/svg+xml')
|
||||
const parserError = doc.querySelector('parsererror')
|
||||
const svg = doc.querySelector('svg')
|
||||
|
||||
if (!parserError && svg) {
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg.outerHTML)}`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/* eslint-disable jsdoc/require-example */
|
||||
|
||||
/**
|
||||
* @summary SVG element that takes both filepaths and file contents
|
||||
* @type {Object}
|
||||
* @public
|
||||
*/
|
||||
class SVGIcon extends react.Component {
|
||||
/**
|
||||
* @summary Render the SVG
|
||||
* @returns {react.Element}
|
||||
*/
|
||||
render () {
|
||||
// __dirname behaves strangely inside a Webpack bundle,
|
||||
// so we need to provide different base directories
|
||||
// depending on whether __dirname is absolute or not,
|
||||
// which helps detecting a Webpack bundle.
|
||||
// We use global.__dirname inside a Webpack bundle since
|
||||
// that's the only way to get the "real" __dirname.
|
||||
const baseDirectory = path.isAbsolute(__dirname)
|
||||
? path.join(__dirname, '..')
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
: global.__dirname
|
||||
|
||||
let svgData = ''
|
||||
|
||||
_.find(this.props.contents, (content) => {
|
||||
const attempt = tryParseSVGContents(content)
|
||||
|
||||
if (attempt) {
|
||||
svgData = attempt
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
if (!svgData) {
|
||||
_.find(this.props.paths, (relativePath) => {
|
||||
// This means the path to the icon should be
|
||||
// relative to *this directory*.
|
||||
// TODO: There might be a way to compute the path
|
||||
// relatively to the `index.html`.
|
||||
const imagePath = path.join(baseDirectory, 'assets', relativePath)
|
||||
|
||||
const contents = _.attempt(() => {
|
||||
return fs.readFileSync(imagePath, {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
})
|
||||
|
||||
if (_.isError(contents)) {
|
||||
analytics.logException(contents)
|
||||
return false
|
||||
}
|
||||
|
||||
const parsed = _.attempt(tryParseSVGContents, contents)
|
||||
|
||||
if (parsed) {
|
||||
svgData = parsed
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
const width = this.props.width || DEFAULT_SIZE
|
||||
const height = this.props.height || DEFAULT_SIZE
|
||||
|
||||
return react.createElement('img', {
|
||||
className: 'svg-icon',
|
||||
style: {
|
||||
width,
|
||||
height
|
||||
},
|
||||
src: svgData,
|
||||
disabled: this.props.disabled
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
SVGIcon.propTypes = {
|
||||
|
||||
/**
|
||||
* @summary Paths to SVG files to be tried in succession if any fails
|
||||
*/
|
||||
paths: propTypes.array,
|
||||
|
||||
/**
|
||||
* @summary List of embedded SVG contents to be tried in succession if any fails
|
||||
*/
|
||||
contents: propTypes.array,
|
||||
|
||||
/**
|
||||
* @summary SVG image width unit
|
||||
*/
|
||||
width: propTypes.string,
|
||||
|
||||
/**
|
||||
* @summary SVG image height unit
|
||||
*/
|
||||
height: propTypes.string,
|
||||
|
||||
/**
|
||||
* @summary Should the element visually appear grayed out and disabled?
|
||||
*/
|
||||
disabled: propTypes.bool
|
||||
|
||||
}
|
||||
|
||||
module.exports = SVGIcon
|
||||
74
lib/gui/app/components/svg-icon/svg-icon.tsx
Normal file
74
lib/gui/app/components/svg-icon/svg-icon.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2018 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
const domParser = new window.DOMParser();
|
||||
|
||||
const DEFAULT_SIZE = '40px';
|
||||
|
||||
/**
|
||||
* @summary Try to parse SVG contents and return it data encoded
|
||||
*
|
||||
*/
|
||||
function tryParseSVGContents(contents?: string): string | undefined {
|
||||
if (contents === undefined) {
|
||||
return;
|
||||
}
|
||||
const doc = domParser.parseFromString(contents, 'image/svg+xml');
|
||||
const parserError = doc.querySelector('parsererror');
|
||||
const svg = doc.querySelector('svg');
|
||||
if (!parserError && svg) {
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg.outerHTML)}`;
|
||||
}
|
||||
}
|
||||
|
||||
interface SVGIconProps {
|
||||
// Optional string representing the SVG contents to be tried
|
||||
contents?: string;
|
||||
// Fallback SVG element to show if `contents` is invalid/undefined
|
||||
fallback: React.FunctionComponent<React.SVGProps<HTMLOrSVGElement>>;
|
||||
// SVG image width unit
|
||||
width?: string;
|
||||
// SVG image height unit
|
||||
height?: string;
|
||||
// Should the element visually appear grayed out and disabled?
|
||||
disabled?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary SVG element that takes file contents
|
||||
*/
|
||||
export class SVGIcon extends React.PureComponent<SVGIconProps> {
|
||||
public render() {
|
||||
const svgData = tryParseSVGContents(this.props.contents);
|
||||
const { width, height, style = {} } = this.props;
|
||||
style.width = width || DEFAULT_SIZE;
|
||||
style.height = height || DEFAULT_SIZE;
|
||||
if (svgData !== undefined) {
|
||||
return (
|
||||
<img
|
||||
className={this.props.disabled ? 'disabled' : ''}
|
||||
style={style}
|
||||
src={svgData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const { fallback: FallbackSVG } = this.props;
|
||||
return <FallbackSVG style={style} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Copyright 2019 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg';
|
||||
import * as React from 'react';
|
||||
import { Flex, FlexProps, Txt } from 'rendition';
|
||||
|
||||
import {
|
||||
getDriveImageCompatibilityStatuses,
|
||||
DriveStatus,
|
||||
} from '../../../../shared/drive-constraints';
|
||||
import { compatibility, warning } from '../../../../shared/messages';
|
||||
import * as prettyBytes from 'pretty-bytes';
|
||||
import { getImage, getSelectedDrives } from '../../models/selection-state';
|
||||
import {
|
||||
ChangeButton,
|
||||
DetailsText,
|
||||
StepButton,
|
||||
StepNameButton,
|
||||
} from '../../styled-components';
|
||||
import { middleEllipsis } from '../../utils/middle-ellipsis';
|
||||
import * as i18next from 'i18next';
|
||||
|
||||
interface TargetSelectorProps {
|
||||
targets: any[];
|
||||
disabled: boolean;
|
||||
openDriveSelector: () => void;
|
||||
reselectDrive: () => void;
|
||||
flashing: boolean;
|
||||
show: boolean;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
function getDriveWarning(status: DriveStatus) {
|
||||
switch (status.message) {
|
||||
case compatibility.containsImage():
|
||||
return warning.sourceDrive();
|
||||
case compatibility.largeDrive():
|
||||
return warning.largeDriveSize();
|
||||
case compatibility.system():
|
||||
return warning.systemDrive();
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const DriveCompatibilityWarning = ({
|
||||
warnings,
|
||||
...props
|
||||
}: {
|
||||
warnings: string[];
|
||||
} & FlexProps) => {
|
||||
const systemDrive = warnings.find(
|
||||
(message) => message === warning.systemDrive(),
|
||||
);
|
||||
return (
|
||||
<Flex tooltip={warnings.join(', ')} {...props}>
|
||||
<ExclamationTriangleSvg
|
||||
fill={systemDrive ? '#fca321' : '#8f9297'}
|
||||
height="1em"
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export function TargetSelectorButton(props: TargetSelectorProps) {
|
||||
const targets = getSelectedDrives();
|
||||
|
||||
if (targets.length === 1) {
|
||||
const target = targets[0];
|
||||
const warnings = getDriveImageCompatibilityStatuses(
|
||||
target,
|
||||
getImage(),
|
||||
true,
|
||||
).map(getDriveWarning);
|
||||
return (
|
||||
<>
|
||||
<StepNameButton plain tooltip={props.tooltip}>
|
||||
{warnings.length > 0 && (
|
||||
<DriveCompatibilityWarning warnings={warnings} mr={2} />
|
||||
)}
|
||||
{middleEllipsis(target.description, 20)}
|
||||
</StepNameButton>
|
||||
{!props.flashing && (
|
||||
<ChangeButton plain mb={14} onClick={props.reselectDrive}>
|
||||
{i18next.t('target.change')}
|
||||
</ChangeButton>
|
||||
)}
|
||||
{target.size != null && (
|
||||
<DetailsText>{prettyBytes(target.size)}</DetailsText>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (targets.length > 1) {
|
||||
const targetsTemplate = [];
|
||||
for (const target of targets) {
|
||||
const warnings = getDriveImageCompatibilityStatuses(
|
||||
target,
|
||||
getImage(),
|
||||
true,
|
||||
).map(getDriveWarning);
|
||||
targetsTemplate.push(
|
||||
<DetailsText
|
||||
key={target.device}
|
||||
tooltip={`${target.description} ${target.displayName} ${
|
||||
target.size != null ? prettyBytes(target.size) : ''
|
||||
}`}
|
||||
px={21}
|
||||
>
|
||||
{warnings.length > 0 ? (
|
||||
<DriveCompatibilityWarning warnings={warnings} mr={2} />
|
||||
) : null}
|
||||
<Txt mr={2}>{middleEllipsis(target.description, 14)}</Txt>
|
||||
{target.size != null && <Txt>{prettyBytes(target.size)}</Txt>}
|
||||
</DetailsText>,
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<StepNameButton plain tooltip={props.tooltip}>
|
||||
{targets.length} {i18next.t('target.targets')}
|
||||
</StepNameButton>
|
||||
{!props.flashing && (
|
||||
<ChangeButton plain onClick={props.reselectDrive} mb={14}>
|
||||
{i18next.t('target.change')}
|
||||
</ChangeButton>
|
||||
)}
|
||||
{targetsTemplate}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StepButton
|
||||
primary
|
||||
tabIndex={targets.length > 0 ? -1 : 2}
|
||||
disabled={props.disabled}
|
||||
onClick={props.openDriveSelector}
|
||||
>
|
||||
{i18next.t('target.selectTarget')}
|
||||
</StepButton>
|
||||
);
|
||||
}
|
||||
194
lib/gui/app/components/target-selector/target-selector.tsx
Normal file
194
lib/gui/app/components/target-selector/target-selector.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import { Flex, Txt } from 'rendition';
|
||||
|
||||
import {
|
||||
DriveSelector,
|
||||
DriveSelectorProps,
|
||||
} from '../drive-selector/drive-selector';
|
||||
import {
|
||||
isDriveSelected,
|
||||
getImage,
|
||||
getSelectedDrives,
|
||||
deselectDrive,
|
||||
selectDrive,
|
||||
deselectAllDrives,
|
||||
} from '../../models/selection-state';
|
||||
import { observe } from '../../models/store';
|
||||
import * as analytics from '../../modules/analytics';
|
||||
import { TargetSelectorButton } from './target-selector-button';
|
||||
|
||||
import TgtSvg from '../../../assets/tgt.svg';
|
||||
import DriveSvg from '../../../assets/drive.svg';
|
||||
import { warning } from '../../../../shared/messages';
|
||||
import { DrivelistDrive } from '../../../../shared/drive-constraints';
|
||||
import * as i18next from 'i18next';
|
||||
|
||||
export const getDriveListLabel = () => {
|
||||
return getSelectedDrives()
|
||||
.map((drive: any) => {
|
||||
return `${drive.description} (${drive.displayName})`;
|
||||
})
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
const getDriveSelectionStateSlice = () => ({
|
||||
driveListLabel: getDriveListLabel(),
|
||||
targets: getSelectedDrives(),
|
||||
image: getImage(),
|
||||
});
|
||||
|
||||
export const TargetSelectorModal = (
|
||||
props: Omit<
|
||||
DriveSelectorProps,
|
||||
'titleLabel' | 'emptyListLabel' | 'multipleSelection' | 'emptyListIcon'
|
||||
>,
|
||||
) => (
|
||||
<DriveSelector
|
||||
multipleSelection={true}
|
||||
titleLabel={i18next.t('target.selectTarget')}
|
||||
emptyListLabel={i18next.t('target.plugTarget')}
|
||||
emptyListIcon={<TgtSvg width="40px" />}
|
||||
showWarnings={true}
|
||||
selectedList={getSelectedDrives()}
|
||||
updateSelectedList={getSelectedDrives}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const selectAllTargets = (modalTargets: DrivelistDrive[]) => {
|
||||
const selectedDrivesFromState = getSelectedDrives();
|
||||
const deselected = selectedDrivesFromState.filter(
|
||||
(drive) =>
|
||||
!modalTargets.find((modalTarget) => modalTarget.device === drive.device),
|
||||
);
|
||||
// deselect drives
|
||||
deselected.forEach((drive) => {
|
||||
analytics.logEvent('Toggle drive', {
|
||||
drive,
|
||||
previouslySelected: true,
|
||||
});
|
||||
deselectDrive(drive.device);
|
||||
});
|
||||
// select drives
|
||||
modalTargets.forEach((drive) => {
|
||||
// Don't send events for drives that were already selected
|
||||
if (!isDriveSelected(drive.device)) {
|
||||
analytics.logEvent('Toggle drive', {
|
||||
drive,
|
||||
previouslySelected: false,
|
||||
});
|
||||
}
|
||||
selectDrive(drive.device);
|
||||
});
|
||||
};
|
||||
|
||||
interface TargetSelectorProps {
|
||||
disabled: boolean;
|
||||
hasDrive: boolean;
|
||||
flashing: boolean;
|
||||
}
|
||||
|
||||
export const TargetSelector = ({
|
||||
disabled,
|
||||
hasDrive,
|
||||
flashing,
|
||||
}: TargetSelectorProps) => {
|
||||
// TODO: inject these from redux-connector
|
||||
const [{ driveListLabel, targets }, setStateSlice] = React.useState(
|
||||
getDriveSelectionStateSlice(),
|
||||
);
|
||||
const [showTargetSelectorModal, setShowTargetSelectorModal] =
|
||||
React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
return observe(() => {
|
||||
setStateSlice(getDriveSelectionStateSlice());
|
||||
});
|
||||
}, []);
|
||||
|
||||
const hasSystemDrives = targets.some((target) => target.isSystem);
|
||||
return (
|
||||
<Flex flexDirection="column" alignItems="center">
|
||||
<DriveSvg
|
||||
className={disabled ? 'disabled' : ''}
|
||||
width="40px"
|
||||
style={{
|
||||
marginBottom: 30,
|
||||
}}
|
||||
/>
|
||||
|
||||
<TargetSelectorButton
|
||||
disabled={disabled}
|
||||
show={!hasDrive}
|
||||
tooltip={driveListLabel}
|
||||
openDriveSelector={() => {
|
||||
setShowTargetSelectorModal(true);
|
||||
}}
|
||||
reselectDrive={() => {
|
||||
analytics.logEvent('Reselect drive');
|
||||
setShowTargetSelectorModal(true);
|
||||
}}
|
||||
flashing={flashing}
|
||||
targets={targets}
|
||||
/>
|
||||
|
||||
{hasSystemDrives ? (
|
||||
<Txt
|
||||
color="#fca321"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '25px',
|
||||
}}
|
||||
>
|
||||
Warning: {warning.systemDrive()}
|
||||
</Txt>
|
||||
) : null}
|
||||
|
||||
{showTargetSelectorModal && (
|
||||
<TargetSelectorModal
|
||||
write={true}
|
||||
cancel={(originalList) => {
|
||||
if (originalList.length) {
|
||||
selectAllTargets(originalList);
|
||||
} else {
|
||||
deselectAllDrives();
|
||||
}
|
||||
setShowTargetSelectorModal(false);
|
||||
}}
|
||||
done={(modalTargets) => {
|
||||
if (modalTargets.length === 0) {
|
||||
deselectAllDrives();
|
||||
}
|
||||
setShowTargetSelectorModal(false);
|
||||
}}
|
||||
onSelect={(drive) => {
|
||||
if (
|
||||
getSelectedDrives().find(
|
||||
(selectedDrive) => selectedDrive.device === drive.device,
|
||||
)
|
||||
) {
|
||||
return deselectDrive(drive.device);
|
||||
}
|
||||
selectDrive(drive.device);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
BIN
lib/gui/app/css/fonts/SourceSansPro-Regular.ttf
Normal file
BIN
lib/gui/app/css/fonts/SourceSansPro-Regular.ttf
Normal file
Binary file not shown.
BIN
lib/gui/app/css/fonts/SourceSansPro-SemiBold.ttf
Normal file
BIN
lib/gui/app/css/fonts/SourceSansPro-SemiBold.ttf
Normal file
Binary file not shown.
@@ -14,40 +14,36 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* Prevent text selection */
|
||||
body {
|
||||
-webkit-user-select: none;
|
||||
@font-face {
|
||||
font-family: "SourceSansPro";
|
||||
src: url("./fonts/SourceSansPro-Regular.ttf") format("truetype");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
|
||||
/* Allow window to be dragged from anywhere */
|
||||
#app-header {
|
||||
-webkit-app-region: drag;
|
||||
@font-face {
|
||||
font-family: "SourceSansPro";
|
||||
src: url("./fonts/SourceSansPro-SemiBold.ttf") format("truetype");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
button,
|
||||
a,
|
||||
input {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* Prevent WebView bounce effect in OS X */
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
|
||||
/* Prevent white flash when running application */
|
||||
background-color: #4d5057;
|
||||
|
||||
/* Prevent WebView bounce effect in OS X */
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Prevent text selection */
|
||||
body {
|
||||
overflow: hidden;
|
||||
-webkit-user-select: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
@@ -55,11 +51,16 @@ body {
|
||||
a:focus,
|
||||
input:focus,
|
||||
button:focus,
|
||||
[tabindex]:focus {
|
||||
[tabindex]:focus,
|
||||
input[type="checkbox"] + div {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Titles don't have margins on desktop apps */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 0;
|
||||
.disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
#rendition-tooltip-root > div {
|
||||
font-family: "SourceSansPro", sans-serif;
|
||||
}
|
||||
42
lib/gui/app/i18n.ts
Normal file
42
lib/gui/app/i18n.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as i18next from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import zh_CN_translation from './i18n/zh-CN';
|
||||
import zh_TW_translation from './i18n/zh-TW';
|
||||
import en_translation from './i18n/en';
|
||||
|
||||
export function langParser() {
|
||||
if (process.env.LANG !== undefined) {
|
||||
// Bypass mocha, where lang-detect don't works
|
||||
return 'en';
|
||||
}
|
||||
|
||||
const lang = Intl.DateTimeFormat().resolvedOptions().locale;
|
||||
|
||||
switch (lang.substr(0, 2)) {
|
||||
case 'zh':
|
||||
if (lang === 'zh-CN' || lang === 'zh-SG') {
|
||||
return 'zh-CN';
|
||||
} // Simplified Chinese
|
||||
else {
|
||||
return 'zh-TW';
|
||||
} // Traditional Chinese
|
||||
default:
|
||||
return lang.substr(0, 2);
|
||||
}
|
||||
}
|
||||
|
||||
i18next.use(initReactI18next).init({
|
||||
lng: langParser(),
|
||||
fallbackLng: 'en',
|
||||
nonExplicitSupportedLngs: true,
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
resources: {
|
||||
'zh-CN': zh_CN_translation,
|
||||
'zh-TW': zh_TW_translation,
|
||||
en: en_translation,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18next;
|
||||
23
lib/gui/app/i18n/README.md
Normal file
23
lib/gui/app/i18n/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# i18n
|
||||
|
||||
## How it was done
|
||||
|
||||
Using the open-source lib [i18next](https://www.i18next.com/).
|
||||
|
||||
## How to add your own language
|
||||
|
||||
1. Go to `lib/gui/app/i18n` and add a file named `xx.ts` (use the codes mentioned
|
||||
in [the link](https://www.science.co.il/language/Locale-codes.php), and we support styles as `fr`, `de`, `es-ES`
|
||||
and `pt-BR`)
|
||||
.
|
||||
2. Copy the content from an existing translation and start to translate.
|
||||
3. Once done, go to `lib/gui/app/i18n.ts` and add a line of `import xx_translation from './i18n/xx'` after the
|
||||
already-added imports and add `xx: xx_translation` in the `resources` section of `i18next.init()` function.
|
||||
4. Now go to `lib/shared/catalina-sudo/` and copy the `sudo-askpass.osascript-en.js`, change it to
|
||||
be `sudo-askpass.osascript-xx.js` and edit
|
||||
the `'balenaEtcher needs privileged access in order to flash disks.\n\nType your password to allow this.'` line and
|
||||
those `Ok`s and `Cancel`s to your own language.
|
||||
5. If, your language has several variations when they are used in several countries/regions, such as `zh-CN` and `zh-TW`
|
||||
, or `pt-BR` and `pt-PT`, edit
|
||||
the `langParser()` in the `lib/gui/app/i18n.ts` file to meet your need.
|
||||
6. Make a commit, and then a pull request on GitHub.
|
||||
161
lib/gui/app/i18n/en.ts
Normal file
161
lib/gui/app/i18n/en.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
const translation = {
|
||||
translation: {
|
||||
continue: 'Continue',
|
||||
ok: 'OK',
|
||||
cancel: 'Cancel',
|
||||
skip: 'Skip',
|
||||
sure: "Yes, I'm sure",
|
||||
warning: 'WARNING! ',
|
||||
attention: 'Attention',
|
||||
failed: 'Failed',
|
||||
completed: 'Completed',
|
||||
yesContinue: 'Yes, continue',
|
||||
reallyExit: 'Are you sure you want to close Etcher?',
|
||||
yesExit: 'Yes, quit',
|
||||
progress: {
|
||||
starting: 'Starting...',
|
||||
decompressing: 'Decompressing...',
|
||||
flashing: 'Flashing...',
|
||||
finishing: 'Finishing...',
|
||||
verifying: 'Validating...',
|
||||
failing: 'Failed',
|
||||
},
|
||||
message: {
|
||||
sizeNotRecommended: 'Not recommended',
|
||||
tooSmall: 'Too small',
|
||||
locked: 'Locked',
|
||||
system: 'System drive',
|
||||
containsImage: 'Source drive',
|
||||
largeDrive: 'Large drive',
|
||||
sourceLarger: 'The selected source is {{byte}} larger than this drive.',
|
||||
flashSucceed_one: 'Successful target',
|
||||
flashSucceed_other: 'Successful targets',
|
||||
flashFail_one: 'Failed target',
|
||||
flashFail_other: 'Failed targets',
|
||||
toDrive: 'to {{description}} ({{name}})',
|
||||
toTarget_one: 'to {{num}} target',
|
||||
toTarget_other: 'to {{num}} targets',
|
||||
andFailTarget_one: 'and failed to be flashed to {{num}} target',
|
||||
andFailTarget_other: 'and failed to be flashed to {{num}} targets',
|
||||
succeedTo: '{{name}} was successfully flashed {{target}}',
|
||||
exitWhileFlashing:
|
||||
'You are currently flashing a drive. Closing Etcher may leave your drive in an unusable state.',
|
||||
looksLikeWindowsImage:
|
||||
'It looks like you are trying to burn a Windows image.\n\nUnlike other images, Windows images require special processing to be made bootable. We suggest you use a tool specially designed for this purpose, such as <a href="https://rufus.akeo.ie">Rufus</a> (Windows), <a href="https://github.com/slacka/WoeUSB">WoeUSB</a> (Linux), or Boot Camp Assistant (macOS).',
|
||||
image: 'image',
|
||||
drive: 'drive',
|
||||
missingPartitionTable:
|
||||
'It looks like this is not a bootable {{type}}.\n\nThe {{type}} does not appear to contain a partition table, and might not be recognized or bootable by your device.',
|
||||
largeDriveSize:
|
||||
"This is a large drive! Make sure it doesn't contain files that you want to keep.",
|
||||
systemDrive:
|
||||
'Selecting your system drive is dangerous and will erase your drive!',
|
||||
sourceDrive: 'Contains the image you chose to flash',
|
||||
noSpace:
|
||||
'Not enough space on the drive. Please insert larger one and try again.',
|
||||
genericFlashError:
|
||||
'Something went wrong. If it is a compressed image, please check that the archive is not corrupted.\n{{error}}',
|
||||
validation:
|
||||
'The write has been completed successfully but Etcher detected potential corruption issues when reading the image back from the drive. \n\nPlease consider writing the image to a different drive.',
|
||||
openError:
|
||||
'Something went wrong while opening {{source}}.\n\nError: {{error}}',
|
||||
flashError: 'Something went wrong while writing {{image}} {{targets}}.',
|
||||
unplug:
|
||||
"Looks like Etcher lost access to the drive. Did it get unplugged accidentally?\n\nSometimes this error is caused by faulty readers that don't provide stable access to the drive.",
|
||||
cannotWrite:
|
||||
'Looks like Etcher is not able to write to this location of the drive. This error is usually caused by a faulty drive, reader, or port. \n\nPlease try again with another drive, reader, or port.',
|
||||
childWriterDied:
|
||||
'The writer process ended unexpectedly. Please try again, and contact the Etcher team if the problem persists.',
|
||||
badProtocol: 'Only http:// and https:// URLs are supported.',
|
||||
},
|
||||
target: {
|
||||
selectTarget: 'Select target',
|
||||
plugTarget: 'Plug a target drive',
|
||||
targets: 'Targets',
|
||||
change: 'Change',
|
||||
},
|
||||
source: {
|
||||
useSourceURL: 'Use Image URL',
|
||||
auth: 'Authentication',
|
||||
username: 'Enter username',
|
||||
password: 'Enter password',
|
||||
unsupportedProtocol: 'Unsupported protocol',
|
||||
windowsImage: 'Possible Windows image detected',
|
||||
partitionTable: 'Missing partition table',
|
||||
errorOpen: 'Error opening source',
|
||||
fromFile: 'Flash from file',
|
||||
fromURL: 'Flash from URL',
|
||||
clone: 'Clone drive',
|
||||
image: 'Image',
|
||||
name: 'Name: ',
|
||||
path: 'Path: ',
|
||||
selectSource: 'Select source',
|
||||
plugSource: 'Plug a source drive',
|
||||
osImages: 'OS Images',
|
||||
allFiles: 'All',
|
||||
enterValidURL: 'Enter a valid URL',
|
||||
},
|
||||
drives: {
|
||||
name: 'Name',
|
||||
size: 'Size',
|
||||
location: 'Location',
|
||||
find: '{{length}} found',
|
||||
select: 'Select {{select}}',
|
||||
showHidden: 'Show {{num}} hidden',
|
||||
systemDriveDanger:
|
||||
'Selecting your system drive is dangerous and will erase your drive!',
|
||||
openInBrowser: '`Etcher will open {{link}} in your browser`',
|
||||
changeTarget: 'Change target',
|
||||
largeDriveWarning: 'You are about to erase an unusually large drive',
|
||||
largeDriveWarningMsg:
|
||||
'Are you sure the selected drive is not a storage drive?',
|
||||
systemDriveWarning: "You are about to erase your computer's drives",
|
||||
systemDriveWarningMsg:
|
||||
'Are you sure you want to flash your system drive?',
|
||||
},
|
||||
flash: {
|
||||
another: 'Flash another',
|
||||
target: 'Target',
|
||||
location: 'Location',
|
||||
error: 'Error',
|
||||
flash: 'Flash',
|
||||
flashNow: 'Flash!',
|
||||
skip: 'Validation has been skipped',
|
||||
moreInfo: 'more info',
|
||||
speedTip:
|
||||
'The speed is calculated by dividing the image size by the flashing time.\nDisk images with ext partitions flash faster as we are able to skip unused parts.',
|
||||
speed: 'Effective speed: {{speed}} MB/s',
|
||||
speedShort: '{{speed}} MB/s',
|
||||
eta: 'ETA: {{eta}}',
|
||||
failedTarget: 'Failed targets',
|
||||
failedRetry: 'Retry failed targets',
|
||||
flashFailed: 'Flash Failed.',
|
||||
flashCompleted: 'Flash Completed!',
|
||||
},
|
||||
settings: {
|
||||
errorReporting:
|
||||
'Anonymously report errors and usage statistics to balena.io',
|
||||
autoUpdate: 'Auto-updates enabled',
|
||||
settings: 'Settings',
|
||||
systemInformation: 'System Information',
|
||||
trimExtPartitions: 'Trim unallocated space on raw images (in ext-type partitions)',
|
||||
},
|
||||
menu: {
|
||||
edit: 'Edit',
|
||||
view: 'View',
|
||||
devTool: 'Toggle Developer Tools',
|
||||
window: 'Window',
|
||||
help: 'Help',
|
||||
pro: 'Etcher Pro',
|
||||
website: 'Etcher Website',
|
||||
issue: 'Report an issue',
|
||||
about: 'About Etcher',
|
||||
hide: 'Hide Etcher',
|
||||
hideOthers: 'Hide Others',
|
||||
unhide: 'Unhide All',
|
||||
quit: 'Quit Etcher',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default translation;
|
||||
152
lib/gui/app/i18n/zh-CN.ts
Normal file
152
lib/gui/app/i18n/zh-CN.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
const translation = {
|
||||
translation: {
|
||||
ok: '好',
|
||||
cancel: '取消',
|
||||
continue: '继续',
|
||||
skip: '跳过',
|
||||
sure: '我确定',
|
||||
warning: '请注意!',
|
||||
attention: '请注意',
|
||||
failed: '失败',
|
||||
completed: '完毕',
|
||||
yesExit: '是的,可以退出',
|
||||
reallyExit: '真的要现在退出 Etcher 吗?',
|
||||
yesContinue: '是的,继续',
|
||||
progress: {
|
||||
starting: '正在启动……',
|
||||
decompressing: '正在解压……',
|
||||
flashing: '正在烧录……',
|
||||
finishing: '正在结束……',
|
||||
verifying: '正在验证……',
|
||||
failing: '失败……',
|
||||
},
|
||||
message: {
|
||||
sizeNotRecommended: '大小不推荐',
|
||||
tooSmall: '空间太小',
|
||||
locked: '被锁定',
|
||||
system: '系统盘',
|
||||
containsImage: '存放源镜像',
|
||||
largeDrive: '很大的磁盘',
|
||||
sourceLarger: '所选的镜像比目标盘大了 {{byte}} 比特。',
|
||||
flashSucceed_one: '烧录成功',
|
||||
flashSucceed_other: '烧录成功',
|
||||
flashFail_one: '烧录失败',
|
||||
flashFail_other: '烧录失败',
|
||||
toDrive: '到 {{description}} ({{name}})',
|
||||
toTarget_one: '到 {{num}} 个目标',
|
||||
toTarget_other: '到 {{num}} 个目标',
|
||||
andFailTarget_one: '并烧录失败了 {{num}} 个目标',
|
||||
andFailTarget_other: '并烧录失败了 {{num}} 个目标',
|
||||
succeedTo: '{{name}} 被成功烧录 {{target}}',
|
||||
exitWhileFlashing:
|
||||
'您当前正在刷机。 关闭 Etcher 可能会导致您的磁盘无法使用。',
|
||||
looksLikeWindowsImage:
|
||||
'看起来您正在尝试刻录 Windows 镜像。\n\n与其他镜像不同,Windows 镜像需要特殊处理才能使其可启动。 我们建议您使用专门为此目的设计的工具,例如 <a href="https://rufus.akeo.ie">Rufus</a> (Windows)、<a href="https://github. com/slacka/WoeUSB">WoeUSB</a> (Linux) 或 Boot Camp 助理 (macOS)。',
|
||||
image: '镜像',
|
||||
drive: '磁盘',
|
||||
missingPartitionTable:
|
||||
'看起来这不是一个可启动的{{type}}。\n\n这个{{type}}似乎不包含分区表,因此您的设备可能无法识别或无法正确启动。',
|
||||
largeDriveSize: '这是个很大的磁盘!请检查并确认它不包含对您很重要的信息',
|
||||
systemDrive: '选择系统盘很危险,因为这将会删除你的系统',
|
||||
sourceDrive: '源镜像位于这个分区中',
|
||||
noSpace: '磁盘空间不足。 请插入另一个较大的磁盘并重试。',
|
||||
genericFlashError:
|
||||
'出了点问题。如果源镜像曾被压缩过,请检查它是否已损坏。\n{{error}}',
|
||||
validation:
|
||||
'写入已成功完成,但 Etcher 在从磁盘读取镜像时检测到潜在的损坏问题。 \n\n请考虑将镜像写入其他磁盘。',
|
||||
openError: '打开 {{source}} 时出错。\n\n错误信息: {{error}}',
|
||||
flashError: '烧录 {{image}} {{targets}} 失败。',
|
||||
unplug:
|
||||
'看起来 Etcher 失去了对磁盘的连接。 它是不是被意外拔掉了?\n\n有时这个错误是因为读卡器出了故障。',
|
||||
cannotWrite:
|
||||
'看起来 Etcher 无法写入磁盘的这个位置。 此错误通常是由故障的磁盘、读取器或端口引起的。 \n\n请使用其他磁盘、读卡器或端口重试。',
|
||||
childWriterDied:
|
||||
'写入进程意外崩溃。请再试一次,如果问题仍然存在,请联系 Etcher 团队。',
|
||||
badProtocol: '仅支持 http:// 和 https:// 开头的网址。',
|
||||
},
|
||||
target: {
|
||||
selectTarget: '选择目标磁盘',
|
||||
plugTarget: '请插入目标磁盘',
|
||||
targets: '个目标',
|
||||
change: '更改',
|
||||
},
|
||||
menu: {
|
||||
edit: '编辑',
|
||||
view: '视图',
|
||||
devTool: '打开开发者工具',
|
||||
window: '窗口',
|
||||
help: '帮助',
|
||||
pro: 'Etcher 专业版',
|
||||
website: 'Etcher 的官网',
|
||||
issue: '提交一个 issue',
|
||||
about: '关于 Etcher',
|
||||
hide: '隐藏 Etcher',
|
||||
hideOthers: '隐藏其它窗口',
|
||||
unhide: '取消隐藏',
|
||||
quit: '退出 Etcher',
|
||||
},
|
||||
source: {
|
||||
useSourceURL: '使用镜像网络地址',
|
||||
auth: '验证',
|
||||
username: '输入用户名',
|
||||
password: '输入密码',
|
||||
unsupportedProtocol: '不支持的协议',
|
||||
windowsImage: '这可能是 Windows 系统镜像',
|
||||
partitionTable: '找不到分区表',
|
||||
errorOpen: '打开源镜像时出错',
|
||||
fromFile: '从文件烧录',
|
||||
fromURL: '从在线地址烧录',
|
||||
clone: '克隆磁盘',
|
||||
image: '镜像信息',
|
||||
name: '名称:',
|
||||
path: '路径:',
|
||||
selectSource: '选择源',
|
||||
plugSource: '请插入源磁盘',
|
||||
osImages: '系统镜像格式',
|
||||
allFiles: '任何文件格式',
|
||||
enterValidURL: '请输入一个正确的地址',
|
||||
},
|
||||
drives: {
|
||||
name: '名称',
|
||||
size: '大小',
|
||||
location: '位置',
|
||||
find: '找到 {{length}} 个',
|
||||
select: '选定 {{select}}',
|
||||
showHidden: '显示 {{num}} 个隐藏的磁盘',
|
||||
systemDriveDanger: '选择系统盘很危险,因为这将会删除你的系统!',
|
||||
openInBrowser: 'Etcher 会在浏览器中打开 {{link}}',
|
||||
changeTarget: '改变目标',
|
||||
largeDriveWarning: '您即将擦除一个非常大的磁盘',
|
||||
largeDriveWarningMsg: '您确定所选磁盘不是存储磁盘吗?',
|
||||
systemDriveWarning: '您将要擦除系统盘',
|
||||
systemDriveWarningMsg: '您确定要烧录到系统盘吗?',
|
||||
},
|
||||
flash: {
|
||||
another: '烧录另一目标',
|
||||
target: '目标',
|
||||
location: '位置',
|
||||
error: '错误',
|
||||
flash: '烧录',
|
||||
flashNow: '现在烧录!',
|
||||
skip: '跳过了验证',
|
||||
moreInfo: '更多信息',
|
||||
speedTip:
|
||||
'通过将镜像大小除以烧录时间来计算速度。\n由于我们能够跳过未使用的部分,因此具有EXT分区的磁盘镜像烧录速度更快。',
|
||||
speed: '速度:{{speed}} MB/秒',
|
||||
speedShort: '{{speed}} MB/秒',
|
||||
eta: '预计还需要:{{eta}}',
|
||||
failedTarget: '失败的烧录目标',
|
||||
failedRetry: '重试烧录失败目标',
|
||||
flashFailed: '烧录失败。',
|
||||
flashCompleted: '烧录成功!',
|
||||
},
|
||||
settings: {
|
||||
errorReporting: '匿名地向 balena.io 报告运行错误和使用统计',
|
||||
autoUpdate: '自动更新',
|
||||
settings: '软件设置',
|
||||
systemInformation: '系统信息',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default translation;
|
||||
152
lib/gui/app/i18n/zh-TW.ts
Normal file
152
lib/gui/app/i18n/zh-TW.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
const translation = {
|
||||
translation: {
|
||||
ok: '好',
|
||||
cancel: '取消',
|
||||
continue: '繼續',
|
||||
skip: '跳過',
|
||||
sure: '我確定',
|
||||
warning: '請注意!',
|
||||
attention: '請注意',
|
||||
failed: '失敗',
|
||||
completed: '完畢',
|
||||
yesExit: '是的,可以退出',
|
||||
reallyExit: '真的要現在退出 Etcher 嗎?',
|
||||
yesContinue: '是的,繼續',
|
||||
progress: {
|
||||
starting: '正在啓動……',
|
||||
decompressing: '正在解壓……',
|
||||
flashing: '正在燒錄……',
|
||||
finishing: '正在結束……',
|
||||
verifying: '正在驗證……',
|
||||
failing: '失敗……',
|
||||
},
|
||||
message: {
|
||||
sizeNotRecommended: '大小不推薦',
|
||||
tooSmall: '空間太小',
|
||||
locked: '被鎖定',
|
||||
system: '系統盤',
|
||||
containsImage: '存放源鏡像',
|
||||
largeDrive: '很大的磁盤',
|
||||
sourceLarger: '所選的鏡像比目標盤大了 {{byte}} 比特。',
|
||||
flashSucceed_one: '燒錄成功',
|
||||
flashSucceed_other: '燒錄成功',
|
||||
flashFail_one: '燒錄失敗',
|
||||
flashFail_other: '燒錄失敗',
|
||||
toDrive: '到 {{description}} ({{name}})',
|
||||
toTarget_one: '到 {{num}} 個目標',
|
||||
toTarget_other: '到 {{num}} 個目標',
|
||||
andFailTarget_one: '並燒錄失敗了 {{num}} 個目標',
|
||||
andFailTarget_other: '並燒錄失敗了 {{num}} 個目標',
|
||||
succeedTo: '{{name}} 被成功燒錄 {{target}}',
|
||||
exitWhileFlashing:
|
||||
'您當前正在刷機。 關閉 Etcher 可能會導致您的磁盤無法使用。',
|
||||
looksLikeWindowsImage:
|
||||
'看起來您正在嘗試刻錄 Windows 鏡像。\n\n與其他鏡像不同,Windows 鏡像需要特殊處理才能使其可啓動。 我們建議您使用專門爲此目的設計的工具,例如 <a href="https://rufus.akeo.ie">Rufus</a> (Windows)、<a href="https://github. com/slacka/WoeUSB">WoeUSB</a> (Linux) 或 Boot Camp 助理 (macOS)。',
|
||||
image: '鏡像',
|
||||
drive: '磁盤',
|
||||
missingPartitionTable:
|
||||
'看起來這不是一個可啓動的{{type}}。\n\n這個{{type}}似乎不包含分區表,因此您的設備可能無法識別或無法正確啓動。',
|
||||
largeDriveSize: '這是個很大的磁盤!請檢查並確認它不包含對您很重要的信息',
|
||||
systemDrive: '選擇系統盤很危險,因爲這將會刪除你的系統',
|
||||
sourceDrive: '源鏡像位於這個分區中',
|
||||
noSpace: '磁盤空間不足。 請插入另一個較大的磁盤並重試。',
|
||||
genericFlashError:
|
||||
'出了點問題。如果源鏡像曾被壓縮過,請檢查它是否已損壞。\n{{error}}',
|
||||
validation:
|
||||
'寫入已成功完成,但 Etcher 在從磁盤讀取鏡像時檢測到潛在的損壞問題。 \n\n請考慮將鏡像寫入其他磁盤。',
|
||||
openError: '打開 {{source}} 時出錯。\n\n錯誤信息: {{error}}',
|
||||
flashError: '燒錄 {{image}} {{targets}} 失敗。',
|
||||
unplug:
|
||||
'看起來 Etcher 失去了對磁盤的連接。 它是不是被意外拔掉了?\n\n有時這個錯誤是因爲讀卡器出了故障。',
|
||||
cannotWrite:
|
||||
'看起來 Etcher 無法寫入磁盤的這個位置。 此錯誤通常是由故障的磁盤、讀取器或端口引起的。 \n\n請使用其他磁盤、讀卡器或端口重試。',
|
||||
childWriterDied:
|
||||
'寫入進程意外崩潰。請再試一次,如果問題仍然存在,請聯繫 Etcher 團隊。',
|
||||
badProtocol: '僅支持 http:// 和 https:// 開頭的網址。',
|
||||
},
|
||||
target: {
|
||||
selectTarget: '選擇目標磁盤',
|
||||
plugTarget: '請插入目標磁盤',
|
||||
targets: '個目標',
|
||||
change: '更改',
|
||||
},
|
||||
menu: {
|
||||
edit: '編輯',
|
||||
view: '視圖',
|
||||
devTool: '打開開發者工具',
|
||||
window: '窗口',
|
||||
help: '幫助',
|
||||
pro: 'Etcher 專業版',
|
||||
website: 'Etcher 的官網',
|
||||
issue: '提交一個 issue',
|
||||
about: '關於 Etcher',
|
||||
hide: '隱藏 Etcher',
|
||||
hideOthers: '隱藏其它窗口',
|
||||
unhide: '取消隱藏',
|
||||
quit: '退出 Etcher',
|
||||
},
|
||||
source: {
|
||||
useSourceURL: '使用鏡像網絡地址',
|
||||
auth: '驗證',
|
||||
username: '輸入用戶名',
|
||||
password: '輸入密碼',
|
||||
unsupportedProtocol: '不支持的協議',
|
||||
windowsImage: '這可能是 Windows 系統鏡像',
|
||||
partitionTable: '找不到分區表',
|
||||
errorOpen: '打開源鏡像時出錯',
|
||||
fromFile: '從文件燒錄',
|
||||
fromURL: '從在線地址燒錄',
|
||||
clone: '克隆磁盤',
|
||||
image: '鏡像信息',
|
||||
name: '名稱:',
|
||||
path: '路徑:',
|
||||
selectSource: '選擇源',
|
||||
plugSource: '請插入源磁盤',
|
||||
osImages: '系統鏡像格式',
|
||||
allFiles: '任何文件格式',
|
||||
enterValidURL: '請輸入一個正確的地址',
|
||||
},
|
||||
drives: {
|
||||
name: '名稱',
|
||||
size: '大小',
|
||||
location: '位置',
|
||||
find: '找到 {{length}} 個',
|
||||
select: '選定 {{select}}',
|
||||
showHidden: '顯示 {{num}} 個隱藏的磁盤',
|
||||
systemDriveDanger: '選擇系統盤很危險,因爲這將會刪除你的系統!',
|
||||
openInBrowser: 'Etcher 會在瀏覽器中打開 {{link}}',
|
||||
changeTarget: '改變目標',
|
||||
largeDriveWarning: '您即將擦除一個非常大的磁盤',
|
||||
largeDriveWarningMsg: '您確定所選磁盤不是存儲磁盤嗎?',
|
||||
systemDriveWarning: '您將要擦除系統盤',
|
||||
systemDriveWarningMsg: '您確定要燒錄到系統盤嗎?',
|
||||
},
|
||||
flash: {
|
||||
another: '燒錄另一目標',
|
||||
target: '目標',
|
||||
location: '位置',
|
||||
error: '錯誤',
|
||||
flash: '燒錄',
|
||||
flashNow: '現在燒錄!',
|
||||
skip: '跳過了驗證',
|
||||
moreInfo: '更多信息',
|
||||
speedTip:
|
||||
'通過將鏡像大小除以燒錄時間來計算速度。\n由於我們能夠跳過未使用的部分,因此具有EXT分區的磁盤鏡像燒錄速度更快。',
|
||||
speed: '速度:{{speed}} MB/秒',
|
||||
speedShort: '{{speed}} MB/秒',
|
||||
eta: '預計還需要:{{eta}}',
|
||||
failedTarget: '失敗的燒錄目標',
|
||||
failedRetry: '重試燒錄失敗目標',
|
||||
flashFailed: '燒錄失敗。',
|
||||
flashCompleted: '燒錄成功!',
|
||||
},
|
||||
settings: {
|
||||
errorReporting: '匿名地向 balena.io 報告運行錯誤和使用統計',
|
||||
autoUpdate: '自動更新',
|
||||
settings: '軟件設置',
|
||||
systemInformation: '系統信息',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default translation;
|
||||
12
lib/gui/app/index.dev.html
Normal file
12
lib/gui/app/index.dev.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>balenaEtcher</title>
|
||||
<link rel="stylesheet" type="text/css" href="index.css">
|
||||
</head>
|
||||
<body>
|
||||
<main id="main"></main>
|
||||
<script src="http://localhost:3030/gui.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,25 +2,11 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Etcher</title>
|
||||
<link rel="stylesheet" type="text/css" href="../../../node_modules/flexboxgrid/dist/flexboxgrid.css">
|
||||
<link rel="stylesheet" type="text/css" href="../css/main.css">
|
||||
<link rel="stylesheet" type="text/css" href="../css/desktop.css">
|
||||
<link rel="stylesheet" type="text/css" href="../css/angular.css">
|
||||
<script src="../../../generated/gui.js"></script>
|
||||
<title>balenaEtcher</title>
|
||||
<link rel="stylesheet" type="text/css" href="index.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<main ui-view></main>
|
||||
|
||||
<div class="section-loader"
|
||||
ng-controller="StateController as state"
|
||||
ng-class="{
|
||||
isFinish: state.currentName === 'success'
|
||||
}">
|
||||
<safe-webview src="'https://www.balena.io/etcher/success-banner/'">
|
||||
</safe-webview>
|
||||
</div>
|
||||
|
||||
<main id="main"></main>
|
||||
<script src="gui.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const store = require('./store')
|
||||
|
||||
/**
|
||||
* @summary Check if there are available drives
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {Boolean} whether there are available drives
|
||||
*
|
||||
* @example
|
||||
* if (availableDrives.hasAvailableDrives()) {
|
||||
* console.log('There are available drives!');
|
||||
* }
|
||||
*/
|
||||
exports.hasAvailableDrives = () => {
|
||||
return !_.isEmpty(exports.getDrives())
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Set a list of drives
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @param {Object[]} drives - drives
|
||||
*
|
||||
* @throws Will throw if no drives
|
||||
* @throws Will throw if drives is not an array of objects
|
||||
*
|
||||
* @example
|
||||
* availableDrives.setDrives([ ... ]);
|
||||
*/
|
||||
exports.setDrives = (drives) => {
|
||||
store.dispatch({
|
||||
type: store.Actions.SET_AVAILABLE_DRIVES,
|
||||
data: drives
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get detected drives
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @returns {Object[]} drives
|
||||
*
|
||||
* @example
|
||||
* const drives = availableDrives.getDrives();
|
||||
*/
|
||||
exports.getDrives = () => {
|
||||
return store.getState().toJS().availableDrives
|
||||
}
|
||||
@@ -14,22 +14,20 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
.label {
|
||||
font-size: 9px;
|
||||
margin-right: 4.5px;
|
||||
import { DrivelistDrive } from '../../../shared/drive-constraints';
|
||||
import { Actions, store } from './store';
|
||||
|
||||
export function hasAvailableDrives() {
|
||||
return getDrives().length > 0;
|
||||
}
|
||||
|
||||
.label-big {
|
||||
font-size: 11px;
|
||||
padding: 8px 25px;
|
||||
export function setDrives(drives: any[]) {
|
||||
store.dispatch({
|
||||
type: Actions.SET_AVAILABLE_TARGETS,
|
||||
data: drives,
|
||||
});
|
||||
}
|
||||
|
||||
.label-inset {
|
||||
background-color: darken($palette-theme-dark-background, 10%);
|
||||
color: darken($palette-theme-dark-foreground, 43%);
|
||||
}
|
||||
|
||||
.label-danger {
|
||||
background-color: $palette-theme-danger-background;
|
||||
color: $palette-theme-danger-foreground;
|
||||
export function getDrives(): DrivelistDrive[] {
|
||||
return store.getState().toJS().availableDrives;
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
/*
|
||||
* Copyright 2018 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const Bluebird = require('bluebird')
|
||||
const fs = Bluebird.promisifyAll(require('fs'))
|
||||
const path = require('path')
|
||||
|
||||
const driveScanner = require('../modules/drive-scanner')
|
||||
|
||||
/* eslint-disable lodash/prefer-lodash-method */
|
||||
/* eslint-disable no-undefined */
|
||||
|
||||
const CONCURRENCY = 10
|
||||
|
||||
const collator = new Intl.Collator(undefined, {
|
||||
sensitivity: 'case'
|
||||
})
|
||||
|
||||
/**
|
||||
* @summary Sort files by their names / stats
|
||||
* @param {FileEntry} fileA - first file
|
||||
* @param {FileEntry} fileB - second file
|
||||
* @returns {Number}
|
||||
*
|
||||
* @example
|
||||
* files.readdirAsync(dirname).then((files) => {
|
||||
* return files.sort(sortFiles)
|
||||
* })
|
||||
*/
|
||||
const sortFiles = (fileA, fileB) => {
|
||||
return (fileB.isDirectory - fileA.isDirectory) ||
|
||||
collator.compare(fileA.basename, fileB.basename)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary FileEntry struct
|
||||
* @class
|
||||
* @type {FileEntry}
|
||||
*/
|
||||
class FileEntry {
|
||||
/**
|
||||
* @summary FileEntry
|
||||
* @param {String} filename - filename
|
||||
* @param {fs.Stats} stats - stats
|
||||
*
|
||||
* @example
|
||||
* new FileEntry(filename, stats)
|
||||
*/
|
||||
constructor (filename, stats) {
|
||||
const components = path.parse(filename)
|
||||
|
||||
this.path = filename
|
||||
this.dirname = components.dir
|
||||
this.basename = components.base
|
||||
this.name = components.name
|
||||
this.ext = components.ext
|
||||
this.isHidden = components.name.startsWith('.')
|
||||
this.isFile = stats.isFile()
|
||||
this.isDirectory = stats.isDirectory()
|
||||
this.size = stats.size
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Read a directory & stat all contents
|
||||
* @param {String} dirpath - Directory path
|
||||
* @returns {Array<FileEntry>}
|
||||
*
|
||||
* @example
|
||||
* files.readdirAsync('/').then((files) => {
|
||||
* // ...
|
||||
* })
|
||||
*/
|
||||
exports.readdirAsync = (dirpath) => {
|
||||
console.time('readdirAsync')
|
||||
const dirname = path.resolve(dirpath)
|
||||
return fs.readdirAsync(dirname).then((ls) => {
|
||||
return ls.filter((filename) => {
|
||||
return !filename.startsWith('.')
|
||||
}).map((filename) => {
|
||||
return path.join(dirname, filename)
|
||||
})
|
||||
}).map((filename, index, length) => {
|
||||
return fs.statAsync(filename).then((stats) => {
|
||||
return new FileEntry(filename, stats)
|
||||
})
|
||||
}, { concurrency: CONCURRENCY }).then((files) => {
|
||||
console.timeEnd('readdirAsync')
|
||||
return files.sort(sortFiles)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Split a path on it's separator(s)
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {String} fullpath - full path to split
|
||||
* @param {Array<String>} [subpaths] - this param shouldn't normally be used
|
||||
* @returns {Array<String>}
|
||||
*
|
||||
* @example
|
||||
* console.log(splitPath(path.join(os.homedir(), 'Downloads'))
|
||||
* // Linux
|
||||
* > [ '/', 'home', 'user', 'Downloads' ]
|
||||
* // Windows
|
||||
* > [ 'C:', 'Users', 'user', 'Downloads' ]
|
||||
*/
|
||||
exports.splitPath = (fullpath, subpaths = []) => {
|
||||
const {
|
||||
base,
|
||||
dir,
|
||||
root
|
||||
} = path.parse(fullpath)
|
||||
const isAbsolute = path.isAbsolute(fullpath)
|
||||
|
||||
// Takes care of 'relative/path'
|
||||
if (!isAbsolute && dir === '') {
|
||||
return [ base ].concat(subpaths)
|
||||
|
||||
// Takes care of '/'
|
||||
} else if (isAbsolute && base === '') {
|
||||
return [ root ].concat(subpaths)
|
||||
}
|
||||
|
||||
return exports.splitPath(dir, [ base ].concat(subpaths))
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get constraint path device
|
||||
* @param {String} pathname - device path
|
||||
* @returns {Drive} drive - drive object
|
||||
* @example
|
||||
* const device = files.getConstraintDevice('/dev/disk2')
|
||||
*/
|
||||
exports.getConstraintDevice = (pathname) => {
|
||||
// This supposes the drive scanner is ready
|
||||
return driveScanner.getBy('device', pathname) || driveScanner.getBy('devicePath', pathname)
|
||||
}
|
||||
|
||||
exports.FileEntry = FileEntry
|
||||
@@ -1,244 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const store = require('./store')
|
||||
const units = require('../../../shared/units')
|
||||
|
||||
/**
|
||||
* @summary Reset flash state
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @example
|
||||
* flashState.resetState();
|
||||
*/
|
||||
exports.resetState = () => {
|
||||
store.dispatch({
|
||||
type: store.Actions.RESET_FLASH_STATE
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Check if currently flashing
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @returns {Boolean} whether is flashing or not
|
||||
*
|
||||
* @example
|
||||
* if (flashState.isFlashing()) {
|
||||
* console.log('We\'re currently flashing');
|
||||
* }
|
||||
*/
|
||||
exports.isFlashing = () => {
|
||||
return store.getState().toJS().isFlashing
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Set the flashing flag
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @description
|
||||
* This function is extracted for testing purposes.
|
||||
*
|
||||
* The flag is used to signify that we're going to
|
||||
* start a flash process.
|
||||
*
|
||||
* @example
|
||||
* flashState.setFlashingFlag();
|
||||
*/
|
||||
exports.setFlashingFlag = () => {
|
||||
store.dispatch({
|
||||
type: store.Actions.SET_FLASHING_FLAG
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Unset the flashing flag
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @description
|
||||
* This function is extracted for testing purposes.
|
||||
*
|
||||
* The flag is used to signify that the write process ended.
|
||||
*
|
||||
* @param {Object} results - flash results
|
||||
*
|
||||
* @example
|
||||
* flashState.unsetFlashingFlag({
|
||||
* cancelled: false,
|
||||
* sourceChecksum: 'a1b45d'
|
||||
* });
|
||||
*/
|
||||
exports.unsetFlashingFlag = (results) => {
|
||||
store.dispatch({
|
||||
type: store.Actions.UNSET_FLASHING_FLAG,
|
||||
data: results
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Set the flashing state
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @description
|
||||
* This function is extracted for testing purposes.
|
||||
*
|
||||
* @param {Object} state - flashing state
|
||||
*
|
||||
* @example
|
||||
* flashState.setProgressState({
|
||||
* type: 'write',
|
||||
* percentage: 50,
|
||||
* eta: 15,
|
||||
* speed: 100000000000
|
||||
* });
|
||||
*/
|
||||
exports.setProgressState = (state) => {
|
||||
// Preserve only one decimal place
|
||||
const PRECISION = 1
|
||||
const data = _.assign({}, state, {
|
||||
percentage: _.isFinite(state.percentage)
|
||||
? Math.floor(state.percentage)
|
||||
// eslint-disable-next-line no-undefined
|
||||
: undefined,
|
||||
|
||||
speed: _.attempt(() => {
|
||||
if (_.isFinite(state.speed)) {
|
||||
return _.round(units.bytesToMegabytes(state.speed), PRECISION)
|
||||
}
|
||||
|
||||
return null
|
||||
}),
|
||||
|
||||
totalSpeed: _.attempt(() => {
|
||||
if (_.isFinite(state.totalSpeed)) {
|
||||
return _.round(units.bytesToMegabytes(state.totalSpeed), PRECISION)
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
store.dispatch({
|
||||
type: store.Actions.SET_FLASH_STATE,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get the flash results
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @returns {Object} flash results
|
||||
*
|
||||
* @example
|
||||
* const results = flashState.getFlashResults();
|
||||
*/
|
||||
exports.getFlashResults = () => {
|
||||
return store.getState().toJS().flashResults
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get the current flash state
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {Object} flash state
|
||||
*
|
||||
* @example
|
||||
* const flashState = flashState.getFlashState();
|
||||
*/
|
||||
exports.getFlashState = () => {
|
||||
return store.getState().get('flashState').toJS()
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Determine if the last flash was cancelled
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @description
|
||||
* This function returns false if there was no last flash.
|
||||
*
|
||||
* @returns {Boolean} whether the last flash was cancelled
|
||||
*
|
||||
* @example
|
||||
* if (flashState.wasLastFlashCancelled()) {
|
||||
* console.log('The last flash was cancelled');
|
||||
* }
|
||||
*/
|
||||
exports.wasLastFlashCancelled = () => {
|
||||
return _.get(exports.getFlashResults(), [ 'cancelled' ], false)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get last flash source checksum
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @description
|
||||
* This function returns undefined if there was no last flash.
|
||||
*
|
||||
* @returns {(String|Undefined)} the last flash source checksum
|
||||
*
|
||||
* @example
|
||||
* const checksum = flashState.getLastFlashSourceChecksum();
|
||||
*/
|
||||
exports.getLastFlashSourceChecksum = () => {
|
||||
return exports.getFlashResults().sourceChecksum
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get last flash error code
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @description
|
||||
* This function returns undefined if there was no last flash.
|
||||
*
|
||||
* @returns {(String|Undefined)} the last flash error code
|
||||
*
|
||||
* @example
|
||||
* const errorCode = flashState.getLastFlashErrorCode();
|
||||
*/
|
||||
exports.getLastFlashErrorCode = () => {
|
||||
return exports.getFlashResults().errorCode
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get current (or last) flash uuid
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @description
|
||||
* This function returns undefined if no flash has been started yet.
|
||||
*
|
||||
* @returns {String} the last flash uuid
|
||||
*
|
||||
* @example
|
||||
* const uuid = flashState.getFlashUuid();
|
||||
*/
|
||||
exports.getFlashUuid = () => {
|
||||
return store.getState().toJS().flashUuid
|
||||
}
|
||||
161
lib/gui/app/models/flash-state.ts
Normal file
161
lib/gui/app/models/flash-state.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as electron from 'electron';
|
||||
import * as sdk from 'etcher-sdk';
|
||||
import * as _ from 'lodash';
|
||||
import { DrivelistDrive } from '../../../shared/drive-constraints';
|
||||
import { bytesToMegabytes } from '../../../shared/units';
|
||||
import { Actions, store } from './store';
|
||||
|
||||
/**
|
||||
* @summary Reset flash state
|
||||
*/
|
||||
export function resetState() {
|
||||
store.dispatch({
|
||||
type: Actions.RESET_FLASH_STATE,
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Check if currently flashing
|
||||
*/
|
||||
export function isFlashing(): boolean {
|
||||
return store.getState().toJS().isFlashing;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Set the flashing flag
|
||||
*
|
||||
* @description
|
||||
* The flag is used to signify that we're going to
|
||||
* start a flash process.
|
||||
*/
|
||||
export function setFlashingFlag() {
|
||||
// see https://github.com/balenablocks/balena-electron-env/blob/4fce9c461f294d4a768db8f247eea6f75d7b08b0/README.md#remote-methods
|
||||
electron.ipcRenderer.invoke('disable-screensaver');
|
||||
store.dispatch({
|
||||
type: Actions.SET_FLASHING_FLAG,
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Unset the flashing flag
|
||||
*
|
||||
* @description
|
||||
* The flag is used to signify that the write process ended.
|
||||
*/
|
||||
export function unsetFlashingFlag(results: {
|
||||
cancelled?: boolean;
|
||||
sourceChecksum?: string;
|
||||
errorCode?: string | number;
|
||||
}) {
|
||||
store.dispatch({
|
||||
type: Actions.UNSET_FLASHING_FLAG,
|
||||
data: results,
|
||||
});
|
||||
// see https://github.com/balenablocks/balena-electron-env/blob/4fce9c461f294d4a768db8f247eea6f75d7b08b0/README.md#remote-methods
|
||||
electron.ipcRenderer.invoke('enable-screensaver');
|
||||
}
|
||||
|
||||
export function setDevicePaths(devicePaths: string[]) {
|
||||
store.dispatch({
|
||||
type: Actions.SET_DEVICE_PATHS,
|
||||
data: devicePaths,
|
||||
});
|
||||
}
|
||||
|
||||
export function addFailedDeviceError({
|
||||
device,
|
||||
error,
|
||||
}: {
|
||||
device: DrivelistDrive;
|
||||
error: Error;
|
||||
}) {
|
||||
const failedDeviceErrorsMap = new Map(
|
||||
store.getState().toJS().failedDeviceErrors,
|
||||
);
|
||||
if (failedDeviceErrorsMap.has(device.device)) {
|
||||
// Only store the first error
|
||||
return;
|
||||
}
|
||||
failedDeviceErrorsMap.set(device.device, {
|
||||
description: device.description,
|
||||
device: device.device,
|
||||
devicePath: device.devicePath,
|
||||
...error,
|
||||
});
|
||||
store.dispatch({
|
||||
type: Actions.SET_FAILED_DEVICE_ERRORS,
|
||||
data: Array.from(failedDeviceErrorsMap),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Set the flashing state
|
||||
*/
|
||||
export function setProgressState(
|
||||
state: sdk.multiWrite.MultiDestinationProgress,
|
||||
) {
|
||||
// Preserve only one decimal place
|
||||
const PRECISION = 1;
|
||||
const data = {
|
||||
...state,
|
||||
percentage:
|
||||
state.percentage !== undefined && _.isFinite(state.percentage)
|
||||
? Math.floor(state.percentage)
|
||||
: undefined,
|
||||
|
||||
speed: _.attempt(() => {
|
||||
if (_.isFinite(state.speed)) {
|
||||
return _.round(bytesToMegabytes(state.speed), PRECISION);
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
store.dispatch({
|
||||
type: Actions.SET_FLASH_STATE,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function getFlashResults() {
|
||||
return store.getState().toJS().flashResults;
|
||||
}
|
||||
|
||||
export function getFlashState() {
|
||||
return store.getState().get('flashState').toJS();
|
||||
}
|
||||
|
||||
export function wasLastFlashCancelled() {
|
||||
return _.get(getFlashResults(), ['cancelled'], false);
|
||||
}
|
||||
|
||||
export function getLastFlashSourceChecksum(): string {
|
||||
return getFlashResults().sourceChecksum;
|
||||
}
|
||||
|
||||
export function getLastFlashErrorCode() {
|
||||
return getFlashResults().errorCode;
|
||||
}
|
||||
|
||||
export function getFlashUuid() {
|
||||
return store.getState().toJS().flashUuid;
|
||||
}
|
||||
260
lib/gui/app/models/leds.ts
Normal file
260
lib/gui/app/models/leds.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* Copyright 2020 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as _ from 'lodash';
|
||||
import { Animator, AnimationFunction, Color, RGBLed } from 'sys-class-rgb-led';
|
||||
|
||||
import {
|
||||
DrivelistDrive,
|
||||
isSourceDrive,
|
||||
} from '../../../shared/drive-constraints';
|
||||
import { getDrives } from './available-drives';
|
||||
import { getSelectedDrives } from './selection-state';
|
||||
import * as settings from './settings';
|
||||
import { observe, store } from './store';
|
||||
|
||||
const leds: Map<string, RGBLed> = new Map();
|
||||
const animator = new Animator([], 10);
|
||||
|
||||
function createAnimationFunction(
|
||||
intensityFunction: (t: number) => number,
|
||||
color: Color,
|
||||
): AnimationFunction {
|
||||
return (t: number): Color => {
|
||||
const intensity = intensityFunction(t);
|
||||
return color.map((v: number) => v * intensity) as Color;
|
||||
};
|
||||
}
|
||||
|
||||
function blink(t: number) {
|
||||
return Math.floor(t) % 2;
|
||||
}
|
||||
|
||||
function one(_t: number) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
type LEDColors = {
|
||||
green: Color;
|
||||
purple: Color;
|
||||
red: Color;
|
||||
blue: Color;
|
||||
white: Color;
|
||||
black: Color;
|
||||
};
|
||||
|
||||
type LEDAnimationFunctions = {
|
||||
blinkGreen: AnimationFunction;
|
||||
blinkPurple: AnimationFunction;
|
||||
staticRed: AnimationFunction;
|
||||
staticGreen: AnimationFunction;
|
||||
staticBlue: AnimationFunction;
|
||||
staticWhite: AnimationFunction;
|
||||
staticBlack: AnimationFunction;
|
||||
};
|
||||
|
||||
let ledColors: LEDColors;
|
||||
let ledAnimationFunctions: LEDAnimationFunctions;
|
||||
|
||||
interface LedsState {
|
||||
step: 'main' | 'flashing' | 'verifying' | 'finish';
|
||||
sourceDrive: string | undefined;
|
||||
availableDrives: string[];
|
||||
selectedDrives: string[];
|
||||
failedDrives: string[];
|
||||
}
|
||||
|
||||
function setLeds(animation: AnimationFunction, drivesPaths: Set<string>) {
|
||||
const rgbLeds: RGBLed[] = [];
|
||||
for (const path of drivesPaths) {
|
||||
const led = leds.get(path);
|
||||
if (led) {
|
||||
rgbLeds.push(led);
|
||||
}
|
||||
}
|
||||
return { animation, rgbLeds };
|
||||
}
|
||||
|
||||
// Source slot (1st slot): behaves as a target unless it is chosen as source
|
||||
// No drive: black
|
||||
// Drive plugged: blue - on
|
||||
//
|
||||
// Other slots (2 - 16):
|
||||
//
|
||||
// +----------------+---------------+-----------------------------+----------------------------+---------------------------------+
|
||||
// | | main screen | flashing | validating | results screen |
|
||||
// +----------------+---------------+-----------------------------+----------------------------+---------------------------------+
|
||||
// | no drive | black | black | black | black |
|
||||
// +----------------+---------------+-----------------------------+----------------------------+---------------------------------+
|
||||
// | drive plugged | black | black | black | black |
|
||||
// +----------------+---------------+-----------------------------+----------------------------+---------------------------------+
|
||||
// | drive selected | white | blink purple, red if failed | blink green, red if failed | green if success, red if failed |
|
||||
// +----------------+---------------+-----------------------------+----------------------------+---------------------------------+
|
||||
export function updateLeds({
|
||||
step,
|
||||
sourceDrive,
|
||||
availableDrives,
|
||||
selectedDrives,
|
||||
failedDrives,
|
||||
}: LedsState) {
|
||||
const unplugged = new Set(leds.keys());
|
||||
const plugged = new Set(availableDrives);
|
||||
const selectedOk = new Set(selectedDrives);
|
||||
const selectedFailed = new Set(failedDrives);
|
||||
|
||||
// Remove selected devices from plugged set
|
||||
for (const d of selectedOk) {
|
||||
plugged.delete(d);
|
||||
unplugged.delete(d);
|
||||
}
|
||||
|
||||
// Remove plugged devices from unplugged set
|
||||
for (const d of plugged) {
|
||||
unplugged.delete(d);
|
||||
}
|
||||
|
||||
// Remove failed devices from selected set
|
||||
for (const d of selectedFailed) {
|
||||
selectedOk.delete(d);
|
||||
}
|
||||
|
||||
const mapping: Array<{
|
||||
animation: AnimationFunction;
|
||||
rgbLeds: RGBLed[];
|
||||
}> = [];
|
||||
// Handle source slot
|
||||
if (sourceDrive !== undefined) {
|
||||
if (plugged.has(sourceDrive)) {
|
||||
plugged.delete(sourceDrive);
|
||||
mapping.push(
|
||||
setLeds(ledAnimationFunctions.staticBlue, new Set([sourceDrive])),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (step === 'main') {
|
||||
mapping.push(
|
||||
setLeds(
|
||||
ledAnimationFunctions.staticBlack,
|
||||
new Set([...unplugged, ...plugged]),
|
||||
),
|
||||
setLeds(
|
||||
ledAnimationFunctions.staticWhite,
|
||||
new Set([...selectedOk, ...selectedFailed]),
|
||||
),
|
||||
);
|
||||
} else if (step === 'flashing') {
|
||||
mapping.push(
|
||||
setLeds(
|
||||
ledAnimationFunctions.staticBlack,
|
||||
new Set([...unplugged, ...plugged]),
|
||||
),
|
||||
setLeds(ledAnimationFunctions.blinkPurple, selectedOk),
|
||||
setLeds(ledAnimationFunctions.staticRed, selectedFailed),
|
||||
);
|
||||
} else if (step === 'verifying') {
|
||||
mapping.push(
|
||||
setLeds(
|
||||
ledAnimationFunctions.staticBlack,
|
||||
new Set([...unplugged, ...plugged]),
|
||||
),
|
||||
setLeds(ledAnimationFunctions.blinkGreen, selectedOk),
|
||||
setLeds(ledAnimationFunctions.staticRed, selectedFailed),
|
||||
);
|
||||
} else if (step === 'finish') {
|
||||
mapping.push(
|
||||
setLeds(
|
||||
ledAnimationFunctions.staticBlack,
|
||||
new Set([...unplugged, ...plugged]),
|
||||
),
|
||||
setLeds(ledAnimationFunctions.staticGreen, selectedOk),
|
||||
setLeds(ledAnimationFunctions.staticRed, selectedFailed),
|
||||
);
|
||||
}
|
||||
animator.mapping = mapping;
|
||||
}
|
||||
|
||||
let ledsState: LedsState | undefined;
|
||||
|
||||
function stateObserver() {
|
||||
const s = store.getState().toJS();
|
||||
let step: 'main' | 'flashing' | 'verifying' | 'finish';
|
||||
if (s.isFlashing) {
|
||||
step = s.flashState.type;
|
||||
} else {
|
||||
step = s.lastAverageFlashingSpeed == null ? 'main' : 'finish';
|
||||
}
|
||||
const availableDrives = getDrives().filter(
|
||||
(d: DrivelistDrive) => d.devicePath,
|
||||
);
|
||||
const sourceDrivePath = availableDrives.filter((d: DrivelistDrive) =>
|
||||
isSourceDrive(d, s.selection.image),
|
||||
)[0]?.devicePath;
|
||||
const availableDrivesPaths = availableDrives.map(
|
||||
(d: DrivelistDrive) => d.devicePath,
|
||||
);
|
||||
let selectedDrivesPaths: string[];
|
||||
if (step === 'main') {
|
||||
selectedDrivesPaths = getSelectedDrives()
|
||||
.filter((drive) => drive.devicePath !== null)
|
||||
.map((drive) => drive.devicePath) as string[];
|
||||
} else {
|
||||
selectedDrivesPaths = s.devicePaths;
|
||||
}
|
||||
const failedDevicePaths = s.failedDeviceErrors.map(
|
||||
([, { devicePath }]: [string, { devicePath: string }]) => devicePath,
|
||||
);
|
||||
const newLedsState = {
|
||||
step,
|
||||
sourceDrive: sourceDrivePath,
|
||||
availableDrives: availableDrivesPaths,
|
||||
selectedDrives: selectedDrivesPaths,
|
||||
failedDrives: failedDevicePaths,
|
||||
} as LedsState;
|
||||
if (!_.isEqual(newLedsState, ledsState)) {
|
||||
updateLeds(newLedsState);
|
||||
ledsState = newLedsState;
|
||||
}
|
||||
}
|
||||
|
||||
export async function init(): Promise<void> {
|
||||
// ledsMapping is something like:
|
||||
// {
|
||||
// 'platform-xhci-hcd.0.auto-usb-0:1.1.1:1.0-scsi-0:0:0:0': [
|
||||
// 'led1_r',
|
||||
// 'led1_g',
|
||||
// 'led1_b',
|
||||
// ],
|
||||
// ...
|
||||
// }
|
||||
const ledsMapping: _.Dictionary<[string, string, string]> =
|
||||
(await settings.get('ledsMapping')) || {};
|
||||
if (!_.isEmpty(ledsMapping)) {
|
||||
for (const [drivePath, ledsNames] of Object.entries(ledsMapping)) {
|
||||
leds.set('/dev/disk/by-path/' + drivePath, new RGBLed(ledsNames));
|
||||
}
|
||||
ledColors = (await settings.get('ledColors')) || {};
|
||||
ledAnimationFunctions = {
|
||||
blinkGreen: createAnimationFunction(blink, ledColors['green']),
|
||||
blinkPurple: createAnimationFunction(blink, ledColors['purple']),
|
||||
staticRed: createAnimationFunction(one, ledColors['red']),
|
||||
staticGreen: createAnimationFunction(one, ledColors['green']),
|
||||
staticBlue: createAnimationFunction(one, ledColors['blue']),
|
||||
staticWhite: createAnimationFunction(one, ledColors['white']),
|
||||
staticBlack: createAnimationFunction(one, ledColors['black']),
|
||||
};
|
||||
observe(_.debounce(stateObserver, 1000, { maxWait: 1000 }));
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
/*
|
||||
* Copyright 2017 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const Bluebird = require('bluebird')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
/**
|
||||
* @summary Number of spaces to indent JSON output with
|
||||
* @type {Number}
|
||||
* @constant
|
||||
*/
|
||||
const JSON_INDENT = 2
|
||||
|
||||
/**
|
||||
* @summary Userdata directory path
|
||||
* @description
|
||||
* Defaults to the following:
|
||||
* - `%APPDATA%/etcher` on Windows
|
||||
* - `$XDG_CONFIG_HOME/etcher` or `~/.config/etcher` on Linux
|
||||
* - `~/Library/Application Support/etcher` on macOS
|
||||
* See https://electronjs.org/docs/api/app#appgetpathname
|
||||
* @constant
|
||||
* @type {String}
|
||||
*/
|
||||
const USER_DATA_DIR = (() => {
|
||||
// NOTE: The ternary is due to this module being loaded both,
|
||||
// Electron's main process and renderer process
|
||||
const electron = require('electron')
|
||||
return electron.app
|
||||
? electron.app.getPath('userData')
|
||||
: electron.remote.app.getPath('userData')
|
||||
})()
|
||||
|
||||
/**
|
||||
* @summary Configuration file path
|
||||
* @type {String}
|
||||
* @constant
|
||||
*/
|
||||
const CONFIG_PATH = path.join(USER_DATA_DIR, 'config.json')
|
||||
|
||||
/**
|
||||
* @summary Read a local config.json file
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @param {String} filename - file path
|
||||
* @fulfil {Object} - settings
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @example
|
||||
* readConfigFile('config.json').then((settings) => {
|
||||
* console.log(settings)
|
||||
* })
|
||||
*/
|
||||
const readConfigFile = (filename) => {
|
||||
return new Bluebird((resolve, reject) => {
|
||||
fs.readFile(filename, { encoding: 'utf8' }, (error, contents) => {
|
||||
let data = {}
|
||||
if (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
resolve(data)
|
||||
} else {
|
||||
reject(error)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
data = JSON.parse(contents)
|
||||
} catch (parseError) {
|
||||
console.error(parseError)
|
||||
}
|
||||
resolve(data)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Write to the local configuration file
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @param {String} filename - file path
|
||||
* @param {Object} data - data
|
||||
* @fulfil {Object} data - data
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @example
|
||||
* writeConfigFile('config.json', { something: 'good' })
|
||||
* .then(() => {
|
||||
* console.log('data written')
|
||||
* })
|
||||
*/
|
||||
const writeConfigFile = (filename, data) => {
|
||||
return new Bluebird((resolve, reject) => {
|
||||
const contents = JSON.stringify(data, null, JSON_INDENT)
|
||||
fs.writeFile(filename, contents, (error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve(data)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Read all local settings
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @fulfil {Object} - local settings
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @example
|
||||
* localSettings.readAll().then((settings) => {
|
||||
* console.log(settings);
|
||||
* });
|
||||
*/
|
||||
exports.readAll = () => {
|
||||
return readConfigFile(CONFIG_PATH)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Write local settings
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {Object} settings - settings
|
||||
* @fulfil {Object} settings - settings
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @example
|
||||
* localSettings.writeAll({
|
||||
* foo: 'bar'
|
||||
* }).then(() => {
|
||||
* console.log('Done!');
|
||||
* });
|
||||
*/
|
||||
exports.writeAll = (settings) => {
|
||||
return writeConfigFile(CONFIG_PATH, settings)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Clear the local settings
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @description
|
||||
* Exported for testing purposes
|
||||
*
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @example
|
||||
* localSettings.clear().then(() => {
|
||||
* console.log('Done!');
|
||||
* });
|
||||
*/
|
||||
exports.clear = () => {
|
||||
return new Bluebird((resolve, reject) => {
|
||||
fs.unlink(CONFIG_PATH, (error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,438 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const store = require('./store')
|
||||
const availableDrives = require('./available-drives')
|
||||
|
||||
/**
|
||||
* @summary Select a drive by its device path
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {String} driveDevice - drive device
|
||||
*
|
||||
* @example
|
||||
* selectionState.selectDrive('/dev/disk2');
|
||||
*/
|
||||
exports.selectDrive = (driveDevice) => {
|
||||
store.dispatch({
|
||||
type: store.Actions.SELECT_DRIVE,
|
||||
data: driveDevice
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Toggle drive selection
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {String} driveDevice - drive device
|
||||
*
|
||||
* @example
|
||||
* selectionState.toggleDrive('/dev/disk2');
|
||||
*/
|
||||
exports.toggleDrive = (driveDevice) => {
|
||||
if (exports.isDriveSelected(driveDevice)) {
|
||||
exports.deselectDrive(driveDevice)
|
||||
} else {
|
||||
exports.selectDrive(driveDevice)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Deselect all other drives and keep the current drive's status
|
||||
* @function
|
||||
* @public
|
||||
* @deprecated
|
||||
*
|
||||
* @description
|
||||
* This is a temporary function during the transition to multi-writes,
|
||||
* remove this and its uses when multi-selection should become user-facing.
|
||||
*
|
||||
* @param {String} driveDevice - drive device identifier
|
||||
*
|
||||
* @example
|
||||
* console.log(selectionState.getSelectedDevices())
|
||||
* > [ '/dev/disk1', '/dev/disk2', '/dev/disk3' ]
|
||||
* selectionState.deselectOtherDrives('/dev/disk2')
|
||||
* console.log(selectionState.getSelectedDevices())
|
||||
* > [ '/dev/disk2' ]
|
||||
*/
|
||||
exports.deselectOtherDrives = (driveDevice) => {
|
||||
if (exports.isDriveSelected(driveDevice)) {
|
||||
const otherDevices = _.reject(exports.getSelectedDevices(), _.partial(_.isEqual, driveDevice))
|
||||
_.each(otherDevices, exports.deselectDrive)
|
||||
} else {
|
||||
exports.deselectAllDrives()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Select an image
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {Object} image - image
|
||||
*
|
||||
* @example
|
||||
* selectionState.selectImage({
|
||||
* path: 'foo.img',
|
||||
* size: 1000000000,
|
||||
* compressedSize: 1000000000,
|
||||
* isSizeEstimated: false,
|
||||
* });
|
||||
*/
|
||||
exports.selectImage = (image) => {
|
||||
store.dispatch({
|
||||
type: store.Actions.SELECT_IMAGE,
|
||||
data: image
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get all selected drives' devices
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {String[]} selected drives' devices
|
||||
*
|
||||
* @example
|
||||
* for (driveDevice of selectionState.getSelectedDevices()) {
|
||||
* console.log(driveDevice)
|
||||
* }
|
||||
* > '/dev/disk1'
|
||||
* > '/dev/disk2'
|
||||
*/
|
||||
exports.getSelectedDevices = () => {
|
||||
return store.getState().getIn([ 'selection', 'devices' ]).toJS()
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get all selected drive objects
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {Object[]} selected drive objects
|
||||
*
|
||||
* @example
|
||||
* for (drive of selectionState.getSelectedDrives()) {
|
||||
* console.log(drive)
|
||||
* }
|
||||
* > '{ device: '/dev/disk1', size: 123456789, ... }'
|
||||
* > '{ device: '/dev/disk2', size: 987654321, ... }'
|
||||
*/
|
||||
exports.getSelectedDrives = () => {
|
||||
const drives = availableDrives.getDrives()
|
||||
return _.map(exports.getSelectedDevices(), (device) => {
|
||||
return _.find(drives, { device })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get the head of the list of selected drives
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {Object} drive
|
||||
*
|
||||
* @example
|
||||
* const drive = selectionState.getCurrentDrive();
|
||||
* console.log(drive)
|
||||
* > { device: '/dev/disk1', name: 'Flash drive', ... }
|
||||
*/
|
||||
exports.getCurrentDrive = () => {
|
||||
const device = _.head(exports.getSelectedDevices())
|
||||
return _.find(availableDrives.getDrives(), { device })
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get the selected image
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {Object} image
|
||||
*
|
||||
* @example
|
||||
* const image = selectionState.getImage();
|
||||
*/
|
||||
exports.getImage = () => {
|
||||
return _.get(store.getState().toJS(), [ 'selection', 'image' ])
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get image path
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {String} image path
|
||||
*
|
||||
* @example
|
||||
* const imagePath = selectionState.getImagePath();
|
||||
*/
|
||||
exports.getImagePath = () => {
|
||||
return _.get(store.getState().toJS(), [
|
||||
'selection',
|
||||
'image',
|
||||
'path'
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get image size
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {Number} image size
|
||||
*
|
||||
* @example
|
||||
* const imageSize = selectionState.getImageSize();
|
||||
*/
|
||||
exports.getImageSize = () => {
|
||||
return _.get(store.getState().toJS(), [
|
||||
'selection',
|
||||
'image',
|
||||
'size'
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get image url
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {String} image url
|
||||
*
|
||||
* @example
|
||||
* const imageUrl = selectionState.getImageUrl();
|
||||
*/
|
||||
exports.getImageUrl = () => {
|
||||
return _.get(store.getState().toJS(), [
|
||||
'selection',
|
||||
'image',
|
||||
'url'
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get image name
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {String} image name
|
||||
*
|
||||
* @example
|
||||
* const imageName = selectionState.getImageName();
|
||||
*/
|
||||
exports.getImageName = () => {
|
||||
return _.get(store.getState().toJS(), [
|
||||
'selection',
|
||||
'image',
|
||||
'name'
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get image logo
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {String} image logo
|
||||
*
|
||||
* @example
|
||||
* const imageLogo = selectionState.getImageLogo();
|
||||
*/
|
||||
exports.getImageLogo = () => {
|
||||
return _.get(store.getState().toJS(), [
|
||||
'selection',
|
||||
'image',
|
||||
'logo'
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get image support url
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {String} image support url
|
||||
*
|
||||
* @example
|
||||
* const imageSupportUrl = selectionState.getImageSupportUrl();
|
||||
*/
|
||||
exports.getImageSupportUrl = () => {
|
||||
return _.get(store.getState().toJS(), [
|
||||
'selection',
|
||||
'image',
|
||||
'supportUrl'
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get image recommended drive size
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {String} image recommended drive size
|
||||
*
|
||||
* @example
|
||||
* const imageRecommendedDriveSize = selectionState.getImageRecommendedDriveSize();
|
||||
*/
|
||||
exports.getImageRecommendedDriveSize = () => {
|
||||
return _.get(store.getState().toJS(), [
|
||||
'selection',
|
||||
'image',
|
||||
'recommendedDriveSize'
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Check if there is a selected drive
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {Boolean} whether there is a selected drive
|
||||
*
|
||||
* @example
|
||||
* if (selectionState.hasDrive()) {
|
||||
* console.log('There is a drive!');
|
||||
* }
|
||||
*/
|
||||
exports.hasDrive = () => {
|
||||
return Boolean(exports.getSelectedDevices().length)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Check if there is a selected image
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {Boolean} whether there is a selected image
|
||||
*
|
||||
* @example
|
||||
* if (selectionState.hasImage()) {
|
||||
* console.log('There is an image!');
|
||||
* }
|
||||
*/
|
||||
exports.hasImage = () => {
|
||||
return Boolean(exports.getImage())
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Remove drive from selection
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {String} driveDevice - drive device identifier
|
||||
*
|
||||
* @example
|
||||
* selectionState.deselectDrive('/dev/sdc');
|
||||
*
|
||||
* @example
|
||||
* selectionState.deselectDrive('\\\\.\\PHYSICALDRIVE3');
|
||||
*/
|
||||
exports.deselectDrive = (driveDevice) => {
|
||||
store.dispatch({
|
||||
type: store.Actions.DESELECT_DRIVE,
|
||||
data: driveDevice
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Deselect image
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @example
|
||||
* selectionState.deselectImage();
|
||||
*/
|
||||
exports.deselectImage = () => {
|
||||
store.dispatch({
|
||||
type: store.Actions.DESELECT_IMAGE
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Deselect all drives
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @example
|
||||
* selectionState.deselectAllDrives()
|
||||
*/
|
||||
exports.deselectAllDrives = () => {
|
||||
_.each(exports.getSelectedDevices(), exports.deselectDrive)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Clear selections
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @example
|
||||
* selectionState.clear();
|
||||
*/
|
||||
exports.clear = () => {
|
||||
exports.deselectImage()
|
||||
exports.deselectAllDrives()
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Check if a drive is the current drive
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {String} driveDevice - drive device
|
||||
* @returns {Boolean} whether the drive is the current drive
|
||||
*
|
||||
* @example
|
||||
* if (selectionState.isCurrentDrive('/dev/sdb')) {
|
||||
* console.log('This is the current drive!');
|
||||
* }
|
||||
*/
|
||||
exports.isCurrentDrive = (driveDevice) => {
|
||||
if (!driveDevice) {
|
||||
return false
|
||||
}
|
||||
|
||||
return driveDevice === _.get(exports.getCurrentDrive(), [ 'device' ])
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Check whether a given device is selected.
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {String} driveDevice - drive device identifier
|
||||
* @returns {Boolean}
|
||||
*
|
||||
* @example
|
||||
* const isSelected = selectionState.isDriveSelected('/dev/sdb')
|
||||
*
|
||||
* if (isSelected) {
|
||||
* selectionState.deselectDrive(driveDevice)
|
||||
* }
|
||||
*/
|
||||
exports.isDriveSelected = (driveDevice) => {
|
||||
if (!driveDevice) {
|
||||
return false
|
||||
}
|
||||
|
||||
const selectedDriveDevices = exports.getSelectedDevices()
|
||||
return _.includes(selectedDriveDevices, driveDevice)
|
||||
}
|
||||
128
lib/gui/app/models/selection-state.ts
Normal file
128
lib/gui/app/models/selection-state.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { DrivelistDrive } from '../../../shared/drive-constraints';
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { SourceMetadata } from '../components/source-selector/source-selector';
|
||||
|
||||
import * as availableDrives from './available-drives';
|
||||
import { Actions, store } from './store';
|
||||
|
||||
/**
|
||||
* @summary Select a drive by its device path
|
||||
*/
|
||||
export function selectDrive(driveDevice: string) {
|
||||
store.dispatch({
|
||||
type: Actions.SELECT_TARGET,
|
||||
data: driveDevice,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Toggle drive selection
|
||||
*/
|
||||
export function toggleDrive(driveDevice: string) {
|
||||
if (isDriveSelected(driveDevice)) {
|
||||
deselectDrive(driveDevice);
|
||||
} else {
|
||||
selectDrive(driveDevice);
|
||||
}
|
||||
}
|
||||
|
||||
export function selectSource(source: SourceMetadata) {
|
||||
store.dispatch({
|
||||
type: Actions.SELECT_SOURCE,
|
||||
data: source,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get all selected drives' devices
|
||||
*/
|
||||
export function getSelectedDevices(): string[] {
|
||||
return store.getState().getIn(['selection', 'devices']).toJS();
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get all selected drive objects
|
||||
*/
|
||||
export function getSelectedDrives(): DrivelistDrive[] {
|
||||
const selectedDevices = getSelectedDevices();
|
||||
return availableDrives
|
||||
.getDrives()
|
||||
.filter((drive) => selectedDevices.includes(drive.device));
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get the selected image
|
||||
*/
|
||||
export function getImage(): SourceMetadata | undefined {
|
||||
return store.getState().toJS().selection.image;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Check if there is a selected drive
|
||||
*/
|
||||
export function hasDrive(): boolean {
|
||||
return Boolean(getSelectedDevices().length);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Check if there is a selected image
|
||||
*/
|
||||
export function hasImage(): boolean {
|
||||
return getImage() !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Remove drive from selection
|
||||
*/
|
||||
export function deselectDrive(driveDevice: string) {
|
||||
store.dispatch({
|
||||
type: Actions.DESELECT_TARGET,
|
||||
data: driveDevice,
|
||||
});
|
||||
}
|
||||
|
||||
export function deselectImage() {
|
||||
store.dispatch({
|
||||
type: Actions.DESELECT_SOURCE,
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
|
||||
export function deselectAllDrives() {
|
||||
getSelectedDevices().forEach(deselectDrive);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Clear selections
|
||||
*/
|
||||
export function clear() {
|
||||
deselectImage();
|
||||
deselectAllDrives();
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Check whether a given device is selected.
|
||||
*/
|
||||
export function isDriveSelected(driveDevice: string) {
|
||||
if (!driveDevice) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectedDriveDevices = getSelectedDevices();
|
||||
return selectedDriveDevices.includes(driveDevice);
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* @module Etcher.Models.Settings
|
||||
*/
|
||||
|
||||
const _ = require('lodash')
|
||||
const Bluebird = require('bluebird')
|
||||
const localSettings = require('./local-settings')
|
||||
const errors = require('../../../shared/errors')
|
||||
const packageJSON = require('../../../../package.json')
|
||||
const debug = require('debug')('etcher:models:settings')
|
||||
|
||||
/**
|
||||
* @summary Default settings
|
||||
* @constant
|
||||
* @type {Object}
|
||||
*/
|
||||
const DEFAULT_SETTINGS = {
|
||||
unsafeMode: false,
|
||||
errorReporting: true,
|
||||
unmountOnSuccess: true,
|
||||
validateWriteOnSuccess: true,
|
||||
trim: false,
|
||||
updatesEnabled: packageJSON.updates.enabled && !_.includes([ 'rpm', 'deb' ], packageJSON.packageType),
|
||||
lastSleptUpdateNotifier: null,
|
||||
lastSleptUpdateNotifierVersion: null,
|
||||
desktopNotifications: true
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Settings state
|
||||
* @type {Object}
|
||||
* @private
|
||||
*/
|
||||
let settings = _.cloneDeep(DEFAULT_SETTINGS)
|
||||
|
||||
/**
|
||||
* @summary Reset settings to their default values
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @example
|
||||
* settings.reset().then(() => {
|
||||
* console.log('Done!');
|
||||
* });
|
||||
*/
|
||||
exports.reset = () => {
|
||||
debug('reset')
|
||||
|
||||
// TODO: Remove default settings from config file (?)
|
||||
settings = _.cloneDeep(DEFAULT_SETTINGS)
|
||||
return localSettings.writeAll(settings)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Extend the current settings
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {Object} value - value
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @example
|
||||
* settings.assign({
|
||||
* foo: 'bar'
|
||||
* }).then(() => {
|
||||
* console.log('Done!');
|
||||
* });
|
||||
*/
|
||||
exports.assign = (value) => {
|
||||
debug('assign', value)
|
||||
if (_.isNil(value)) {
|
||||
return Bluebird.reject(errors.createError({
|
||||
title: 'Missing settings'
|
||||
}))
|
||||
}
|
||||
|
||||
if (!_.isPlainObject(value)) {
|
||||
return Bluebird.reject(errors.createError({
|
||||
title: 'Settings must be an object'
|
||||
}))
|
||||
}
|
||||
|
||||
const newSettings = _.assign({}, settings, value)
|
||||
|
||||
return localSettings.writeAll(newSettings)
|
||||
.then((updatedSettings) => {
|
||||
// NOTE: Only update in memory settings when successfully written
|
||||
settings = updatedSettings
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Extend the application state with the local settings
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @example
|
||||
* settings.load().then(() => {
|
||||
* console.log('Done!');
|
||||
* });
|
||||
*/
|
||||
exports.load = () => {
|
||||
debug('load')
|
||||
return localSettings.readAll().then((loadedSettings) => {
|
||||
return _.assign(settings, loadedSettings)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Set a setting value
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {String} key - setting key
|
||||
* @param {*} value - setting value
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @example
|
||||
* settings.set('unmountOnSuccess', true).then(() => {
|
||||
* console.log('Done!');
|
||||
* });
|
||||
*/
|
||||
exports.set = (key, value) => {
|
||||
debug('set', key, value)
|
||||
if (_.isNil(key)) {
|
||||
return Bluebird.reject(errors.createError({
|
||||
title: 'Missing setting key'
|
||||
}))
|
||||
}
|
||||
|
||||
if (!_.isString(key)) {
|
||||
return Bluebird.reject(errors.createError({
|
||||
title: `Invalid setting key: ${key}`
|
||||
}))
|
||||
}
|
||||
|
||||
const previousValue = settings[key]
|
||||
|
||||
settings[key] = value
|
||||
|
||||
return localSettings.writeAll(settings)
|
||||
.catch((error) => {
|
||||
// Revert to previous value if persisting settings failed
|
||||
settings[key] = previousValue
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get a setting value
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {String} key - setting key
|
||||
* @returns {*} setting value
|
||||
*
|
||||
* @example
|
||||
* const value = settings.get('unmountOnSuccess');
|
||||
*/
|
||||
exports.get = (key) => {
|
||||
return _.cloneDeep(_.get(settings, [ key ]))
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Check if setting value exists
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {String} key - setting key
|
||||
* @returns {Boolean} exists
|
||||
*
|
||||
* @example
|
||||
* const hasValue = settings.has('unmountOnSuccess');
|
||||
*/
|
||||
exports.has = (key) => {
|
||||
/* eslint-disable no-eq-null */
|
||||
return settings[key] != null
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get all setting values
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {Object} all setting values
|
||||
*
|
||||
* @example
|
||||
* const allSettings = settings.getAll();
|
||||
* console.log(allSettings.unmountOnSuccess);
|
||||
*/
|
||||
exports.getAll = () => {
|
||||
debug('getAll')
|
||||
return _.cloneDeep(settings)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get the default setting values
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {Object} all setting values
|
||||
*
|
||||
* @example
|
||||
* const defaults = settings.getDefaults();
|
||||
* console.log(defaults.unmountOnSuccess);
|
||||
*/
|
||||
exports.getDefaults = () => {
|
||||
debug('getDefaults')
|
||||
return _.cloneDeep(DEFAULT_SETTINGS)
|
||||
}
|
||||
127
lib/gui/app/models/settings.ts
Normal file
127
lib/gui/app/models/settings.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as _debug from 'debug';
|
||||
import * as electron from 'electron';
|
||||
import * as _ from 'lodash';
|
||||
import { promises as fs } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import * as packageJSON from '../../../../package.json';
|
||||
|
||||
const debug = _debug('etcher:models:settings');
|
||||
|
||||
const JSON_INDENT = 2;
|
||||
|
||||
export const DEFAULT_WIDTH = 800;
|
||||
export const DEFAULT_HEIGHT = 480;
|
||||
|
||||
/**
|
||||
* @summary Userdata directory path
|
||||
* @description
|
||||
* Defaults to the following:
|
||||
* - `%APPDATA%/etcher` on Windows
|
||||
* - `$XDG_CONFIG_HOME/etcher` or `~/.config/etcher` on Linux
|
||||
* - `~/Library/Application Support/etcher` on macOS
|
||||
* See https://electronjs.org/docs/api/app#appgetpathname
|
||||
*
|
||||
* NOTE: We use the remote property when this module
|
||||
* is loaded in the Electron's renderer process
|
||||
*/
|
||||
const app = electron.app || electron.remote.app;
|
||||
|
||||
const USER_DATA_DIR = app.getPath('userData');
|
||||
|
||||
const CONFIG_PATH = join(USER_DATA_DIR, 'config.json');
|
||||
|
||||
async function readConfigFile(filename: string): Promise<_.Dictionary<any>> {
|
||||
let contents = '{}';
|
||||
try {
|
||||
contents = await fs.readFile(filename, { encoding: 'utf8' });
|
||||
} catch (error: any) {
|
||||
// noop
|
||||
}
|
||||
try {
|
||||
return JSON.parse(contents);
|
||||
} catch (parseError) {
|
||||
console.error(parseError);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// exported for tests
|
||||
export async function readAll() {
|
||||
return await readConfigFile(CONFIG_PATH);
|
||||
}
|
||||
|
||||
// exported for tests
|
||||
export async function writeConfigFile(
|
||||
filename: string,
|
||||
data: _.Dictionary<any>,
|
||||
): Promise<void> {
|
||||
await fs.writeFile(filename, JSON.stringify(data, null, JSON_INDENT));
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: _.Dictionary<any> = {
|
||||
errorReporting: true,
|
||||
updatesEnabled: ['appimage', 'nsis', 'dmg'].includes(packageJSON.packageType),
|
||||
desktopNotifications: true,
|
||||
autoBlockmapping: true,
|
||||
decompressFirst: true,
|
||||
};
|
||||
|
||||
const settings = _.cloneDeep(DEFAULT_SETTINGS);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
debug('load');
|
||||
const loadedSettings = await readAll();
|
||||
_.assign(settings, loadedSettings);
|
||||
}
|
||||
|
||||
const loaded = load();
|
||||
|
||||
export async function set(
|
||||
key: string,
|
||||
value: any,
|
||||
writeConfigFileFn = writeConfigFile,
|
||||
): Promise<void> {
|
||||
debug('set', key, value);
|
||||
await loaded;
|
||||
const previousValue = settings[key];
|
||||
settings[key] = value;
|
||||
try {
|
||||
await writeConfigFileFn(CONFIG_PATH, settings);
|
||||
} catch (error: any) {
|
||||
// Revert to previous value if persisting settings failed
|
||||
settings[key] = previousValue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function get(key: string): Promise<any> {
|
||||
await loaded;
|
||||
return getSync(key);
|
||||
}
|
||||
|
||||
export function getSync(key: string): any {
|
||||
return _.cloneDeep(settings[key]);
|
||||
}
|
||||
|
||||
export async function getAll() {
|
||||
debug('getAll');
|
||||
await loaded;
|
||||
return _.cloneDeep(settings);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
/*
|
||||
* Copyright 2018 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const INDENTATION_SPACES = 2
|
||||
|
||||
/**
|
||||
* @summary Localstorage class and helper functions
|
||||
* @class
|
||||
* @public
|
||||
*/
|
||||
class Storage {
|
||||
/**
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {String} superkey - superkey
|
||||
*
|
||||
* @example
|
||||
* const potatoStorage = new Storage('potato')
|
||||
*/
|
||||
constructor (superkey) {
|
||||
this.superkey = superkey
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get the whole object under the superkey
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {Object}
|
||||
*
|
||||
* @example
|
||||
* for (const key in potatoStorage.getAll()) {
|
||||
* console.log(key)
|
||||
* }
|
||||
*/
|
||||
getAll () {
|
||||
try {
|
||||
// JSON.parse(null) === null, so we fallback to {}
|
||||
return JSON.parse(window.localStorage.getItem(this.superkey)) || {}
|
||||
} catch (err) {
|
||||
this.setAll({})
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Set the whole object under the superkey
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {Any} value - any valid JSON value
|
||||
*
|
||||
* @example
|
||||
* potatoStorage.setAll({
|
||||
* location: 'somewhere',
|
||||
* freshness: 100,
|
||||
* edible: true
|
||||
* })
|
||||
*/
|
||||
setAll (value) {
|
||||
window.localStorage.setItem(this.superkey, JSON.stringify(value, null, INDENTATION_SPACES))
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Clear the whole object under the superkey
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @example
|
||||
* potatoStorage.clearAll()
|
||||
*/
|
||||
clearAll () {
|
||||
window.localStorage.removeItem(this.superkey)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get a stored value
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {String} key - object field key
|
||||
* @param {Any} defaultValue - any valid JSON value
|
||||
* @returns {Any} - the JSON parsed value
|
||||
*
|
||||
* @example
|
||||
* potatoStorage.get('location', 'my farm')
|
||||
*/
|
||||
get (key, defaultValue) {
|
||||
const value = this.getAll()[key]
|
||||
|
||||
// eslint-disable-next-line no-undefined
|
||||
if (value === undefined) {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Modify a stored value
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {String} key - object field key
|
||||
* @param {Function} func - function to apply to the value
|
||||
* @param {Any} defaultValue - fallback value
|
||||
* @returns {Any} - the value returned by the function applied above
|
||||
*
|
||||
* @example
|
||||
* potatoStorage.modify('freshness', (freshness) => {
|
||||
* return freshness + 1
|
||||
* })
|
||||
*/
|
||||
modify (key, func, defaultValue) {
|
||||
const obj = this.getAll()
|
||||
|
||||
let result = null
|
||||
// eslint-disable-next-line no-undefined
|
||||
if (obj[key] === undefined) {
|
||||
result = func(defaultValue)
|
||||
} else {
|
||||
result = func(obj[key])
|
||||
}
|
||||
|
||||
// eslint-disable-next-line lodash/prefer-lodash-method
|
||||
this.setAll(Object.assign(obj, { [key]: result }))
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Set a stored value
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {String} key - object field key
|
||||
* @param {Any} value - value to set
|
||||
*
|
||||
* @example
|
||||
* potatoStorage.set('edible', true)
|
||||
*/
|
||||
set (key, value) {
|
||||
this.modify(key, () => {
|
||||
return value
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Storage
|
||||
@@ -1,554 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const Immutable = require('immutable')
|
||||
const _ = require('lodash')
|
||||
const redux = require('redux')
|
||||
const uuidV4 = require('uuid/v4')
|
||||
const constraints = require('../../../shared/drive-constraints')
|
||||
const supportedFormats = require('../../../shared/supported-formats')
|
||||
const errors = require('../../../shared/errors')
|
||||
const fileExtensions = require('../../../shared/file-extensions')
|
||||
const utils = require('../../../shared/utils')
|
||||
const settings = require('./settings')
|
||||
|
||||
/**
|
||||
* @summary Verify and throw if any state fields are nil
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {Object} object - state object
|
||||
* @param {Array<Array<String>> | Array<String>} fields - array of object field paths
|
||||
* @param {String} name - name of the state we're dealing with
|
||||
* @throws
|
||||
*
|
||||
* @example
|
||||
* const fields = [ 'type', 'percentage' ]
|
||||
* verifyNoNilFields(action.data, fields, 'flash')
|
||||
*/
|
||||
const verifyNoNilFields = (object, fields, name) => {
|
||||
const nilFields = _.filter(fields, (field) => {
|
||||
return _.isNil(_.get(object, field))
|
||||
})
|
||||
if (nilFields.length) {
|
||||
throw new Error(`Missing ${name} fields: ${nilFields.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary FLASH_STATE fields that can't be nil
|
||||
* @constant
|
||||
* @private
|
||||
*/
|
||||
const flashStateNoNilFields = [
|
||||
'speed',
|
||||
'totalSpeed'
|
||||
]
|
||||
|
||||
/**
|
||||
* @summary SELECT_IMAGE fields that can't be nil
|
||||
* @constant
|
||||
* @private
|
||||
*/
|
||||
const selectImageNoNilFields = [
|
||||
'path',
|
||||
'extension'
|
||||
]
|
||||
|
||||
/**
|
||||
* @summary Application default state
|
||||
* @type {Object}
|
||||
* @constant
|
||||
* @private
|
||||
*/
|
||||
const DEFAULT_STATE = Immutable.fromJS({
|
||||
applicationSessionUuid: '',
|
||||
flashingWorkflowUuid: '',
|
||||
availableDrives: [],
|
||||
selection: {
|
||||
devices: new Immutable.OrderedSet()
|
||||
},
|
||||
isFlashing: false,
|
||||
flashResults: {},
|
||||
flashState: {
|
||||
flashing: 0,
|
||||
verifying: 0,
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
percentage: 0,
|
||||
speed: null,
|
||||
totalSpeed: null
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* @summary Application supported action messages
|
||||
* @type {Object}
|
||||
* @constant
|
||||
*/
|
||||
const ACTIONS = _.fromPairs(_.map([
|
||||
'SET_AVAILABLE_DRIVES',
|
||||
'SET_FLASH_STATE',
|
||||
'RESET_FLASH_STATE',
|
||||
'SET_FLASHING_FLAG',
|
||||
'UNSET_FLASHING_FLAG',
|
||||
'SELECT_DRIVE',
|
||||
'SELECT_IMAGE',
|
||||
'DESELECT_DRIVE',
|
||||
'DESELECT_IMAGE',
|
||||
'SET_APPLICATION_SESSION_UUID',
|
||||
'SET_FLASHING_WORKFLOW_UUID',
|
||||
'SET_WEBVIEW_SHOWING_STATUS'
|
||||
], (message) => {
|
||||
return [ message, message ]
|
||||
}))
|
||||
|
||||
/**
|
||||
* @summary Get available drives from the state
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {Object} state - state object
|
||||
* @returns {Object} new state
|
||||
*
|
||||
* @example
|
||||
* const drives = getAvailableDrives(state)
|
||||
* _.find(drives, { device: '/dev/sda' })
|
||||
*/
|
||||
const getAvailableDrives = (state) => {
|
||||
// eslint-disable-next-line lodash/prefer-lodash-method
|
||||
return state.get('availableDrives').toJS()
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary The redux store reducer
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @param {Object} state - application state
|
||||
* @param {Object} action - dispatched action
|
||||
* @returns {Object} new application state
|
||||
*
|
||||
* @example
|
||||
* const newState = storeReducer(DEFAULT_STATE, {
|
||||
* type: ACTIONS.DESELECT_DRIVE
|
||||
* });
|
||||
*/
|
||||
const storeReducer = (state = DEFAULT_STATE, action) => {
|
||||
switch (action.type) {
|
||||
case ACTIONS.SET_AVAILABLE_DRIVES: {
|
||||
// Type: action.data : Array<DriveObject>
|
||||
|
||||
if (!action.data) {
|
||||
throw errors.createError({
|
||||
title: 'Missing drives'
|
||||
})
|
||||
}
|
||||
|
||||
const drives = action.data
|
||||
|
||||
if (!_.isArray(drives) || !_.every(drives, _.isObject)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid drives: ${drives}`
|
||||
})
|
||||
}
|
||||
|
||||
const newState = state.set('availableDrives', Immutable.fromJS(drives))
|
||||
const selectedDevices = newState.getIn([ 'selection', 'devices' ]).toJS()
|
||||
|
||||
// Remove selected drives that are stale, i.e. missing from availableDrives
|
||||
const nonStaleNewState = _.reduce(selectedDevices, (accState, device) => {
|
||||
// Check whether the drive still exists in availableDrives
|
||||
if (device && !_.find(drives, {
|
||||
device
|
||||
})) {
|
||||
// Deselect this drive gone from availableDrives
|
||||
return storeReducer(accState, {
|
||||
type: ACTIONS.DESELECT_DRIVE,
|
||||
data: device
|
||||
})
|
||||
}
|
||||
|
||||
return accState
|
||||
}, newState)
|
||||
|
||||
const shouldAutoselectAll = Boolean(settings.get('disableExplicitDriveSelection'))
|
||||
const AUTOSELECT_DRIVE_COUNT = 1
|
||||
const nonStaleSelectedDevices = nonStaleNewState.getIn([ 'selection', 'devices' ]).toJS()
|
||||
const hasSelectedDevices = nonStaleSelectedDevices.length >= AUTOSELECT_DRIVE_COUNT
|
||||
const shouldAutoselectOne = drives.length === AUTOSELECT_DRIVE_COUNT && !hasSelectedDevices
|
||||
|
||||
if (shouldAutoselectOne || shouldAutoselectAll) {
|
||||
// Even if there's no image selected, we need to call several
|
||||
// drive/image related checks, and `{}` works fine with them
|
||||
const image = state.getIn([ 'selection', 'image' ], Immutable.fromJS({})).toJS()
|
||||
|
||||
return _.reduce(drives, (accState, drive) => {
|
||||
if (_.every([
|
||||
constraints.isDriveValid(drive, image),
|
||||
constraints.isDriveSizeRecommended(drive, image),
|
||||
|
||||
// We don't want to auto-select large drives
|
||||
!constraints.isDriveSizeLarge(drive),
|
||||
|
||||
// We don't want to auto-select system drives,
|
||||
// even when "unsafe mode" is enabled
|
||||
!constraints.isSystemDrive(drive)
|
||||
|
||||
]) || (shouldAutoselectAll && constraints.isDriveValid(drive, image))) {
|
||||
// Auto-select this drive
|
||||
return storeReducer(accState, {
|
||||
type: ACTIONS.SELECT_DRIVE,
|
||||
data: drive.device
|
||||
})
|
||||
}
|
||||
|
||||
// Deselect this drive in case it still is selected
|
||||
return storeReducer(accState, {
|
||||
type: ACTIONS.DESELECT_DRIVE,
|
||||
data: drive.device
|
||||
})
|
||||
}, nonStaleNewState)
|
||||
}
|
||||
|
||||
return nonStaleNewState
|
||||
}
|
||||
|
||||
case ACTIONS.SET_FLASH_STATE: {
|
||||
// Type: action.data : FlashStateObject
|
||||
|
||||
if (!state.get('isFlashing')) {
|
||||
throw errors.createError({
|
||||
title: 'Can\'t set the flashing state when not flashing'
|
||||
})
|
||||
}
|
||||
|
||||
verifyNoNilFields(action.data, flashStateNoNilFields, 'flash')
|
||||
|
||||
if (!_.every(_.pick(action.data, [
|
||||
'flashing',
|
||||
'verifying',
|
||||
'successful',
|
||||
'failed'
|
||||
]), _.isFinite)) {
|
||||
throw errors.createError({
|
||||
title: 'State quantity field(s) not finite number'
|
||||
})
|
||||
}
|
||||
|
||||
if (!_.isUndefined(action.data.percentage) && !utils.isValidPercentage(action.data.percentage)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid state percentage: ${action.data.percentage}`
|
||||
})
|
||||
}
|
||||
|
||||
if (!_.isUndefined(action.data.eta) && !_.isNumber(action.data.eta)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid state eta: ${action.data.eta}`
|
||||
})
|
||||
}
|
||||
|
||||
return state.set('flashState', Immutable.fromJS(action.data))
|
||||
}
|
||||
|
||||
case ACTIONS.RESET_FLASH_STATE: {
|
||||
return state
|
||||
.set('isFlashing', false)
|
||||
.set('flashState', DEFAULT_STATE.get('flashState'))
|
||||
.set('flashResults', DEFAULT_STATE.get('flashResults'))
|
||||
.delete('flashUuid')
|
||||
}
|
||||
|
||||
case ACTIONS.SET_FLASHING_FLAG: {
|
||||
return state
|
||||
.set('isFlashing', true)
|
||||
.set('flashUuid', uuidV4())
|
||||
.set('flashResults', DEFAULT_STATE.get('flashResults'))
|
||||
}
|
||||
|
||||
case ACTIONS.UNSET_FLASHING_FLAG: {
|
||||
// Type: action.data : FlashResultsObject
|
||||
|
||||
if (!action.data) {
|
||||
throw errors.createError({
|
||||
title: 'Missing results'
|
||||
})
|
||||
}
|
||||
|
||||
_.defaults(action.data, {
|
||||
cancelled: false
|
||||
})
|
||||
|
||||
if (!_.isBoolean(action.data.cancelled)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid results cancelled: ${action.data.cancelled}`
|
||||
})
|
||||
}
|
||||
|
||||
if (action.data.cancelled && action.data.sourceChecksum) {
|
||||
throw errors.createError({
|
||||
title: 'The sourceChecksum value can\'t exist if the flashing was cancelled'
|
||||
})
|
||||
}
|
||||
|
||||
if (action.data.sourceChecksum && !_.isString(action.data.sourceChecksum)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid results sourceChecksum: ${action.data.sourceChecksum}`
|
||||
})
|
||||
}
|
||||
|
||||
if (action.data.errorCode && !_.isString(action.data.errorCode) && !_.isNumber(action.data.errorCode)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid results errorCode: ${action.data.errorCode}`
|
||||
})
|
||||
}
|
||||
|
||||
return state
|
||||
.set('isFlashing', false)
|
||||
.set('flashResults', Immutable.fromJS(action.data))
|
||||
.set('flashState', DEFAULT_STATE.get('flashState'))
|
||||
}
|
||||
|
||||
case ACTIONS.SELECT_DRIVE: {
|
||||
// Type: action.data : String
|
||||
|
||||
const device = action.data
|
||||
|
||||
if (!device) {
|
||||
throw errors.createError({
|
||||
title: 'Missing drive'
|
||||
})
|
||||
}
|
||||
|
||||
if (!_.isString(device)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid drive: ${device}`
|
||||
})
|
||||
}
|
||||
|
||||
const selectedDrive = _.find(getAvailableDrives(state), { device })
|
||||
|
||||
if (!selectedDrive) {
|
||||
throw errors.createError({
|
||||
title: `The drive is not available: ${device}`
|
||||
})
|
||||
}
|
||||
|
||||
if (selectedDrive.isReadOnly) {
|
||||
throw errors.createError({
|
||||
title: 'The drive is write-protected'
|
||||
})
|
||||
}
|
||||
|
||||
const image = state.getIn([ 'selection', 'image' ])
|
||||
if (image && !constraints.isDriveLargeEnough(selectedDrive, image.toJS())) {
|
||||
throw errors.createError({
|
||||
title: 'The drive is not large enough'
|
||||
})
|
||||
}
|
||||
|
||||
const selectedDevices = state.getIn([ 'selection', 'devices' ])
|
||||
|
||||
return state.setIn([ 'selection', 'devices' ], selectedDevices.add(device))
|
||||
}
|
||||
|
||||
// TODO(jhermsmeier): Consolidate these assertions
|
||||
// with image-stream / supported-formats, and have *one*
|
||||
// place where all the image extension / format handling
|
||||
// takes place, to avoid having to check 2+ locations with different logic
|
||||
case ACTIONS.SELECT_IMAGE: {
|
||||
// Type: action.data : ImageObject
|
||||
|
||||
verifyNoNilFields(action.data, selectImageNoNilFields, 'image')
|
||||
|
||||
if (!_.isString(action.data.path)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid image path: ${action.data.path}`
|
||||
})
|
||||
}
|
||||
|
||||
if (!_.isString(action.data.extension)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid image extension: ${action.data.extension}`
|
||||
})
|
||||
}
|
||||
|
||||
const extension = _.toLower(action.data.extension)
|
||||
|
||||
if (!_.includes(supportedFormats.getAllExtensions(), extension)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid image extension: ${action.data.extension}`
|
||||
})
|
||||
}
|
||||
|
||||
let lastImageExtension = fileExtensions.getLastFileExtension(action.data.path)
|
||||
lastImageExtension = _.isString(lastImageExtension) ? _.toLower(lastImageExtension) : lastImageExtension
|
||||
|
||||
if (lastImageExtension !== extension) {
|
||||
if (!_.isString(action.data.archiveExtension)) {
|
||||
throw errors.createError({
|
||||
title: 'Missing image archive extension'
|
||||
})
|
||||
}
|
||||
|
||||
const archiveExtension = _.toLower(action.data.archiveExtension)
|
||||
|
||||
if (!_.includes(supportedFormats.getAllExtensions(), archiveExtension)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid image archive extension: ${action.data.archiveExtension}`
|
||||
})
|
||||
}
|
||||
|
||||
if (lastImageExtension !== archiveExtension) {
|
||||
throw errors.createError({
|
||||
title: `Image archive extension mismatch: ${action.data.archiveExtension} and ${lastImageExtension}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const MINIMUM_IMAGE_SIZE = 0
|
||||
|
||||
// eslint-disable-next-line no-undefined
|
||||
if (action.data.size !== undefined) {
|
||||
if ((action.data.size < MINIMUM_IMAGE_SIZE) || !_.isInteger(action.data.size)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid image size: ${action.data.size}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!_.isUndefined(action.data.compressedSize)) {
|
||||
if ((action.data.compressedSize < MINIMUM_IMAGE_SIZE) || !_.isInteger(action.data.compressedSize)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid image compressed size: ${action.data.compressedSize}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (action.data.url && !_.isString(action.data.url)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid image url: ${action.data.url}`
|
||||
})
|
||||
}
|
||||
|
||||
if (action.data.name && !_.isString(action.data.name)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid image name: ${action.data.name}`
|
||||
})
|
||||
}
|
||||
|
||||
if (action.data.logo && !_.isString(action.data.logo)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid image logo: ${action.data.logo}`
|
||||
})
|
||||
}
|
||||
|
||||
const selectedDevices = state.getIn([ 'selection', 'devices' ])
|
||||
|
||||
// Remove image-incompatible drives from selection with `constraints.isDriveValid`
|
||||
return _.reduce(selectedDevices.toJS(), (accState, device) => {
|
||||
const drive = _.find(getAvailableDrives(state), { device })
|
||||
if (!constraints.isDriveValid(drive, action.data) || !constraints.isDriveSizeRecommended(drive, action.data)) {
|
||||
return storeReducer(accState, {
|
||||
type: ACTIONS.DESELECT_DRIVE,
|
||||
data: device
|
||||
})
|
||||
}
|
||||
|
||||
return accState
|
||||
}, state).setIn([ 'selection', 'image' ], Immutable.fromJS(action.data))
|
||||
}
|
||||
|
||||
case ACTIONS.DESELECT_DRIVE: {
|
||||
// Type: action.data : String
|
||||
|
||||
if (!action.data) {
|
||||
throw errors.createError({
|
||||
title: 'Missing drive'
|
||||
})
|
||||
}
|
||||
|
||||
if (!_.isString(action.data)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid drive: ${action.data}`
|
||||
})
|
||||
}
|
||||
|
||||
const selectedDevices = state.getIn([ 'selection', 'devices' ])
|
||||
|
||||
// Remove drive from set in state
|
||||
return state.setIn([ 'selection', 'devices' ], selectedDevices.delete(action.data))
|
||||
}
|
||||
|
||||
case ACTIONS.DESELECT_IMAGE: {
|
||||
return state.deleteIn([ 'selection', 'image' ])
|
||||
}
|
||||
|
||||
case ACTIONS.SET_APPLICATION_SESSION_UUID: {
|
||||
return state.set('applicationSessionUuid', action.data)
|
||||
}
|
||||
|
||||
case ACTIONS.SET_FLASHING_WORKFLOW_UUID: {
|
||||
return state.set('flashingWorkflowUuid', action.data)
|
||||
}
|
||||
|
||||
case ACTIONS.SET_WEBVIEW_SHOWING_STATUS: {
|
||||
return state.set('isWebviewShowing', action.data)
|
||||
}
|
||||
|
||||
default: {
|
||||
return state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = _.merge(redux.createStore(storeReducer, DEFAULT_STATE), {
|
||||
Actions: ACTIONS,
|
||||
Defaults: DEFAULT_STATE
|
||||
})
|
||||
|
||||
/**
|
||||
* @summary Observe the store for changes
|
||||
* @param {Function} onChange - change handler
|
||||
* @returns {Function} unsubscribe
|
||||
* @example
|
||||
* store.observe((newState) => {
|
||||
* // ...
|
||||
* })
|
||||
*/
|
||||
module.exports.observe = (onChange) => {
|
||||
let currentState = null
|
||||
|
||||
/**
|
||||
* @summary Internal change detection handler
|
||||
* @private
|
||||
* @example
|
||||
* store.subscribe(changeHandler)
|
||||
*/
|
||||
const changeHandler = () => {
|
||||
const nextState = module.exports.getState()
|
||||
if (!_.isEqual(nextState, currentState)) {
|
||||
currentState = nextState
|
||||
onChange(currentState)
|
||||
}
|
||||
}
|
||||
|
||||
changeHandler()
|
||||
|
||||
return module.exports.subscribe(changeHandler)
|
||||
}
|
||||
554
lib/gui/app/models/store.ts
Normal file
554
lib/gui/app/models/store.ts
Normal file
@@ -0,0 +1,554 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as Immutable from 'immutable';
|
||||
import * as _ from 'lodash';
|
||||
import { basename } from 'path';
|
||||
import * as redux from 'redux';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
|
||||
import * as constraints from '../../../shared/drive-constraints';
|
||||
import * as errors from '../../../shared/errors';
|
||||
import * as utils from '../../../shared/utils';
|
||||
import * as settings from './settings';
|
||||
|
||||
/**
|
||||
* @summary Verify and throw if any state fields are nil
|
||||
*/
|
||||
function verifyNoNilFields(
|
||||
object: _.Dictionary<any>,
|
||||
fields: string[],
|
||||
name: string,
|
||||
) {
|
||||
const nilFields = _.filter(fields, (field) => {
|
||||
return _.isNil(_.get(object, field));
|
||||
});
|
||||
if (nilFields.length) {
|
||||
throw new Error(`Missing ${name} fields: ${nilFields.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary FLASH_STATE fields that can't be nil
|
||||
*/
|
||||
const flashStateNoNilFields = ['speed'];
|
||||
|
||||
/**
|
||||
* @summary SELECT_IMAGE fields that can't be nil
|
||||
*/
|
||||
const selectImageNoNilFields = ['path', 'extension'];
|
||||
|
||||
/**
|
||||
* @summary Application default state
|
||||
*/
|
||||
export const DEFAULT_STATE = Immutable.fromJS({
|
||||
applicationSessionUuid: '',
|
||||
flashingWorkflowUuid: '',
|
||||
availableDrives: [],
|
||||
selection: {
|
||||
devices: Immutable.OrderedSet(),
|
||||
},
|
||||
isFlashing: false,
|
||||
devicePaths: [],
|
||||
failedDeviceErrors: [],
|
||||
flashResults: {},
|
||||
flashState: {
|
||||
active: 0,
|
||||
failed: 0,
|
||||
percentage: 0,
|
||||
speed: null,
|
||||
averageSpeed: null,
|
||||
},
|
||||
lastAverageFlashingSpeed: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Application supported action messages
|
||||
*/
|
||||
export enum Actions {
|
||||
SET_DEVICE_PATHS,
|
||||
SET_FAILED_DEVICE_ERRORS,
|
||||
SET_AVAILABLE_TARGETS,
|
||||
SET_FLASH_STATE,
|
||||
RESET_FLASH_STATE,
|
||||
SET_FLASHING_FLAG,
|
||||
UNSET_FLASHING_FLAG,
|
||||
SELECT_TARGET,
|
||||
SELECT_SOURCE,
|
||||
DESELECT_TARGET,
|
||||
DESELECT_SOURCE,
|
||||
SET_APPLICATION_SESSION_UUID,
|
||||
SET_FLASHING_WORKFLOW_UUID,
|
||||
}
|
||||
|
||||
interface Action {
|
||||
type: Actions;
|
||||
data: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get available drives from the state
|
||||
*
|
||||
* @param {Object} state - state object
|
||||
* @returns {Object} new state
|
||||
*/
|
||||
function getAvailableDrives(state: typeof DEFAULT_STATE) {
|
||||
return state.get('availableDrives').toJS();
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary The redux store reducer
|
||||
*/
|
||||
function storeReducer(
|
||||
state = DEFAULT_STATE,
|
||||
action: Action,
|
||||
): typeof DEFAULT_STATE {
|
||||
switch (action.type) {
|
||||
case Actions.SET_AVAILABLE_TARGETS: {
|
||||
// Type: action.data : Array<DriveObject>
|
||||
|
||||
if (!action.data) {
|
||||
throw errors.createError({
|
||||
title: 'Missing drives',
|
||||
});
|
||||
}
|
||||
|
||||
let drives = action.data;
|
||||
|
||||
if (!_.isArray(drives) || !_.every(drives, _.isObject)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid drives: ${drives}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Drives order is a list of devicePaths
|
||||
const drivesOrder = settings.getSync('drivesOrder') ?? [];
|
||||
|
||||
drives = _.sortBy(drives, [
|
||||
// System drives last
|
||||
(d) => !!d.isSystem,
|
||||
// Devices with no devicePath first (usbboot)
|
||||
(d) => !!d.devicePath,
|
||||
// Sort as defined in the drivesOrder setting if there is one (only for Linux with udev)
|
||||
(d) => drivesOrder.indexOf(basename(d.devicePath || '')),
|
||||
// Then sort by devicePath (only available on Linux with udev) or device
|
||||
(d) => d.devicePath || d.device,
|
||||
]);
|
||||
|
||||
const newState = state.set('availableDrives', Immutable.fromJS(drives));
|
||||
const selectedDevices = newState.getIn(['selection', 'devices']).toJS();
|
||||
|
||||
// Remove selected drives that are stale, i.e. missing from availableDrives
|
||||
const nonStaleNewState = _.reduce(
|
||||
selectedDevices,
|
||||
(accState, device) => {
|
||||
// Check whether the drive still exists in availableDrives
|
||||
if (
|
||||
device &&
|
||||
!_.find(drives, {
|
||||
device,
|
||||
})
|
||||
) {
|
||||
// Deselect this drive gone from availableDrives
|
||||
return storeReducer(accState, {
|
||||
type: Actions.DESELECT_TARGET,
|
||||
data: device,
|
||||
});
|
||||
}
|
||||
|
||||
return accState;
|
||||
},
|
||||
newState,
|
||||
);
|
||||
|
||||
const shouldAutoselectAll = Boolean(
|
||||
settings.getSync('autoSelectAllDrives'),
|
||||
);
|
||||
const AUTOSELECT_DRIVE_COUNT = 1;
|
||||
const nonStaleSelectedDevices = nonStaleNewState
|
||||
.getIn(['selection', 'devices'])
|
||||
.toJS();
|
||||
const hasSelectedDevices =
|
||||
nonStaleSelectedDevices.length >= AUTOSELECT_DRIVE_COUNT;
|
||||
const shouldAutoselectOne =
|
||||
drives.length === AUTOSELECT_DRIVE_COUNT && !hasSelectedDevices;
|
||||
|
||||
if (shouldAutoselectOne || shouldAutoselectAll) {
|
||||
// Even if there's no image selected, we need to call several
|
||||
// drive/image related checks, and `{}` works fine with them
|
||||
const image = state
|
||||
.getIn(['selection', 'image'], Immutable.fromJS({}))
|
||||
.toJS();
|
||||
|
||||
return _.reduce(
|
||||
drives,
|
||||
(accState, drive) => {
|
||||
if (
|
||||
constraints.isDriveValid(drive, image) &&
|
||||
!drive.isReadOnly &&
|
||||
constraints.isDriveSizeRecommended(drive, image) &&
|
||||
// We don't want to auto-select large drives execpt is autoSelectAllDrives is true
|
||||
(!constraints.isDriveSizeLarge(drive) || shouldAutoselectAll) &&
|
||||
// We don't want to auto-select system drives
|
||||
!constraints.isSystemDrive(drive)
|
||||
) {
|
||||
// Auto-select this drive
|
||||
return storeReducer(accState, {
|
||||
type: Actions.SELECT_TARGET,
|
||||
data: drive.device,
|
||||
});
|
||||
}
|
||||
|
||||
// Deselect this drive in case it still is selected
|
||||
return storeReducer(accState, {
|
||||
type: Actions.DESELECT_TARGET,
|
||||
data: drive.device,
|
||||
});
|
||||
},
|
||||
nonStaleNewState,
|
||||
);
|
||||
}
|
||||
|
||||
return nonStaleNewState;
|
||||
}
|
||||
|
||||
case Actions.SET_FLASH_STATE: {
|
||||
// Type: action.data : FlashStateObject
|
||||
|
||||
if (!state.get('isFlashing')) {
|
||||
throw errors.createError({
|
||||
title: "Can't set the flashing state when not flashing",
|
||||
});
|
||||
}
|
||||
|
||||
verifyNoNilFields(action.data, flashStateNoNilFields, 'flash');
|
||||
|
||||
if (!_.every(_.pick(action.data, ['active', 'failed']), _.isFinite)) {
|
||||
throw errors.createError({
|
||||
title: 'State quantity field(s) not finite number',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!_.isUndefined(action.data.percentage) &&
|
||||
!utils.isValidPercentage(action.data.percentage)
|
||||
) {
|
||||
throw errors.createError({
|
||||
title: `Invalid state percentage: ${action.data.percentage}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!_.isUndefined(action.data.eta) && !_.isNumber(action.data.eta)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid state eta: ${action.data.eta}`,
|
||||
});
|
||||
}
|
||||
|
||||
let ret = state.set('flashState', Immutable.fromJS(action.data));
|
||||
if (action.data.type === 'flashing') {
|
||||
ret = ret.set('lastAverageFlashingSpeed', action.data.averageSpeed);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
case Actions.RESET_FLASH_STATE: {
|
||||
return state
|
||||
.set('isFlashing', false)
|
||||
.set('flashState', DEFAULT_STATE.get('flashState'))
|
||||
.set('flashResults', DEFAULT_STATE.get('flashResults'))
|
||||
.set('devicePaths', DEFAULT_STATE.get('devicePaths'))
|
||||
.set('failedDeviceErrors', DEFAULT_STATE.get('failedDeviceErrors'))
|
||||
.set(
|
||||
'lastAverageFlashingSpeed',
|
||||
DEFAULT_STATE.get('lastAverageFlashingSpeed'),
|
||||
)
|
||||
.delete('flashUuid');
|
||||
}
|
||||
|
||||
case Actions.SET_FLASHING_FLAG: {
|
||||
return state
|
||||
.set('isFlashing', true)
|
||||
.set('flashUuid', uuidV4())
|
||||
.set('flashResults', DEFAULT_STATE.get('flashResults'));
|
||||
}
|
||||
|
||||
case Actions.UNSET_FLASHING_FLAG: {
|
||||
// Type: action.data : FlashResultsObject
|
||||
|
||||
if (!action.data) {
|
||||
throw errors.createError({
|
||||
title: 'Missing results',
|
||||
});
|
||||
}
|
||||
|
||||
_.defaults(action.data, {
|
||||
cancelled: false,
|
||||
skip: false,
|
||||
});
|
||||
|
||||
if (!_.isBoolean(action.data.cancelled)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid results cancelled: ${action.data.cancelled}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (action.data.cancelled && action.data.sourceChecksum) {
|
||||
throw errors.createError({
|
||||
title:
|
||||
"The sourceChecksum value can't exist if the flashing was cancelled",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
action.data.sourceChecksum &&
|
||||
!_.isString(action.data.sourceChecksum)
|
||||
) {
|
||||
throw errors.createError({
|
||||
title: `Invalid results sourceChecksum: ${action.data.sourceChecksum}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
action.data.errorCode &&
|
||||
!_.isString(action.data.errorCode) &&
|
||||
!_.isNumber(action.data.errorCode)
|
||||
) {
|
||||
throw errors.createError({
|
||||
title: `Invalid results errorCode: ${action.data.errorCode}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (action.data.results) {
|
||||
action.data.results.averageFlashingSpeed = state.get(
|
||||
'lastAverageFlashingSpeed',
|
||||
);
|
||||
}
|
||||
|
||||
if (action.data.skip) {
|
||||
return state
|
||||
.set('isFlashing', false)
|
||||
.set('flashResults', Immutable.fromJS(action.data));
|
||||
}
|
||||
|
||||
return state
|
||||
.set('isFlashing', false)
|
||||
.set('flashResults', Immutable.fromJS(action.data))
|
||||
.set('flashState', DEFAULT_STATE.get('flashState'));
|
||||
}
|
||||
|
||||
case Actions.SELECT_TARGET: {
|
||||
// Type: action.data : String
|
||||
|
||||
const device = action.data;
|
||||
|
||||
if (!device) {
|
||||
throw errors.createError({
|
||||
title: 'Missing drive',
|
||||
});
|
||||
}
|
||||
|
||||
if (!_.isString(device)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid drive: ${device}`,
|
||||
});
|
||||
}
|
||||
|
||||
const selectedDrive = _.find(getAvailableDrives(state), { device });
|
||||
|
||||
if (!selectedDrive) {
|
||||
throw errors.createError({
|
||||
title: `The drive is not available: ${device}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedDrive.isReadOnly) {
|
||||
throw errors.createError({
|
||||
title: 'The drive is write-protected',
|
||||
});
|
||||
}
|
||||
|
||||
const image = state.getIn(['selection', 'image']);
|
||||
if (
|
||||
image &&
|
||||
!constraints.isDriveLargeEnough(selectedDrive, image.toJS())
|
||||
) {
|
||||
throw errors.createError({
|
||||
title: 'The drive is not large enough',
|
||||
});
|
||||
}
|
||||
|
||||
const selectedDevices = state.getIn(['selection', 'devices']);
|
||||
|
||||
return state.setIn(['selection', 'devices'], selectedDevices.add(device));
|
||||
}
|
||||
|
||||
// TODO(jhermsmeier): Consolidate these assertions
|
||||
// with image-stream / supported-formats, and have *one*
|
||||
// place where all the image extension / format handling
|
||||
// takes place, to avoid having to check 2+ locations with different logic
|
||||
case Actions.SELECT_SOURCE: {
|
||||
// Type: action.data : ImageObject
|
||||
|
||||
if (!action.data.drive) {
|
||||
verifyNoNilFields(action.data, selectImageNoNilFields, 'image');
|
||||
}
|
||||
|
||||
if (!_.isString(action.data.path)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid image path: ${action.data.path}`,
|
||||
});
|
||||
}
|
||||
|
||||
const MINIMUM_IMAGE_SIZE = 0;
|
||||
|
||||
if (action.data.size !== undefined) {
|
||||
if (
|
||||
action.data.size < MINIMUM_IMAGE_SIZE ||
|
||||
!_.isInteger(action.data.size)
|
||||
) {
|
||||
throw errors.createError({
|
||||
title: `Invalid image size: ${action.data.size}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!_.isUndefined(action.data.compressedSize)) {
|
||||
if (
|
||||
action.data.compressedSize < MINIMUM_IMAGE_SIZE ||
|
||||
!_.isInteger(action.data.compressedSize)
|
||||
) {
|
||||
throw errors.createError({
|
||||
title: `Invalid image compressed size: ${action.data.compressedSize}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (action.data.url && !_.isString(action.data.url)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid image url: ${action.data.url}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (action.data.name && !_.isString(action.data.name)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid image name: ${action.data.name}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (action.data.logo && !_.isString(action.data.logo)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid image logo: ${action.data.logo}`,
|
||||
});
|
||||
}
|
||||
|
||||
const selectedDevices = state.getIn(['selection', 'devices']);
|
||||
|
||||
// Remove image-incompatible drives from selection with `constraints.isDriveValid`
|
||||
return _.reduce(
|
||||
selectedDevices.toJS(),
|
||||
(accState, device) => {
|
||||
const drive = _.find(getAvailableDrives(state), { device });
|
||||
if (
|
||||
!constraints.isDriveValid(drive, action.data) ||
|
||||
!constraints.isDriveSizeRecommended(drive, action.data)
|
||||
) {
|
||||
return storeReducer(accState, {
|
||||
type: Actions.DESELECT_TARGET,
|
||||
data: device,
|
||||
});
|
||||
}
|
||||
|
||||
return accState;
|
||||
},
|
||||
state,
|
||||
).setIn(['selection', 'image'], Immutable.fromJS(action.data));
|
||||
}
|
||||
|
||||
case Actions.DESELECT_TARGET: {
|
||||
// Type: action.data : String
|
||||
|
||||
if (!action.data) {
|
||||
throw errors.createError({
|
||||
title: 'Missing drive',
|
||||
});
|
||||
}
|
||||
|
||||
if (!_.isString(action.data)) {
|
||||
throw errors.createError({
|
||||
title: `Invalid drive: ${action.data}`,
|
||||
});
|
||||
}
|
||||
|
||||
const selectedDevices = state.getIn(['selection', 'devices']);
|
||||
|
||||
// Remove drive from set in state
|
||||
return state.setIn(
|
||||
['selection', 'devices'],
|
||||
selectedDevices.delete(action.data),
|
||||
);
|
||||
}
|
||||
|
||||
case Actions.DESELECT_SOURCE: {
|
||||
return state.deleteIn(['selection', 'image']);
|
||||
}
|
||||
|
||||
case Actions.SET_APPLICATION_SESSION_UUID: {
|
||||
return state.set('applicationSessionUuid', action.data);
|
||||
}
|
||||
|
||||
case Actions.SET_FLASHING_WORKFLOW_UUID: {
|
||||
return state.set('flashingWorkflowUuid', action.data);
|
||||
}
|
||||
|
||||
case Actions.SET_DEVICE_PATHS: {
|
||||
return state.set('devicePaths', action.data);
|
||||
}
|
||||
|
||||
case Actions.SET_FAILED_DEVICE_ERRORS: {
|
||||
return state.set('failedDeviceErrors', action.data);
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const store = redux.createStore(storeReducer, DEFAULT_STATE);
|
||||
|
||||
/**
|
||||
* @summary Observe the store for changes
|
||||
* @param {Function} onChange - change handler
|
||||
* @returns {Function} unsubscribe
|
||||
*/
|
||||
export function observe(onChange: (state: typeof DEFAULT_STATE) => void) {
|
||||
let currentState: typeof DEFAULT_STATE | null = null;
|
||||
|
||||
/**
|
||||
* @summary Internal change detection handler
|
||||
*/
|
||||
const changeHandler = () => {
|
||||
const nextState = store.getState();
|
||||
if (!_.isEqual(nextState, currentState)) {
|
||||
currentState = nextState;
|
||||
onChange(currentState);
|
||||
}
|
||||
};
|
||||
|
||||
changeHandler();
|
||||
|
||||
return store.subscribe(changeHandler);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const resinCorvus = require('resin-corvus/browser')
|
||||
const packageJSON = require('../../../../package.json')
|
||||
const settings = require('../models/settings')
|
||||
const { getConfig, hasProps } = require('../../../shared/utils')
|
||||
|
||||
const sentryToken = settings.get('analyticsSentryToken') ||
|
||||
_.get(packageJSON, [ 'analytics', 'sentry', 'token' ])
|
||||
const mixpanelToken = settings.get('analyticsMixpanelToken') ||
|
||||
_.get(packageJSON, [ 'analytics', 'mixpanel', 'token' ])
|
||||
|
||||
const configUrl = settings.get('configUrl') || 'https://balena.io/etcher/static/config.json'
|
||||
|
||||
const DEFAULT_PROBABILITY = 0.1
|
||||
|
||||
const services = {
|
||||
sentry: sentryToken,
|
||||
mixpanel: mixpanelToken
|
||||
}
|
||||
resinCorvus.install({
|
||||
services,
|
||||
options: {
|
||||
release: packageJSON.version,
|
||||
shouldReport: () => {
|
||||
return settings.get('errorReporting')
|
||||
},
|
||||
mixpanelDeferred: true
|
||||
}
|
||||
})
|
||||
|
||||
let mixpanelSample = DEFAULT_PROBABILITY
|
||||
|
||||
/**
|
||||
* @summary Init analytics configurations
|
||||
* @example initConfig()
|
||||
*/
|
||||
const initConfig = async () => {
|
||||
let validatedConfig = null
|
||||
try {
|
||||
const config = await getConfig(configUrl)
|
||||
const mixpanel = _.get(config, [ 'analytics', 'mixpanel' ], {})
|
||||
mixpanelSample = mixpanel.probability || DEFAULT_PROBABILITY
|
||||
if (isClientEligible(mixpanelSample)) {
|
||||
validatedConfig = validateMixpanelConfig(mixpanel)
|
||||
}
|
||||
} catch (err) {
|
||||
resinCorvus.logException(err)
|
||||
}
|
||||
resinCorvus.setConfigs({
|
||||
mixpanel: validatedConfig
|
||||
})
|
||||
}
|
||||
|
||||
initConfig()
|
||||
|
||||
/**
|
||||
* @summary Check that the client is eligible for analytics
|
||||
* @param {Object} - config
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
function isClientEligible(probability) {
|
||||
return Math.random() < probability
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Check that config has at least HTTP_PROTOCOL and api_host
|
||||
* @param {Object} - config
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
function validateMixpanelConfig (config) {
|
||||
/* eslint-disable camelcase */
|
||||
const mixpanelConfig = {
|
||||
api_host: 'https://api.mixpanel.com'
|
||||
}
|
||||
if (hasProps(config, [ 'HTTP_PROTOCOL', 'api_host' ])) {
|
||||
mixpanelConfig.api_host = `${config.HTTP_PROTOCOL}://${config.api_host}`
|
||||
}
|
||||
return mixpanelConfig
|
||||
/* eslint-enable camelcase */
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Log a debug message
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @description
|
||||
* This function sends the debug message to error reporting services.
|
||||
*
|
||||
* @param {String} message - message
|
||||
*
|
||||
* @example
|
||||
* analytics.log('Hello World');
|
||||
*/
|
||||
exports.logDebug = resinCorvus.logDebug
|
||||
|
||||
/**
|
||||
* @summary Log an event
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @description
|
||||
* This function sends the debug message to product analytics services.
|
||||
*
|
||||
* @param {String} message - message
|
||||
* @param {Object} [data] - event data
|
||||
*
|
||||
* @example
|
||||
* analytics.logEvent('Select image', {
|
||||
* image: '/dev/disk2'
|
||||
* });
|
||||
*/
|
||||
exports.logEvent = (message, data) => {
|
||||
resinCorvus.logEvent(message, { ...data, sample: mixpanelSample })
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Log an exception
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @description
|
||||
* This function logs an exception to error reporting services.
|
||||
*
|
||||
* @param {Error} exception - exception
|
||||
*
|
||||
* @example
|
||||
* analytics.logException(new Error('Something happened'));
|
||||
*/
|
||||
exports.logException = resinCorvus.logException
|
||||
122
lib/gui/app/modules/analytics.ts
Normal file
122
lib/gui/app/modules/analytics.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as _ from 'lodash';
|
||||
import * as resinCorvus from 'resin-corvus/browser';
|
||||
|
||||
import * as packageJSON from '../../../../package.json';
|
||||
import { getConfig } from '../../../shared/utils';
|
||||
import * as settings from '../models/settings';
|
||||
import { store } from '../models/store';
|
||||
|
||||
const DEFAULT_PROBABILITY = 0.1;
|
||||
|
||||
async function installCorvus(): Promise<void> {
|
||||
const sentryToken =
|
||||
(await settings.get('analyticsSentryToken')) ||
|
||||
_.get(packageJSON, ['analytics', 'sentry', 'token']);
|
||||
const mixpanelToken =
|
||||
(await settings.get('analyticsMixpanelToken')) ||
|
||||
_.get(packageJSON, ['analytics', 'mixpanel', 'token']);
|
||||
resinCorvus.install({
|
||||
services: {
|
||||
sentry: sentryToken,
|
||||
mixpanel: mixpanelToken,
|
||||
},
|
||||
options: {
|
||||
release: packageJSON.version,
|
||||
shouldReport: () => {
|
||||
return settings.getSync('errorReporting');
|
||||
},
|
||||
mixpanelDeferred: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let mixpanelSample = DEFAULT_PROBABILITY;
|
||||
|
||||
/**
|
||||
* @summary Init analytics configurations
|
||||
*/
|
||||
async function initConfig() {
|
||||
await installCorvus();
|
||||
let validatedConfig = null;
|
||||
try {
|
||||
const configUrl = await settings.get('configUrl');
|
||||
const config = await getConfig(configUrl);
|
||||
const mixpanel = _.get(config, ['analytics', 'mixpanel'], {});
|
||||
mixpanelSample = mixpanel.probability || DEFAULT_PROBABILITY;
|
||||
if (isClientEligible(mixpanelSample)) {
|
||||
validatedConfig = validateMixpanelConfig(mixpanel);
|
||||
}
|
||||
} catch (err) {
|
||||
resinCorvus.logException(err);
|
||||
}
|
||||
resinCorvus.setConfigs({
|
||||
mixpanel: validatedConfig,
|
||||
});
|
||||
}
|
||||
|
||||
initConfig();
|
||||
|
||||
/**
|
||||
* @summary Check that the client is eligible for analytics
|
||||
*/
|
||||
function isClientEligible(probability: number) {
|
||||
return Math.random() < probability;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Check that config has at least HTTP_PROTOCOL and api_host
|
||||
*/
|
||||
function validateMixpanelConfig(config: {
|
||||
api_host?: string;
|
||||
HTTP_PROTOCOL?: string;
|
||||
}) {
|
||||
const mixpanelConfig = {
|
||||
api_host: 'https://api.mixpanel.com',
|
||||
};
|
||||
if (config.HTTP_PROTOCOL !== undefined && config.api_host !== undefined) {
|
||||
mixpanelConfig.api_host = `${config.HTTP_PROTOCOL}://${config.api_host}`;
|
||||
}
|
||||
return mixpanelConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Log an event
|
||||
*
|
||||
* @description
|
||||
* This function sends the debug message to product analytics services.
|
||||
*/
|
||||
export function logEvent(message: string, data: _.Dictionary<any> = {}) {
|
||||
const { applicationSessionUuid, flashingWorkflowUuid } = store
|
||||
.getState()
|
||||
.toJS();
|
||||
resinCorvus.logEvent(message, {
|
||||
...data,
|
||||
sample: mixpanelSample,
|
||||
applicationSessionUuid,
|
||||
flashingWorkflowUuid,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Log an exception
|
||||
*
|
||||
* @description
|
||||
* This function logs an exception to error reporting services.
|
||||
*/
|
||||
export const logException = resinCorvus.logException;
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const sdk = require('etcher-sdk')
|
||||
const process = require('process')
|
||||
|
||||
const settings = require('../models/settings')
|
||||
|
||||
/**
|
||||
* @summary returns true if system drives should be shown
|
||||
* @function
|
||||
*
|
||||
* @returns {Boolean}
|
||||
*
|
||||
* @example
|
||||
* const shouldInclude = includeSystemDrives()
|
||||
*/
|
||||
const includeSystemDrives = () => {
|
||||
return settings.get('unsafeMode') && !settings.get('disableUnsafeMode')
|
||||
}
|
||||
|
||||
const adapters = [
|
||||
new sdk.scanner.adapters.BlockDeviceAdapter(includeSystemDrives)
|
||||
]
|
||||
|
||||
// Can't use permissions.isElevated() here as it returns a promise and we need to set
|
||||
// module.exports = scanner right now.
|
||||
// eslint-disable-next-line no-magic-numbers
|
||||
if ((process.platform !== 'linux') || (process.geteuid() === 0)) {
|
||||
adapters.push(new sdk.scanner.adapters.UsbbootDeviceAdapter())
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
adapters.push(new sdk.scanner.adapters.DriverlessDeviceAdapter())
|
||||
}
|
||||
|
||||
const scanner = new sdk.scanner.Scanner(adapters)
|
||||
|
||||
module.exports = scanner
|
||||
45
lib/gui/app/modules/drive-scanner.ts
Normal file
45
lib/gui/app/modules/drive-scanner.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as sdk from 'etcher-sdk';
|
||||
import {
|
||||
Adapter,
|
||||
BlockDeviceAdapter,
|
||||
UsbbootDeviceAdapter,
|
||||
} from 'etcher-sdk/build/scanner/adapters';
|
||||
import { geteuid, platform } from 'process';
|
||||
|
||||
const adapters: Adapter[] = [
|
||||
new BlockDeviceAdapter({
|
||||
includeSystemDrives: () => true,
|
||||
}),
|
||||
];
|
||||
|
||||
// Can't use permissions.isElevated() here as it returns a promise and we need to set
|
||||
// module.exports = scanner right now.
|
||||
if (platform !== 'linux' || geteuid() === 0) {
|
||||
adapters.push(new UsbbootDeviceAdapter());
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
const {
|
||||
DriverlessDeviceAdapter: driverless,
|
||||
// tslint:disable-next-line:no-var-requires
|
||||
} = require('etcher-sdk/build/scanner/adapters/driverless');
|
||||
adapters.push(new driverless());
|
||||
}
|
||||
|
||||
export const scanner = new sdk.scanner.Scanner(adapters);
|
||||
@@ -14,8 +14,16 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
.caption {
|
||||
font-weight: bold;
|
||||
font-size: 11px;
|
||||
margin-bottom: 0;
|
||||
import { logException } from '../modules/analytics';
|
||||
import { showError } from '../os/dialog';
|
||||
|
||||
/**
|
||||
* @summary Report an exception
|
||||
*/
|
||||
export function report(exception?: Error) {
|
||||
if (exception === undefined) {
|
||||
return;
|
||||
}
|
||||
showError(exception);
|
||||
logException(exception);
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const Bluebird = require('bluebird')
|
||||
const _ = require('lodash')
|
||||
const path = require('path')
|
||||
const os = require('os')
|
||||
const ipc = require('node-ipc')
|
||||
const electron = require('electron')
|
||||
const store = require('../models/store')
|
||||
const settings = require('../models/settings')
|
||||
const flashState = require('../models/flash-state')
|
||||
const errors = require('../../../shared/errors')
|
||||
const permissions = require('../../../shared/permissions')
|
||||
const windowProgress = require('../os/window-progress')
|
||||
const analytics = require('../modules/analytics')
|
||||
const updateLock = require('./update-lock')
|
||||
const packageJSON = require('../../../../package.json')
|
||||
const selectionState = require('../models/selection-state')
|
||||
|
||||
/**
|
||||
* @summary Number of threads per CPU to allocate to the UV_THREADPOOL
|
||||
* @type {Number}
|
||||
* @constant
|
||||
*/
|
||||
const THREADS_PER_CPU = 16
|
||||
|
||||
/**
|
||||
* @summary Handle a flash error and log it to analytics
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @param {Error} error - error object
|
||||
* @param {Object} analyticsData - analytics object
|
||||
*
|
||||
* @example
|
||||
* handleErrorLogging({ code: 'EUNPLUGGED' }, { image: 'balena.img' })
|
||||
*/
|
||||
const handleErrorLogging = (error, analyticsData) => {
|
||||
const eventData = _.assign({
|
||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
|
||||
flashInstanceUuid: flashState.getFlashUuid()
|
||||
}, analyticsData)
|
||||
|
||||
if (error.code === 'EVALIDATION') {
|
||||
analytics.logEvent('Validation error', eventData)
|
||||
} else if (error.code === 'EUNPLUGGED') {
|
||||
analytics.logEvent('Drive unplugged', eventData)
|
||||
} else if (error.code === 'EIO') {
|
||||
analytics.logEvent('Input/output error', eventData)
|
||||
} else if (error.code === 'ENOSPC') {
|
||||
analytics.logEvent('Out of space', eventData)
|
||||
} else if (error.code === 'ECHILDDIED') {
|
||||
analytics.logEvent('Child died unexpectedly', eventData)
|
||||
} else {
|
||||
analytics.logEvent('Flash error', _.merge({
|
||||
error: errors.toJSON(error)
|
||||
}, eventData))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Perform write operation
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @description
|
||||
* This function is extracted for testing purposes.
|
||||
*
|
||||
* @param {String} image - image path
|
||||
* @param {Array<String>} drives - drives
|
||||
* @param {Function} onProgress - in progress callback (state)
|
||||
*
|
||||
* @fulfil {Object} - flash results
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @example
|
||||
* imageWriter.performWrite('path/to/image.img', [ '/dev/disk2' ], (state) => {
|
||||
* console.log(state.percentage)
|
||||
* })
|
||||
*/
|
||||
exports.performWrite = (image, drives, onProgress) => {
|
||||
// There might be multiple Etcher instances running at
|
||||
// the same time, therefore we must ensure each IPC
|
||||
// server/client has a different name.
|
||||
const IPC_SERVER_ID = `etcher-server-${process.pid}`
|
||||
const IPC_CLIENT_ID = `etcher-client-${process.pid}`
|
||||
|
||||
ipc.config.id = IPC_SERVER_ID
|
||||
ipc.config.socketRoot = path.join(process.env.XDG_RUNTIME_DIR || os.tmpdir(), path.sep)
|
||||
|
||||
// NOTE: Ensure this isn't disabled, as it will cause
|
||||
// the stdout maxBuffer size to be exceeded when flashing
|
||||
ipc.config.silent = true
|
||||
ipc.serve()
|
||||
|
||||
/**
|
||||
* @summary Safely terminate the IPC server
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @example
|
||||
* terminateServer()
|
||||
*/
|
||||
const terminateServer = () => {
|
||||
// Turns out we need to destroy all sockets for
|
||||
// the server to actually close. Otherwise, it
|
||||
// just stops receiving any further connections,
|
||||
// but remains open if there are active ones.
|
||||
_.each(ipc.server.sockets, (socket) => {
|
||||
socket.destroy()
|
||||
})
|
||||
|
||||
ipc.server.stop()
|
||||
}
|
||||
|
||||
return new Bluebird((resolve, reject) => {
|
||||
ipc.server.on('error', (error) => {
|
||||
terminateServer()
|
||||
const errorObject = errors.fromJSON(error)
|
||||
reject(errorObject)
|
||||
})
|
||||
|
||||
ipc.server.on('log', (message) => {
|
||||
console.log(message)
|
||||
})
|
||||
|
||||
const flashResults = {}
|
||||
const analyticsData = {
|
||||
image,
|
||||
drives,
|
||||
driveCount: drives.length,
|
||||
uuid: flashState.getFlashUuid(),
|
||||
flashInstanceUuid: flashState.getFlashUuid(),
|
||||
unmountOnSuccess: settings.get('unmountOnSuccess'),
|
||||
validateWriteOnSuccess: settings.get('validateWriteOnSuccess'),
|
||||
trim: settings.get('trim')
|
||||
}
|
||||
|
||||
ipc.server.on('fail', ({ device, error }) => {
|
||||
handleErrorLogging(error, analyticsData)
|
||||
})
|
||||
|
||||
ipc.server.on('done', (event) => {
|
||||
event.results.errors = _.map(event.results.errors, (data) => {
|
||||
return errors.fromJSON(data)
|
||||
})
|
||||
_.merge(flashResults, event)
|
||||
})
|
||||
|
||||
ipc.server.on('abort', () => {
|
||||
terminateServer()
|
||||
resolve({
|
||||
cancelled: true
|
||||
})
|
||||
})
|
||||
|
||||
ipc.server.on('state', onProgress)
|
||||
|
||||
ipc.server.on('ready', (data, socket) => {
|
||||
ipc.server.emit(socket, 'write', {
|
||||
imagePath: image,
|
||||
destinations: drives,
|
||||
validateWriteOnSuccess: settings.get('validateWriteOnSuccess'),
|
||||
trim: settings.get('trim'),
|
||||
unmountOnSuccess: settings.get('unmountOnSuccess')
|
||||
})
|
||||
})
|
||||
|
||||
const argv = _.attempt(() => {
|
||||
let entryPoint = electron.remote.app.getAppPath()
|
||||
|
||||
// AppImages run over FUSE, so the files inside the mount point
|
||||
// can only be accessed by the user that mounted the AppImage.
|
||||
// This means we can't re-spawn Etcher as root from the same
|
||||
// mount-point, and as a workaround, we re-mount the original
|
||||
// AppImage as root.
|
||||
if (os.platform() === 'linux' && process.env.APPIMAGE && process.env.APPDIR) {
|
||||
entryPoint = _.replace(entryPoint, process.env.APPDIR, '')
|
||||
return [
|
||||
process.env.APPIMAGE,
|
||||
'-e',
|
||||
`require(\`\${process.env.APPDIR}${entryPoint}\`)`
|
||||
]
|
||||
}
|
||||
return [
|
||||
_.first(process.argv),
|
||||
entryPoint
|
||||
]
|
||||
})
|
||||
|
||||
ipc.server.on('start', () => {
|
||||
console.log(`Elevating command: ${_.join(argv, ' ')}`)
|
||||
|
||||
const env = _.assign({}, process.platform === 'win32' ? {} : process.env, {
|
||||
IPC_SERVER_ID,
|
||||
IPC_CLIENT_ID,
|
||||
IPC_SOCKET_ROOT: ipc.config.socketRoot,
|
||||
ELECTRON_RUN_AS_NODE: 1,
|
||||
UV_THREADPOOL_SIZE: os.cpus().length * THREADS_PER_CPU,
|
||||
|
||||
// This environment variable prevents the AppImages
|
||||
// desktop integration script from presenting the
|
||||
// "installation" dialog
|
||||
SKIP: 1
|
||||
})
|
||||
|
||||
permissions.elevateCommand(argv, {
|
||||
applicationName: packageJSON.displayName,
|
||||
environment: env
|
||||
}).then((results) => {
|
||||
flashResults.cancelled = results.cancelled
|
||||
console.log('Flash results', flashResults)
|
||||
|
||||
// This likely means the child died halfway through
|
||||
if (!flashResults.cancelled && !_.get(flashResults, [ 'results', 'bytesWritten' ])) {
|
||||
throw errors.createUserError({
|
||||
title: 'The writer process ended unexpectedly',
|
||||
description: 'Please try again, and contact the Etcher team if the problem persists',
|
||||
code: 'ECHILDDIED'
|
||||
})
|
||||
}
|
||||
|
||||
resolve(flashResults)
|
||||
}).catch((error) => {
|
||||
// This happens when the child is killed using SIGKILL
|
||||
const SIGKILL_EXIT_CODE = 137
|
||||
if (error.code === SIGKILL_EXIT_CODE) {
|
||||
error.code = 'ECHILDDIED'
|
||||
}
|
||||
reject(error)
|
||||
}).finally(() => {
|
||||
console.log('Terminating IPC server')
|
||||
terminateServer()
|
||||
})
|
||||
})
|
||||
|
||||
// Clear the update lock timer to prevent longer
|
||||
// flashing timing it out, and releasing the lock
|
||||
updateLock.pause()
|
||||
ipc.server.start()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Flash an image to drives
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @description
|
||||
* This function will update `imageWriter.state` with the current writing state.
|
||||
*
|
||||
* @param {String} image - image path
|
||||
* @param {Array<String>} drives - drives
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @example
|
||||
* imageWriter.flash('foo.img', [ '/dev/disk2' ]).then(() => {
|
||||
* console.log('Write completed!')
|
||||
* })
|
||||
*/
|
||||
exports.flash = (image, drives) => {
|
||||
if (flashState.isFlashing()) {
|
||||
return Bluebird.reject(new Error('There is already a flash in progress'))
|
||||
}
|
||||
|
||||
flashState.setFlashingFlag()
|
||||
|
||||
const analyticsData = {
|
||||
image,
|
||||
drives,
|
||||
driveCount: drives.length,
|
||||
uuid: flashState.getFlashUuid(),
|
||||
status: 'started',
|
||||
flashInstanceUuid: flashState.getFlashUuid(),
|
||||
unmountOnSuccess: settings.get('unmountOnSuccess'),
|
||||
validateWriteOnSuccess: settings.get('validateWriteOnSuccess'),
|
||||
trim: settings.get('trim'),
|
||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
|
||||
}
|
||||
|
||||
analytics.logEvent('Flash', analyticsData)
|
||||
|
||||
return exports.performWrite(image, drives, (state) => {
|
||||
flashState.setProgressState(state)
|
||||
}).then(flashState.unsetFlashingFlag).then(() => {
|
||||
if (flashState.wasLastFlashCancelled()) {
|
||||
const eventData = _.assign({ status: 'cancel' }, analyticsData)
|
||||
analytics.logEvent('Elevation cancelled', eventData)
|
||||
} else {
|
||||
const { results } = flashState.getFlashResults()
|
||||
const eventData = _.assign({
|
||||
errors: results.errors,
|
||||
devices: results.devices,
|
||||
status: 'finished'
|
||||
},
|
||||
analyticsData)
|
||||
analytics.logEvent('Done', eventData)
|
||||
}
|
||||
}).catch((error) => {
|
||||
flashState.unsetFlashingFlag({
|
||||
errorCode: error.code
|
||||
})
|
||||
|
||||
// eslint-disable-next-line no-magic-numbers
|
||||
if (drives.length > 1) {
|
||||
const { results } = flashState.getFlashResults()
|
||||
const eventData = _.assign({
|
||||
errors: results.errors,
|
||||
devices: results.devices,
|
||||
status: 'failed'
|
||||
},
|
||||
analyticsData)
|
||||
analytics.logEvent('Write failed', eventData)
|
||||
}
|
||||
|
||||
return Bluebird.reject(error)
|
||||
}).finally(() => {
|
||||
windowProgress.clear()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Cancel write operation
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @example
|
||||
* imageWriter.cancel()
|
||||
*/
|
||||
exports.cancel = () => {
|
||||
const drives = selectionState.getSelectedDevices()
|
||||
const analyticsData = {
|
||||
image: selectionState.getImagePath(),
|
||||
drives,
|
||||
driveCount: drives.length,
|
||||
uuid: flashState.getFlashUuid(),
|
||||
flashInstanceUuid: flashState.getFlashUuid(),
|
||||
unmountOnSuccess: settings.get('unmountOnSuccess'),
|
||||
validateWriteOnSuccess: settings.get('validateWriteOnSuccess'),
|
||||
trim: settings.get('trim'),
|
||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
|
||||
status: 'cancel'
|
||||
}
|
||||
analytics.logEvent('Cancel', analyticsData)
|
||||
|
||||
// Re-enable lock release on inactivity
|
||||
updateLock.resume()
|
||||
|
||||
try {
|
||||
const [ socket ] = ipc.server.sockets
|
||||
// eslint-disable-next-line no-undefined
|
||||
if (socket !== undefined) {
|
||||
ipc.server.emit(socket, 'cancel')
|
||||
}
|
||||
} catch (error) {
|
||||
analytics.logException(error)
|
||||
}
|
||||
}
|
||||
355
lib/gui/app/modules/image-writer.ts
Normal file
355
lib/gui/app/modules/image-writer.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Drive as DrivelistDrive } from 'drivelist';
|
||||
import * as sdk from 'etcher-sdk';
|
||||
import { Dictionary } from 'lodash';
|
||||
import * as ipc from 'node-ipc';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import * as packageJSON from '../../../../package.json';
|
||||
import * as errors from '../../../shared/errors';
|
||||
import * as permissions from '../../../shared/permissions';
|
||||
import { getAppPath } from '../../../shared/utils';
|
||||
import { SourceMetadata } from '../components/source-selector/source-selector';
|
||||
import * as flashState from '../models/flash-state';
|
||||
import * as selectionState from '../models/selection-state';
|
||||
import * as settings from '../models/settings';
|
||||
import * as analytics from '../modules/analytics';
|
||||
import * as windowProgress from '../os/window-progress';
|
||||
|
||||
const THREADS_PER_CPU = 16;
|
||||
|
||||
// There might be multiple Etcher instances running at
|
||||
// the same time, therefore we must ensure each IPC
|
||||
// server/client has a different name.
|
||||
const IPC_SERVER_ID = `etcher-server-${process.pid}`;
|
||||
const IPC_CLIENT_ID = `etcher-client-${process.pid}`;
|
||||
|
||||
ipc.config.id = IPC_SERVER_ID;
|
||||
ipc.config.socketRoot = path.join(
|
||||
process.env.XDG_RUNTIME_DIR || os.tmpdir(),
|
||||
path.sep,
|
||||
);
|
||||
|
||||
// NOTE: Ensure this isn't disabled, as it will cause
|
||||
// the stdout maxBuffer size to be exceeded when flashing
|
||||
ipc.config.silent = true;
|
||||
|
||||
/**
|
||||
* @summary Handle a flash error and log it to analytics
|
||||
*/
|
||||
function handleErrorLogging(
|
||||
error: Error & { code: string },
|
||||
analyticsData: any,
|
||||
) {
|
||||
const eventData = {
|
||||
...analyticsData,
|
||||
flashInstanceUuid: flashState.getFlashUuid(),
|
||||
};
|
||||
|
||||
if (error.code === 'EVALIDATION') {
|
||||
analytics.logEvent('Validation error', eventData);
|
||||
} else if (error.code === 'EUNPLUGGED') {
|
||||
analytics.logEvent('Drive unplugged', eventData);
|
||||
} else if (error.code === 'EIO') {
|
||||
analytics.logEvent('Input/output error', eventData);
|
||||
} else if (error.code === 'ENOSPC') {
|
||||
analytics.logEvent('Out of space', eventData);
|
||||
} else if (error.code === 'ECHILDDIED') {
|
||||
analytics.logEvent('Child died unexpectedly', eventData);
|
||||
} else {
|
||||
analytics.logEvent('Flash error', {
|
||||
...eventData,
|
||||
error: errors.toJSON(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function terminateServer() {
|
||||
// Turns out we need to destroy all sockets for
|
||||
// the server to actually close. Otherwise, it
|
||||
// just stops receiving any further connections,
|
||||
// but remains open if there are active ones.
|
||||
// @ts-ignore (no Server.sockets in @types/node-ipc)
|
||||
for (const socket of ipc.server.sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
ipc.server.stop();
|
||||
}
|
||||
|
||||
function writerArgv(): string[] {
|
||||
let entryPoint = path.join(getAppPath(), 'generated', 'child-writer.js');
|
||||
// AppImages run over FUSE, so the files inside the mount point
|
||||
// can only be accessed by the user that mounted the AppImage.
|
||||
// This means we can't re-spawn Etcher as root from the same
|
||||
// mount-point, and as a workaround, we re-mount the original
|
||||
// AppImage as root.
|
||||
if (os.platform() === 'linux' && process.env.APPIMAGE && process.env.APPDIR) {
|
||||
entryPoint = entryPoint.replace(process.env.APPDIR, '');
|
||||
return [
|
||||
process.env.APPIMAGE,
|
||||
'-e',
|
||||
`require(\`\${process.env.APPDIR}${entryPoint}\`)`,
|
||||
];
|
||||
} else {
|
||||
return [process.argv[0], entryPoint];
|
||||
}
|
||||
}
|
||||
|
||||
function writerEnv() {
|
||||
return {
|
||||
IPC_SERVER_ID,
|
||||
IPC_CLIENT_ID,
|
||||
IPC_SOCKET_ROOT: ipc.config.socketRoot,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
UV_THREADPOOL_SIZE: (os.cpus().length * THREADS_PER_CPU).toString(),
|
||||
// This environment variable prevents the AppImages
|
||||
// desktop integration script from presenting the
|
||||
// "installation" dialog
|
||||
SKIP: '1',
|
||||
...(process.platform === 'win32' ? {} : process.env),
|
||||
};
|
||||
}
|
||||
|
||||
interface FlashResults {
|
||||
skip?: boolean;
|
||||
cancelled?: boolean;
|
||||
results?: {
|
||||
bytesWritten: number;
|
||||
devices: {
|
||||
failed: number;
|
||||
successful: number;
|
||||
};
|
||||
errors: Error[];
|
||||
};
|
||||
}
|
||||
|
||||
async function performWrite(
|
||||
image: SourceMetadata,
|
||||
drives: DrivelistDrive[],
|
||||
onProgress: sdk.multiWrite.OnProgressFunction,
|
||||
): Promise<{ cancelled?: boolean }> {
|
||||
let cancelled = false;
|
||||
let skip = false;
|
||||
ipc.serve();
|
||||
const { autoBlockmapping, decompressFirst } = await settings.getAll();
|
||||
return await new Promise((resolve, reject) => {
|
||||
ipc.server.on('error', (error) => {
|
||||
terminateServer();
|
||||
const errorObject = errors.fromJSON(error);
|
||||
reject(errorObject);
|
||||
});
|
||||
|
||||
ipc.server.on('log', (message) => {
|
||||
console.log(message);
|
||||
});
|
||||
|
||||
const flashResults: FlashResults = {};
|
||||
const analyticsData = {
|
||||
image,
|
||||
drives,
|
||||
driveCount: drives.length,
|
||||
uuid: flashState.getFlashUuid(),
|
||||
flashInstanceUuid: flashState.getFlashUuid(),
|
||||
};
|
||||
|
||||
ipc.server.on('fail', ({ device, error }) => {
|
||||
if (device.devicePath) {
|
||||
flashState.addFailedDeviceError({ device, error });
|
||||
}
|
||||
handleErrorLogging(error, analyticsData);
|
||||
});
|
||||
|
||||
ipc.server.on('done', (event) => {
|
||||
event.results.errors = event.results.errors.map(
|
||||
(data: Dictionary<any> & { message: string }) => {
|
||||
return errors.fromJSON(data);
|
||||
},
|
||||
);
|
||||
flashResults.results = event.results;
|
||||
});
|
||||
|
||||
ipc.server.on('abort', () => {
|
||||
terminateServer();
|
||||
cancelled = true;
|
||||
});
|
||||
|
||||
ipc.server.on('skip', () => {
|
||||
terminateServer();
|
||||
skip = true;
|
||||
});
|
||||
|
||||
ipc.server.on('state', onProgress);
|
||||
|
||||
ipc.server.on('ready', (_data, socket) => {
|
||||
ipc.server.emit(socket, 'write', {
|
||||
image,
|
||||
destinations: drives,
|
||||
SourceType: image.SourceType.name,
|
||||
autoBlockmapping,
|
||||
decompressFirst,
|
||||
});
|
||||
});
|
||||
|
||||
const argv = writerArgv();
|
||||
|
||||
ipc.server.on('start', async () => {
|
||||
console.log(`Elevating command: ${argv.join(' ')}`);
|
||||
const env = writerEnv();
|
||||
try {
|
||||
const results = await permissions.elevateCommand(argv, {
|
||||
applicationName: packageJSON.displayName,
|
||||
environment: env,
|
||||
});
|
||||
flashResults.cancelled = cancelled || results.cancelled;
|
||||
flashResults.skip = skip;
|
||||
} catch (error: any) {
|
||||
// This happens when the child is killed using SIGKILL
|
||||
const SIGKILL_EXIT_CODE = 137;
|
||||
if (error.code === SIGKILL_EXIT_CODE) {
|
||||
error.code = 'ECHILDDIED';
|
||||
}
|
||||
reject(error);
|
||||
} finally {
|
||||
console.log('Terminating IPC server');
|
||||
terminateServer();
|
||||
}
|
||||
console.log('Flash results', flashResults);
|
||||
|
||||
// The flash wasn't cancelled and we didn't get a 'done' event
|
||||
if (
|
||||
!flashResults.cancelled &&
|
||||
!flashResults.skip &&
|
||||
flashResults.results === undefined
|
||||
) {
|
||||
reject(
|
||||
errors.createUserError({
|
||||
title: 'The writer process ended unexpectedly',
|
||||
description:
|
||||
'Please try again, and contact the Etcher team if the problem persists',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve(flashResults);
|
||||
});
|
||||
|
||||
// Clear the update lock timer to prevent longer
|
||||
// flashing timing it out, and releasing the lock
|
||||
ipc.server.start();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Flash an image to drives
|
||||
*/
|
||||
export async function flash(
|
||||
image: SourceMetadata,
|
||||
drives: DrivelistDrive[],
|
||||
// This function is a parameter so it can be mocked in tests
|
||||
write = performWrite,
|
||||
): Promise<void> {
|
||||
if (flashState.isFlashing()) {
|
||||
throw new Error('There is already a flash in progress');
|
||||
}
|
||||
|
||||
await flashState.setFlashingFlag();
|
||||
flashState.setDevicePaths(
|
||||
drives.map((d) => d.devicePath).filter((p) => p != null) as string[],
|
||||
);
|
||||
|
||||
const analyticsData = {
|
||||
image,
|
||||
drives,
|
||||
driveCount: drives.length,
|
||||
uuid: flashState.getFlashUuid(),
|
||||
status: 'started',
|
||||
flashInstanceUuid: flashState.getFlashUuid(),
|
||||
};
|
||||
|
||||
analytics.logEvent('Flash', analyticsData);
|
||||
|
||||
try {
|
||||
const result = await write(image, drives, flashState.setProgressState);
|
||||
await flashState.unsetFlashingFlag(result);
|
||||
} catch (error: any) {
|
||||
await flashState.unsetFlashingFlag({
|
||||
cancelled: false,
|
||||
errorCode: error.code,
|
||||
});
|
||||
windowProgress.clear();
|
||||
const { results = {} } = flashState.getFlashResults();
|
||||
const eventData = {
|
||||
...analyticsData,
|
||||
errors: results.errors,
|
||||
devices: results.devices,
|
||||
status: 'failed',
|
||||
error,
|
||||
};
|
||||
analytics.logEvent('Write failed', eventData);
|
||||
throw error;
|
||||
}
|
||||
windowProgress.clear();
|
||||
if (flashState.wasLastFlashCancelled()) {
|
||||
const eventData = {
|
||||
...analyticsData,
|
||||
status: 'cancel',
|
||||
};
|
||||
analytics.logEvent('Elevation cancelled', eventData);
|
||||
} else {
|
||||
const { results = {} } = flashState.getFlashResults();
|
||||
const eventData = {
|
||||
...analyticsData,
|
||||
errors: results.errors,
|
||||
devices: results.devices,
|
||||
status: 'finished',
|
||||
bytesWritten: results.bytesWritten,
|
||||
sourceMetadata: results.sourceMetadata,
|
||||
};
|
||||
analytics.logEvent('Done', eventData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Cancel write operation
|
||||
*/
|
||||
export async function cancel(type: string) {
|
||||
const status = type.toLowerCase();
|
||||
const drives = selectionState.getSelectedDevices();
|
||||
const analyticsData = {
|
||||
image: selectionState.getImage()?.path,
|
||||
drives,
|
||||
driveCount: drives.length,
|
||||
uuid: flashState.getFlashUuid(),
|
||||
flashInstanceUuid: flashState.getFlashUuid(),
|
||||
status,
|
||||
};
|
||||
analytics.logEvent('Cancel', analyticsData);
|
||||
|
||||
// Re-enable lock release on inactivity
|
||||
|
||||
try {
|
||||
// @ts-ignore (no Server.sockets in @types/node-ipc)
|
||||
const [socket] = ipc.server.sockets;
|
||||
if (socket !== undefined) {
|
||||
ipc.server.emit(socket, status);
|
||||
}
|
||||
} catch (error: any) {
|
||||
analytics.logException(error);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/*
|
||||
* Copyright 2017 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License")
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const settings = require('../models/settings')
|
||||
const utils = require('../../../shared/utils')
|
||||
const units = require('../../../shared/units')
|
||||
|
||||
/**
|
||||
* @summary Make the progress status subtitle string
|
||||
*
|
||||
* @param {Object} state - flashing metadata
|
||||
*
|
||||
* @returns {String}
|
||||
*
|
||||
* @example
|
||||
* const status = progressStatus.fromFlashState({
|
||||
* flashing: 1,
|
||||
* verifying: 0,
|
||||
* successful: 0,
|
||||
* failed: 0,
|
||||
* percentage: 55,
|
||||
* speed: 2049
|
||||
* })
|
||||
*
|
||||
* console.log(status)
|
||||
* // '55% Flashing'
|
||||
*/
|
||||
exports.fromFlashState = (state) => {
|
||||
const isFlashing = Boolean(state.flashing)
|
||||
const isValidating = !isFlashing && Boolean(state.verifying)
|
||||
const shouldValidate = settings.get('validateWriteOnSuccess')
|
||||
const shouldUnmount = settings.get('unmountOnSuccess')
|
||||
|
||||
if (state.percentage === utils.PERCENTAGE_MINIMUM && !state.speed) {
|
||||
if (isValidating) {
|
||||
return 'Validating...'
|
||||
}
|
||||
|
||||
return 'Starting...'
|
||||
} else if (state.percentage === utils.PERCENTAGE_MAXIMUM) {
|
||||
if ((isValidating || !shouldValidate) && shouldUnmount) {
|
||||
return 'Unmounting...'
|
||||
}
|
||||
|
||||
return 'Finishing...'
|
||||
} else if (isFlashing) {
|
||||
// eslint-disable-next-line no-eq-null
|
||||
if (state.percentage != null) {
|
||||
return `${state.percentage}% Flashing`
|
||||
}
|
||||
return `${units.bytesToClosestUnit(state.position)} flashed`
|
||||
} else if (isValidating) {
|
||||
return `${state.percentage}% Validating`
|
||||
} else if (!isFlashing && !isValidating) {
|
||||
return 'Failed'
|
||||
}
|
||||
|
||||
throw new Error(`Invalid state: ${JSON.stringify(state)}`)
|
||||
}
|
||||
87
lib/gui/app/modules/progress-status.ts
Normal file
87
lib/gui/app/modules/progress-status.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2017 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License")
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as prettyBytes from 'pretty-bytes';
|
||||
import * as i18next from 'i18next';
|
||||
|
||||
export interface FlashState {
|
||||
active: number;
|
||||
failed: number;
|
||||
percentage?: number;
|
||||
speed: number;
|
||||
position: number;
|
||||
type?: 'decompressing' | 'flashing' | 'verifying';
|
||||
}
|
||||
|
||||
export function fromFlashState({
|
||||
type,
|
||||
percentage,
|
||||
position,
|
||||
}: Pick<FlashState, 'type' | 'percentage' | 'position'>): {
|
||||
status: string;
|
||||
position?: string;
|
||||
} {
|
||||
if (type === undefined) {
|
||||
return { status: i18next.t('progress.starting') };
|
||||
} else if (type === 'decompressing') {
|
||||
if (percentage == null) {
|
||||
return { status: i18next.t('progress.decompressing') };
|
||||
} else {
|
||||
return {
|
||||
position: `${percentage}%`,
|
||||
status: i18next.t('progress.decompressing'),
|
||||
};
|
||||
}
|
||||
} else if (type === 'flashing') {
|
||||
if (percentage != null) {
|
||||
if (percentage < 100) {
|
||||
return {
|
||||
position: `${percentage}%`,
|
||||
status: i18next.t('progress.flashing'),
|
||||
};
|
||||
} else {
|
||||
return { status: i18next.t('progress.finishing') };
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
status: i18next.t('progress.flashing'),
|
||||
position: `${position ? prettyBytes(position) : ''}`,
|
||||
};
|
||||
}
|
||||
} else if (type === 'verifying') {
|
||||
if (percentage == null) {
|
||||
return { status: i18next.t('progress.verifying') };
|
||||
} else if (percentage < 100) {
|
||||
return {
|
||||
position: `${percentage}%`,
|
||||
status: i18next.t('progress.verifying'),
|
||||
};
|
||||
} else {
|
||||
return { status: i18next.t('progress.finishing') };
|
||||
}
|
||||
}
|
||||
return { status: i18next.t('progress.failing') };
|
||||
}
|
||||
|
||||
export function titleFromFlashState(
|
||||
state: Pick<FlashState, 'type' | 'percentage' | 'position'>,
|
||||
): string {
|
||||
const { status, position } = fromFlashState(state);
|
||||
if (position !== undefined) {
|
||||
return `${position} ${status}`;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
/*
|
||||
* Copyright 2018 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const electron = require('electron')
|
||||
const EventEmitter = require('events')
|
||||
const createInactivityTimer = require('inactivity-timer')
|
||||
const debug = require('debug')('etcher:update-lock')
|
||||
const analytics = require('./analytics')
|
||||
const settings = require('../models/settings')
|
||||
|
||||
/* eslint-disable no-magic-numbers, callback-return */
|
||||
|
||||
/**
|
||||
* Interaction timeout in milliseconds (defaults to 5 minutes)
|
||||
* @type {Number}
|
||||
* @constant
|
||||
*/
|
||||
const INTERACTION_TIMEOUT_MS = settings.has('interactionTimeout')
|
||||
? parseInt(settings.get('interactionTimeout'), 10)
|
||||
: 5 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Balena Update Lock
|
||||
* @class
|
||||
*/
|
||||
class UpdateLock extends EventEmitter {
|
||||
/**
|
||||
* @summary Balena Update Lock
|
||||
* @example
|
||||
* new UpdateLock()
|
||||
*/
|
||||
constructor () {
|
||||
super()
|
||||
this.paused = false
|
||||
this.on('inactive', UpdateLock.onInactive)
|
||||
this.lockTimer = createInactivityTimer(INTERACTION_TIMEOUT_MS, () => {
|
||||
debug('inactive')
|
||||
this.emit('inactive')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Inactivity event handler, releases the balena update lock on inactivity
|
||||
* @private
|
||||
* @example
|
||||
* this.on('inactive', onInactive)
|
||||
*/
|
||||
static onInactive () {
|
||||
if (settings.get('resinUpdateLock')) {
|
||||
UpdateLock.check((checkError, isLocked) => {
|
||||
debug('inactive-check', Boolean(checkError))
|
||||
if (checkError) {
|
||||
analytics.logException(checkError)
|
||||
}
|
||||
if (isLocked) {
|
||||
UpdateLock.release((error) => {
|
||||
debug('inactive-release', Boolean(error))
|
||||
if (error) {
|
||||
analytics.logException(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Acquire the update lock
|
||||
* @private
|
||||
* @param {Function} callback - callback(error)
|
||||
* @example
|
||||
* UpdateLock.acquire((error) => {
|
||||
* // ...
|
||||
* })
|
||||
*/
|
||||
static acquire (callback) {
|
||||
debug('lock')
|
||||
if (settings.get('resinUpdateLock')) {
|
||||
electron.ipcRenderer.once('resin-update-lock', (event, error) => {
|
||||
callback(error)
|
||||
})
|
||||
electron.ipcRenderer.send('resin-update-lock', 'lock')
|
||||
} else {
|
||||
callback(new Error('Update lock disabled'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Release the update lock
|
||||
* @private
|
||||
* @param {Function} callback - callback(error)
|
||||
* @example
|
||||
* UpdateLock.release((error) => {
|
||||
* // ...
|
||||
* })
|
||||
*/
|
||||
static release (callback) {
|
||||
debug('unlock')
|
||||
if (settings.get('resinUpdateLock')) {
|
||||
electron.ipcRenderer.once('resin-update-lock', (event, error) => {
|
||||
callback(error)
|
||||
})
|
||||
electron.ipcRenderer.send('resin-update-lock', 'unlock')
|
||||
} else {
|
||||
callback(new Error('Update lock disabled'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Check the state of the update lock
|
||||
* @private
|
||||
* @param {Function} callback - callback(error, isLocked)
|
||||
* @example
|
||||
* UpdateLock.check((error, isLocked) => {
|
||||
* if (isLocked) {
|
||||
* // ...
|
||||
* }
|
||||
* })
|
||||
*/
|
||||
static check (callback) {
|
||||
debug('check')
|
||||
if (settings.get('resinUpdateLock')) {
|
||||
electron.ipcRenderer.once('resin-update-lock', (event, error, isLocked) => {
|
||||
callback(error, isLocked)
|
||||
})
|
||||
electron.ipcRenderer.send('resin-update-lock', 'check')
|
||||
} else {
|
||||
callback(new Error('Update lock disabled'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Extend the lock timer
|
||||
* @example
|
||||
* updateLock.extend()
|
||||
*/
|
||||
extend () {
|
||||
debug('extend')
|
||||
|
||||
if (this.paused) {
|
||||
debug('extend:paused')
|
||||
return
|
||||
}
|
||||
|
||||
this.lockTimer.signal()
|
||||
|
||||
// When extending, check that we have the lock,
|
||||
// and acquire it, if not
|
||||
if (settings.get('resinUpdateLock')) {
|
||||
UpdateLock.check((checkError, isLocked) => {
|
||||
if (checkError) {
|
||||
analytics.logException(checkError)
|
||||
}
|
||||
if (!isLocked) {
|
||||
UpdateLock.acquire((error) => {
|
||||
if (error) {
|
||||
analytics.logException(error)
|
||||
}
|
||||
debug('extend-acquire', Boolean(error))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Clear the lock timer
|
||||
* @example
|
||||
* updateLock.clearTimer()
|
||||
*/
|
||||
clearTimer () {
|
||||
debug('clear')
|
||||
this.lockTimer.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Clear the lock timer, and pause extension, avoiding triggering until resume()d
|
||||
* @example
|
||||
* updateLock.pause()
|
||||
*/
|
||||
pause () {
|
||||
debug('pause')
|
||||
this.paused = true
|
||||
this.clearTimer()
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Un-pause lock extension, and restart the timer
|
||||
* @example
|
||||
* updateLock.resume()
|
||||
*/
|
||||
resume () {
|
||||
debug('resume')
|
||||
this.paused = false
|
||||
this.extend()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UpdateLock()
|
||||
@@ -1,147 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const electron = require('electron')
|
||||
const Bluebird = require('bluebird')
|
||||
const errors = require('../../../shared/errors')
|
||||
const supportedFormats = require('../../../shared/supported-formats')
|
||||
|
||||
/**
|
||||
* @summary Current renderer BrowserWindow instance
|
||||
* @type {Object}
|
||||
* @private
|
||||
*/
|
||||
const currentWindow = electron.remote.getCurrentWindow()
|
||||
|
||||
/**
|
||||
* @summary Open an image selection dialog
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @description
|
||||
* Notice that by image, we mean *.img/*.iso/*.zip/etc files.
|
||||
*
|
||||
* @fulfil {Object} - selected image
|
||||
* @returns {Promise};
|
||||
*
|
||||
* @example
|
||||
* osDialog.selectImage().then((image) => {
|
||||
* console.log('The selected image is', image.path);
|
||||
* });
|
||||
*/
|
||||
exports.selectImage = () => {
|
||||
return new Bluebird((resolve) => {
|
||||
electron.remote.dialog.showOpenDialog(currentWindow, {
|
||||
|
||||
// This variable is set when running in GNU/Linux from
|
||||
// inside an AppImage, and represents the working directory
|
||||
// from where the AppImage was run (which might not be the
|
||||
// place where the AppImage is located). `OWD` stands for
|
||||
// "Original Working Directory".
|
||||
//
|
||||
// See: https://github.com/probonopd/AppImageKit/commit/1569d6f8540aa6c2c618dbdb5d6fcbf0003952b7
|
||||
defaultPath: process.env.OWD,
|
||||
properties: [
|
||||
'openFile',
|
||||
'treatPackageAsDirectory'
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
name: 'OS Images',
|
||||
extensions: _.sortBy(supportedFormats.getAllExtensions())
|
||||
}
|
||||
]
|
||||
}, (files) => {
|
||||
// `_.first` is smart enough to not throw and return `undefined`
|
||||
// if we pass it an `undefined` value (e.g: when the selection
|
||||
// dialog was cancelled).
|
||||
return resolve(_.first(files))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Open a warning dialog
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {Object} options - options
|
||||
* @param {String} options.title - dialog title
|
||||
* @param {String} options.description - dialog description
|
||||
* @param {String} [options.confirmationLabel="OK"] - confirmation label
|
||||
* @param {String} [options.rejectionLabel="Cancel"] - rejection label
|
||||
* @fulfil {Boolean} - whether the dialog was confirmed or not
|
||||
* @returns {Promise};
|
||||
*
|
||||
* @example
|
||||
* osDialog.showWarning({
|
||||
* title: 'This is a warning',
|
||||
* description: 'Are you sure you want to continue?',
|
||||
* confirmationLabel: 'Yes, continue',
|
||||
* rejectionLabel: 'Cancel'
|
||||
* }).then((confirmed) => {
|
||||
* if (confirmed) {
|
||||
* console.log('The dialog was confirmed');
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
exports.showWarning = (options) => {
|
||||
_.defaults(options, {
|
||||
confirmationLabel: 'OK',
|
||||
rejectionLabel: 'Cancel'
|
||||
})
|
||||
|
||||
const BUTTONS = [
|
||||
options.confirmationLabel,
|
||||
options.rejectionLabel
|
||||
]
|
||||
|
||||
const BUTTON_CONFIRMATION_INDEX = _.indexOf(BUTTONS, options.confirmationLabel)
|
||||
const BUTTON_REJECTION_INDEX = _.indexOf(BUTTONS, options.rejectionLabel)
|
||||
|
||||
return new Bluebird((resolve) => {
|
||||
electron.remote.dialog.showMessageBox(currentWindow, {
|
||||
type: 'warning',
|
||||
buttons: BUTTONS,
|
||||
defaultId: BUTTON_REJECTION_INDEX,
|
||||
cancelId: BUTTON_REJECTION_INDEX,
|
||||
title: 'Attention',
|
||||
message: options.title,
|
||||
detail: options.description
|
||||
}, (response) => {
|
||||
return resolve(response === BUTTON_CONFIRMATION_INDEX)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Show error dialog for an Error instance
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {Error} error - error
|
||||
*
|
||||
* @example
|
||||
* osDialog.showError(new Error('Foo Bar'));
|
||||
*/
|
||||
exports.showError = (error) => {
|
||||
const title = errors.getTitle(error)
|
||||
const message = errors.getDescription(error)
|
||||
electron.remote.dialog.showErrorBox(title, message)
|
||||
}
|
||||
117
lib/gui/app/os/dialog.ts
Normal file
117
lib/gui/app/os/dialog.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as electron from 'electron';
|
||||
import * as _ from 'lodash';
|
||||
|
||||
import * as errors from '../../../shared/errors';
|
||||
import * as settings from '../../../gui/app/models/settings';
|
||||
import { SUPPORTED_EXTENSIONS } from '../../../shared/supported-formats';
|
||||
import * as i18next from 'i18next';
|
||||
|
||||
async function mountSourceDrive() {
|
||||
// sourceDrivePath is the name of the link in /dev/disk/by-path
|
||||
const sourceDrivePath = await settings.get('automountOnFileSelect');
|
||||
if (sourceDrivePath) {
|
||||
try {
|
||||
await electron.ipcRenderer.invoke('mount-drive', sourceDrivePath);
|
||||
} catch (error: any) {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Open an image selection dialog
|
||||
*
|
||||
* @description
|
||||
* Notice that by image, we mean *.img/*.iso/*.zip/etc files.
|
||||
*/
|
||||
export async function selectImage(): Promise<string | undefined> {
|
||||
await mountSourceDrive();
|
||||
const options: electron.OpenDialogOptions = {
|
||||
// This variable is set when running in GNU/Linux from
|
||||
// inside an AppImage, and represents the working directory
|
||||
// from where the AppImage was run (which might not be the
|
||||
// place where the AppImage is located). `OWD` stands for
|
||||
// "Original Working Directory".
|
||||
//
|
||||
// See: https://github.com/probonopd/AppImageKit/commit/1569d6f8540aa6c2c618dbdb5d6fcbf0003952b7
|
||||
defaultPath: process.env.OWD,
|
||||
properties: ['openFile', 'treatPackageAsDirectory'],
|
||||
filters: [
|
||||
{
|
||||
name: i18next.t('source.osImages'),
|
||||
extensions: SUPPORTED_EXTENSIONS,
|
||||
},
|
||||
{
|
||||
name: i18next.t('source.allFiles'),
|
||||
extensions: ['*'],
|
||||
},
|
||||
],
|
||||
};
|
||||
const currentWindow = electron.remote.getCurrentWindow();
|
||||
const [file] = (
|
||||
await electron.remote.dialog.showOpenDialog(currentWindow, options)
|
||||
).filePaths;
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Open a warning dialog
|
||||
*/
|
||||
export async function showWarning(options: {
|
||||
confirmationLabel: string;
|
||||
rejectionLabel: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}): Promise<boolean> {
|
||||
_.defaults(options, {
|
||||
confirmationLabel: i18next.t('ok'),
|
||||
rejectionLabel: i18next.t('cancel'),
|
||||
});
|
||||
|
||||
const BUTTONS = [options.confirmationLabel, options.rejectionLabel];
|
||||
|
||||
const BUTTON_CONFIRMATION_INDEX = _.indexOf(
|
||||
BUTTONS,
|
||||
options.confirmationLabel,
|
||||
);
|
||||
const BUTTON_REJECTION_INDEX = _.indexOf(BUTTONS, options.rejectionLabel);
|
||||
|
||||
const { response } = await electron.remote.dialog.showMessageBox(
|
||||
electron.remote.getCurrentWindow(),
|
||||
{
|
||||
type: 'warning',
|
||||
buttons: BUTTONS,
|
||||
defaultId: BUTTON_REJECTION_INDEX,
|
||||
cancelId: BUTTON_REJECTION_INDEX,
|
||||
title: i18next.t('attention'),
|
||||
message: options.title,
|
||||
detail: options.description,
|
||||
},
|
||||
);
|
||||
return response === BUTTON_CONFIRMATION_INDEX;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Show error dialog for an Error instance
|
||||
*/
|
||||
export function showError(error: Error) {
|
||||
const title = errors.getTitle(error);
|
||||
const message = errors.getDescription(error);
|
||||
electron.remote.dialog.showErrorBox(title, message);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
* Copyright 2017 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const electron = require('electron')
|
||||
const settings = require('../models/settings')
|
||||
|
||||
/**
|
||||
* @summary Send a notification
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @description
|
||||
* This function makes use of Electron's notification desktop
|
||||
* integration feature. See:
|
||||
* http://electron.atom.io/docs/v0.37.5/tutorial/desktop-environment-integration/
|
||||
*
|
||||
* @param {String} title - notification title
|
||||
* @param {Object} options - options object
|
||||
* @param {String} options.body - notification body
|
||||
* @param {String} options.icon - supported icon path
|
||||
* @returns {Object} HTML5 notification instance
|
||||
*
|
||||
* @example
|
||||
* notification.send('Hello', {
|
||||
* body: 'Foo Bar Bar',
|
||||
* icon: 'icon.png'
|
||||
* });
|
||||
*/
|
||||
exports.send = (title, options) => {
|
||||
// Bail out if desktop notifications are disabled
|
||||
if (!settings.get('desktopNotifications')) {
|
||||
return null
|
||||
}
|
||||
|
||||
// `app.dock` is only defined in OS X
|
||||
if (electron.remote.app.dock) {
|
||||
electron.remote.app.dock.bounce()
|
||||
}
|
||||
|
||||
return new window.Notification(title, options)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
* Copyright 2017 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -14,27 +14,23 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
import * as electron from 'electron';
|
||||
|
||||
const _ = require('lodash')
|
||||
const analytics = require('../modules/analytics')
|
||||
const osDialog = require('../os/dialog')
|
||||
import * as settings from '../models/settings';
|
||||
|
||||
/**
|
||||
* @summary Report an exception
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {Error} exception - exception
|
||||
*
|
||||
* @example
|
||||
* exceptionReporter.report(new Error('Something happened'));
|
||||
* @summary Send a notification
|
||||
*/
|
||||
exports.report = (exception) => {
|
||||
if (_.isUndefined(exception)) {
|
||||
return
|
||||
}
|
||||
export async function send(title: string, body: string, icon: string) {
|
||||
// Bail out if desktop notifications are disabled
|
||||
if (!(await settings.get('desktopNotifications'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
osDialog.showError(exception)
|
||||
analytics.logException(exception)
|
||||
// `app.dock` is only defined in OS X
|
||||
if (electron.remote.app.dock) {
|
||||
electron.remote.app.dock.bounce();
|
||||
}
|
||||
|
||||
return new window.Notification(title, { body, icon });
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user