mirror of
https://github.com/balena-io/etcher.git
synced 2025-10-24 10:38:31 +00:00
Compare commits
904 Commits
v1.5.73
...
kyle/patch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b63fbed03 | ||
|
|
e969735955 | ||
|
|
45bb29a393 | ||
|
|
f38bca290f | ||
|
|
fb8ed5b529 | ||
|
|
09e13e9b43 | ||
|
|
13e1e8e504 | ||
|
|
acab03ad77 | ||
|
|
0a6c15f702 | ||
|
|
589ce9c28e | ||
|
|
f716c74ef7 | ||
|
|
2d7a6220cd | ||
|
|
e0b26d455c | ||
|
|
06d246e3fd | ||
|
|
67b26a5b69 | ||
|
|
b4b9db7ffa | ||
|
|
cc037d23c4 | ||
|
|
9c9c036956 | ||
|
|
7fdbc439f7 | ||
|
|
9410669294 | ||
|
|
497bb0e2cb | ||
|
|
a42be8ee74 | ||
|
|
16b50d2a71 | ||
|
|
882b385c88 | ||
|
|
059a36659e | ||
|
|
cd9cf09422 | ||
|
|
02a4067118 | ||
|
|
6fae328f1f | ||
|
|
81b0eed4d4 | ||
|
|
b786c8bc10 | ||
|
|
856b426dc9 | ||
|
|
197a8f9c57 | ||
|
|
bc4ee48c1b | ||
|
|
0d9ac71088 | ||
|
|
a0fc9bbd68 | ||
|
|
7e0519df9a | ||
|
|
bf0360e7f4 | ||
|
|
62bae7c52e | ||
|
|
802f5b2980 | ||
|
|
496f131c4b | ||
|
|
f582b0215c | ||
|
|
4c3c4babea | ||
|
|
6ec0550b4c | ||
|
|
4e9039c244 | ||
|
|
e479b95d72 | ||
|
|
926ff2b754 | ||
|
|
394b64319d | ||
|
|
96fa53b6ee | ||
|
|
9b54e2af0b | ||
|
|
b01cf3c2e1 | ||
|
|
46307d85d8 | ||
|
|
772df8f5e7 | ||
|
|
04fa3dcd8c | ||
|
|
6538864de4 | ||
|
|
480adc3426 | ||
|
|
c11db0a279 | ||
|
|
6f7570d265 | ||
|
|
ae976894a3 | ||
|
|
cd00f78c05 | ||
|
|
3c1dd6ce29 | ||
|
|
5099c6ff21 | ||
|
|
c63c98b80a | ||
|
|
6834dae281 | ||
|
|
df7854111a | ||
|
|
c0404597c0 | ||
|
|
64eafdc6f0 | ||
|
|
b51418814f | ||
|
|
748f9d9147 | ||
|
|
5c8c4ea412 | ||
|
|
e6d33eda2b | ||
|
|
324102bc73 | ||
|
|
e6182ff807 | ||
|
|
7ee174edce | ||
|
|
cbb4810260 | ||
|
|
c3257216c2 | ||
|
|
a140faaebe | ||
|
|
79200d1f79 | ||
|
|
10e2da2c00 | ||
|
|
85a49a221f | ||
|
|
1bc64bbaf8 | ||
|
|
180bd29afa | ||
|
|
48ddafd120 | ||
|
|
851219f835 | ||
|
|
286c74b41b | ||
|
|
8a0711e2a6 | ||
|
|
887ec42847 | ||
|
|
62c3c35526 | ||
|
|
1a368f55fa | ||
|
|
19d1e093fc | ||
|
|
407138c999 | ||
|
|
b5536bfc7f | ||
|
|
72af77860b | ||
|
|
8e63be2efe | ||
|
|
5f014e163e | ||
|
|
bd88e5a1ca | ||
|
|
5bd4e06cb9 | ||
|
|
46c406e8c1 | ||
|
|
615e035a5d | ||
|
|
7616c41564 | ||
|
|
d5ba1ea5e1 | ||
|
|
54d3636a22 | ||
|
|
45f6ee667d | ||
|
|
d25eda9a7d | ||
|
|
86d43a536f | ||
|
|
6c417e35a1 | ||
|
|
2b728d3c52 | ||
|
|
f3f7ecb852 | ||
|
|
41fca03c98 | ||
|
|
10caf8f1b6 | ||
|
|
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 |
@@ -7,7 +7,6 @@ indent_size = 2
|
|||||||
end_of_line = lf
|
end_of_line = lf
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
insert_final_newline = true
|
|
||||||
|
|
||||||
[*.md]
|
[*.md]
|
||||||
trim_trailing_whitespace = false
|
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)
|
# Javascript files must retain LF line-endings (to keep eslint happy)
|
||||||
*.js text eol=lf
|
*.js text eol=lf
|
||||||
*.jsx text eol=lf
|
*.jsx text eol=lf
|
||||||
@@ -27,6 +30,7 @@ Makefile text
|
|||||||
*.yml text
|
*.yml text
|
||||||
*.patch text
|
*.patch text
|
||||||
*.txt text
|
*.txt text
|
||||||
|
*.tpl text
|
||||||
CODEOWNERS text
|
CODEOWNERS text
|
||||||
*.plist text
|
*.plist text
|
||||||
|
|
||||||
@@ -58,3 +62,7 @@ CODEOWNERS text
|
|||||||
*.ttf binary diff=hex
|
*.ttf binary diff=hex
|
||||||
xz-without-extension binary diff=hex
|
xz-without-extension binary diff=hex
|
||||||
wmic-output.txt 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:**
|
- **Etcher version:**
|
||||||
- **Operating system and architecture:**
|
- **Operating system and architecture:**
|
||||||
- **Image flashed:**
|
- **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?**
|
- **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 -->
|
||||||
|
|||||||
169
.github/actions/publish/action.yml
vendored
Normal file
169
.github/actions/publish/action.yml
vendored
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
---
|
||||||
|
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: "18.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
|
||||||
|
if: runner.os != 'Windows'
|
||||||
|
shell: pwsh
|
||||||
|
working-directory: .
|
||||||
|
run: tar -xf ${{ runner.temp }}/custom.tgz
|
||||||
|
|
||||||
|
- name: Extract custom source artifact
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: pwsh
|
||||||
|
working-directory: .
|
||||||
|
run: C:\"Program Files"\Git\usr\bin\tar.exe --force-local -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'
|
||||||
|
|
||||||
|
# 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='https://739bbcfc0ba4481481138d3fc831136d@o95242.ingest.sentry.io/4504451487301632' \
|
||||||
|
--c.extraMetadata.analytics.amplitude.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 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
|
||||||
64
.github/actions/test/action.yml
vendored
Normal file
64
.github/actions/test/action.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
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: "16.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
|
||||||
|
if: runner.os != 'Windows'
|
||||||
|
shell: pwsh
|
||||||
|
run: tar -acf ${{ runner.temp }}/custom.tgz .
|
||||||
|
|
||||||
|
- name: Compress custom source
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: pwsh
|
||||||
|
run: C:\"Program Files"\Git\usr\bin\tar.exe --force-local -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
|
||||||
24
.github/workflows/flowzone.yml
vendored
Normal file
24
.github/workflows/flowzone.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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
|
||||||
|
cloudflare_website: "etcher"
|
||||||
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 }}
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -28,6 +28,7 @@ pids
|
|||||||
|
|
||||||
# Generated files
|
# Generated files
|
||||||
/generated
|
/generated
|
||||||
|
/binaries
|
||||||
|
|
||||||
# Dependency directory
|
# Dependency directory
|
||||||
# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git
|
# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git
|
||||||
@@ -47,3 +48,13 @@ node_modules
|
|||||||
# OSX files
|
# OSX files
|
||||||
|
|
||||||
.DS_Store
|
.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
|
|
||||||
|
|
||||||
13351
.versionbot/CHANGELOG.yml
Normal file
13351
.versionbot/CHANGELOG.yml
Normal file
File diff suppressed because it is too large
Load Diff
1676
CHANGELOG.md
1676
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
|||||||
* @thundron @zvin @jviotti
|
|
||||||
/scripts @nazrhom
|
|
||||||
78
Makefile
78
Makefile
@@ -3,18 +3,12 @@
|
|||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
|
|
||||||
RESIN_SCRIPTS ?= ./scripts/resin
|
RESIN_SCRIPTS ?= ./scripts/resin
|
||||||
export NPM_VERSION ?= 6.7.0
|
export NPM_VERSION ?= 6.14.8
|
||||||
S3_BUCKET = artifacts.ci.balena-cloud.com
|
S3_BUCKET = artifacts.ci.balena-cloud.com
|
||||||
|
|
||||||
# This directory will be completely deleted by the `clean` rule
|
# This directory will be completely deleted by the `clean` rule
|
||||||
BUILD_DIRECTORY ?= dist
|
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_TEMPORARY_DIRECTORY = $(BUILD_DIRECTORY)/.tmp
|
||||||
|
|
||||||
$(BUILD_DIRECTORY):
|
$(BUILD_DIRECTORY):
|
||||||
@@ -23,9 +17,7 @@ $(BUILD_DIRECTORY):
|
|||||||
$(BUILD_TEMPORARY_DIRECTORY): | $(BUILD_DIRECTORY)
|
$(BUILD_TEMPORARY_DIRECTORY): | $(BUILD_DIRECTORY)
|
||||||
mkdir $@
|
mkdir $@
|
||||||
|
|
||||||
# See https://stackoverflow.com/a/13468229/1641422
|
|
||||||
SHELL := /bin/bash
|
SHELL := /bin/bash
|
||||||
PATH := $(shell pwd)/node_modules/.bin:$(PATH)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
# Operating system and architecture detection
|
# Operating system and architecture detection
|
||||||
@@ -74,6 +66,9 @@ else
|
|||||||
ifeq ($(shell uname -m),x86_64)
|
ifeq ($(shell uname -m),x86_64)
|
||||||
HOST_ARCH = x64
|
HOST_ARCH = x64
|
||||||
endif
|
endif
|
||||||
|
ifeq ($(shell uname -m),arm64)
|
||||||
|
HOST_ARCH = aarch64
|
||||||
|
endif
|
||||||
endif
|
endif
|
||||||
endif
|
endif
|
||||||
|
|
||||||
@@ -93,12 +88,10 @@ TARGET_ARCH ?= $(HOST_ARCH)
|
|||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
# Electron
|
# Electron
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
electron-develop: | $(BUILD_TEMPORARY_DIRECTORY)
|
electron-develop:
|
||||||
$(RESIN_SCRIPTS)/electron/install.sh \
|
git submodule update --init && \
|
||||||
-b $(shell pwd) \
|
npm ci && \
|
||||||
-r $(TARGET_ARCH) \
|
npm run webpack
|
||||||
-s $(PLATFORM) \
|
|
||||||
-m $(NPM_VERSION)
|
|
||||||
|
|
||||||
electron-test:
|
electron-test:
|
||||||
$(RESIN_SCRIPTS)/electron/test.sh \
|
$(RESIN_SCRIPTS)/electron/test.sh \
|
||||||
@@ -124,62 +117,20 @@ TARGETS = \
|
|||||||
help \
|
help \
|
||||||
info \
|
info \
|
||||||
lint \
|
lint \
|
||||||
lint-js \
|
|
||||||
lint-sass \
|
|
||||||
lint-cpp \
|
|
||||||
lint-spell \
|
|
||||||
test-spectron \
|
|
||||||
test-gui \
|
|
||||||
test \
|
test \
|
||||||
sanity-checks \
|
|
||||||
clean \
|
clean \
|
||||||
distclean \
|
distclean \
|
||||||
webpack \
|
|
||||||
electron-develop \
|
electron-develop \
|
||||||
electron-test \
|
electron-test \
|
||||||
electron-build
|
electron-build
|
||||||
|
|
||||||
webpack:
|
|
||||||
./node_modules/.bin/webpack
|
|
||||||
|
|
||||||
.PHONY: $(TARGETS)
|
.PHONY: $(TARGETS)
|
||||||
|
|
||||||
sass:
|
lint:
|
||||||
npm rebuild node-sass
|
npm run lint
|
||||||
node-sass lib/gui/app/scss/main.scss > lib/gui/css/main.css
|
|
||||||
|
|
||||||
lint-ts:
|
test:
|
||||||
resin-lint --typescript typings lib tests scripts/clean-shrinkwrap.ts webpack.config.ts
|
npm run test
|
||||||
|
|
||||||
lint-sass:
|
|
||||||
sass-lint lib/gui/scss
|
|
||||||
|
|
||||||
lint-cpp:
|
|
||||||
cpplint --recursive src
|
|
||||||
|
|
||||||
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-sass lint-cpp 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/runner.spec.ts
|
|
||||||
|
|
||||||
test-gui:
|
|
||||||
electron-mocha $(MOCHA_OPTIONS) --full-trace --no-sandbox --renderer tests/gui/**/*.ts
|
|
||||||
|
|
||||||
test-sdk:
|
|
||||||
electron-mocha $(MOCHA_OPTIONS) --full-trace --no-sandbox tests/shared/**/*.ts
|
|
||||||
|
|
||||||
test: test-gui test-sdk test-spectron
|
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Available targets: $(TARGETS)"
|
@echo "Available targets: $(TARGETS)"
|
||||||
@@ -189,16 +140,11 @@ info:
|
|||||||
@echo "Host arch : $(HOST_ARCH)"
|
@echo "Host arch : $(HOST_ARCH)"
|
||||||
@echo "Target arch : $(TARGET_ARCH)"
|
@echo "Target arch : $(TARGET_ARCH)"
|
||||||
|
|
||||||
sanity-checks:
|
|
||||||
./scripts/ci/ensure-staged-sass.sh
|
|
||||||
./scripts/ci/ensure-all-file-extensions-in-gitattributes.sh
|
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf $(BUILD_DIRECTORY)
|
rm -rf $(BUILD_DIRECTORY)
|
||||||
|
|
||||||
distclean: clean
|
distclean: clean
|
||||||
rm -rf node_modules
|
rm -rf node_modules
|
||||||
rm -rf build
|
|
||||||
rm -rf dist
|
rm -rf dist
|
||||||
rm -rf generated
|
rm -rf generated
|
||||||
rm -rf $(BUILD_TEMPORARY_DIRECTORY)
|
rm -rf $(BUILD_TEMPORARY_DIRECTORY)
|
||||||
|
|||||||
105
README.md
105
README.md
@@ -5,16 +5,15 @@
|
|||||||
Etcher is a powerful OS image flasher built with web technologies to ensure
|
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
|
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
|
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://balena.io/etcher)
|
||||||
[](https://github.com/balena-io/etcher/blob/master/LICENSE)
|
[](https://github.com/balena-io/etcher/blob/master/LICENSE)
|
||||||
[](https://david-dm.org/balena-io/etcher)
|
|
||||||
[](https://forums.balena.io/c/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
|
## 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
|
- macOS 10.10 (Yosemite) and later
|
||||||
- Microsoft Windows 7 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
|
[Electron][electron]. Read more in their
|
||||||
[documentation][electron-supported-platforms].
|
[documentation][electron-supported-platforms].
|
||||||
|
|
||||||
@@ -31,94 +30,62 @@ Note that Etcher will run on any platform officially supported by
|
|||||||
Refer to the [downloads page][etcher] for the latest pre-made
|
Refer to the [downloads page][etcher] for the latest pre-made
|
||||||
installers for all supported operating systems.
|
installers for all supported operating systems.
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
#### Debian and Ubuntu based Package Repository (GNU/Linux x86/x64)
|
#### Debian and Ubuntu based Package Repository (GNU/Linux x86/x64)
|
||||||
|
|
||||||
1. Add Etcher debian repository:
|
Package for Debian and Ubuntu can be downloaded from the [Github release page](https://github.com/balena-io/etcher/releases/)
|
||||||
|
|
||||||
|
##### Install .deb file using apt
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
echo "deb https://deb.etcher.io stable etcher" | sudo tee /etc/apt/sources.list.d/balena-etcher.list
|
sudo apt install ./balena-etcher_******_amd64.deb
|
||||||
```
|
|
||||||
|
|
||||||
2. Trust Bintray.com's GPG key:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 379CE192D401AB61
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Update and install:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install balena-etcher-electron
|
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Uninstall
|
##### Uninstall
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo apt-get remove balena-etcher-electron
|
sudo apt remove balena-etcher
|
||||||
sudo rm /etc/apt/sources.list.d/balena-etcher.list
|
|
||||||
sudo apt-get update
|
|
||||||
```
|
|
||||||
#### Redhat (RHEL) and Fedora based Package Repository (GNU/Linux x86/x64)
|
|
||||||
|
|
||||||
1. Add Etcher rpm repository:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo wget https://balena.io/etcher/static/etcher-rpm.repo -O /etc/yum.repos.d/etcher-rpm.repo
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Update and install:
|
#### Redhat (RHEL) and Fedora-based Package Repository (GNU/Linux x86/x64)
|
||||||
|
|
||||||
|
##### Yum
|
||||||
|
|
||||||
|
Package for Fedora-based and Redhat can be downloaded from the [Github release page](https://github.com/balena-io/etcher/releases/)
|
||||||
|
|
||||||
|
1. Install using yum
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo yum install -y balena-etcher-electron
|
sudo yum localinstall balena-etcher-***.x86_64.rpm
|
||||||
```
|
```
|
||||||
or
|
|
||||||
|
#### Arch/Manjaro Linux (GNU/Linux x64)
|
||||||
|
|
||||||
|
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
|
```sh
|
||||||
sudo dnf install -y balena-etcher-electron
|
yay -S balena-etcher
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Uninstall
|
##### Uninstall
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo yum remove -y balena-etcher-electron
|
yay -R balena-etcher
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Solus (GNU/Linux x64)
|
#### WinGet (Windows)
|
||||||
|
|
||||||
|
This package is updated by [gh-action](https://github.com/vedantmgoyal2009/winget-releaser), and is kept up to date automatically.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo eopkg it etcher
|
winget install balenaEtcher #or Balena.Etcher
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Uninstall
|
##### Uninstall
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo eopkg rm etcher
|
winget uninstall balenaEtcher
|
||||||
```
|
|
||||||
|
|
||||||
#### Brew Cask (macOS)
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
brew cask install balenaetcher
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Uninstall
|
|
||||||
|
|
||||||
```sh
|
|
||||||
brew cask uninstall balenaetcher
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Chocolatey (Windows)
|
#### Chocolatey (Windows)
|
||||||
@@ -138,20 +105,20 @@ choco uninstall etcher
|
|||||||
|
|
||||||
## Support
|
## 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.
|
the balena.io team will be happy to help.
|
||||||
|
|
||||||
## License
|
## 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].
|
the [license].
|
||||||
|
|
||||||
[etcher]: https://balena.io/etcher
|
[etcher]: https://balena.io/etcher
|
||||||
[electron]: https://electronjs.org/
|
[electron]: https://electronjs.org/
|
||||||
[electron-supported-platforms]: https://electronjs.org/docs/tutorial/support#supported-platforms
|
[electron-supported-platforms]: https://electronjs.org/docs/tutorial/support#supported-platforms
|
||||||
[SUPPORT]: https://github.com/balena-io/etcher/blob/master/SUPPORT.md
|
[support]: https://github.com/balena-io/etcher/blob/master/docs/SUPPORT.md
|
||||||
[CONTRIBUTING]: https://github.com/balena-io/etcher/blob/master/docs/CONTRIBUTING.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
|
[user-documentation]: https://github.com/balena-io/etcher/blob/master/docs/USER-DOCUMENTATION.md
|
||||||
[milestones]: https://github.com/balena-io/etcher/milestones
|
[milestones]: https://github.com/balena-io/etcher/milestones
|
||||||
[newissue]: https://github.com/balena-io/etcher/issues/new
|
[newissue]: https://github.com/balena-io/etcher/issues/new
|
||||||
[license]: https://github.com/balena-io/etcher/blob/master/LICENSE
|
[license]: https://github.com/balena-io/etcher/blob/master/LICENSE
|
||||||
|
|||||||
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])
|
cp.execFileSync('mv', [scriptPath, binPath])
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
scriptPath,
|
scriptPath,
|
||||||
outdent`
|
outdent({trimTrailingNewline: false})`
|
||||||
#!/bin/bash
|
#!/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
|
if [[ $EUID -ne 0 ]] || [[ $ELECTRON_RUN_AS_NODE ]]; then
|
||||||
"\${BASH_SOURCE%/*}"/${context.packager.executableName}.bin "$@"
|
"\${script_dir}"/${context.packager.executableName}.bin "$@"
|
||||||
else
|
else
|
||||||
"\${BASH_SOURCE%/*}"/${context.packager.executableName}.bin "$@" --no-sandbox
|
"\${script_dir}"/${context.packager.executableName}.bin "$@" --no-sandbox
|
||||||
fi
|
fi
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
const { notarize } = require('electron-notarize')
|
const { notarize } = require('electron-notarize')
|
||||||
|
const { ELECTRON_SKIP_NOTARIZATION } = process.env
|
||||||
|
|
||||||
async function main(context) {
|
async function main(context) {
|
||||||
const { electronPlatformName, appOutDir } = context
|
const { electronPlatformName, appOutDir } = context
|
||||||
if (electronPlatformName !== 'darwin') {
|
if (electronPlatformName !== 'darwin' || ELECTRON_SKIP_NOTARIZATION === 'true') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const appName = context.packager.appInfo.productFilename
|
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({
|
await notarize({
|
||||||
appBundleId: 'io.balena.etcher',
|
appBundleId: 'io.balena.etcher',
|
||||||
appPath: `${appOutDir}/${appName}.app`,
|
appPath: `${appOutDir}/${appName}.app`,
|
||||||
appleId,
|
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++"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
} ]
|
|
||||||
],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -14,9 +14,7 @@ technologies used in Etcher that you should become familiar with:
|
|||||||
- [NodeJS][nodejs]
|
- [NodeJS][nodejs]
|
||||||
- [Redux][redux]
|
- [Redux][redux]
|
||||||
- [ImmutableJS][immutablejs]
|
- [ImmutableJS][immutablejs]
|
||||||
- [Bootstrap][bootstrap]
|
|
||||||
- [Sass][sass]
|
- [Sass][sass]
|
||||||
- [Flexbox Grid][flexbox-grid]
|
|
||||||
- [Mocha][mocha]
|
- [Mocha][mocha]
|
||||||
- [JSDoc][jsdoc]
|
- [JSDoc][jsdoc]
|
||||||
|
|
||||||
@@ -67,8 +65,6 @@ be documented instead!
|
|||||||
[nodejs]: https://nodejs.org
|
[nodejs]: https://nodejs.org
|
||||||
[redux]: http://redux.js.org
|
[redux]: http://redux.js.org
|
||||||
[immutablejs]: http://facebook.github.io/immutable-js/
|
[immutablejs]: http://facebook.github.io/immutable-js/
|
||||||
[bootstrap]: http://getbootstrap.com
|
|
||||||
[sass]: http://sass-lang.com
|
[sass]: http://sass-lang.com
|
||||||
[flexbox-grid]: http://flexboxgrid.com
|
|
||||||
[mocha]: http://mochajs.org
|
[mocha]: http://mochajs.org
|
||||||
[jsdoc]: http://usejsdoc.org
|
[jsdoc]: http://usejsdoc.org
|
||||||
|
|||||||
@@ -12,67 +12,29 @@ over the commit history.
|
|||||||
- Be able to automatically reference relevant changes from a dependency
|
- Be able to automatically reference relevant changes from a dependency
|
||||||
upgrade.
|
upgrade.
|
||||||
|
|
||||||
The guidelines are inspired by the [AngularJS git commit
|
|
||||||
guidelines][angular-commit-guidelines].
|
|
||||||
|
|
||||||
Commit structure
|
Commit structure
|
||||||
----------------
|
----------------
|
||||||
|
|
||||||
Each commit message consists of a header, a body and a footer. The header has a
|
Each commit message needs to specify the semver-type. Which can be `patch|minor|major`.
|
||||||
special format that includes a type, a scope and a subject.
|
See the [Semantic Versioning][semver] specification for a more detailed explanation of the meaning of these types.
|
||||||
|
See balena commit guidelines for more info about the whole commit structure.
|
||||||
|
|
||||||
```
|
```
|
||||||
<type>(<scope>): <subject>
|
<semver-type>: <subject>
|
||||||
|
```
|
||||||
|
or
|
||||||
|
```
|
||||||
|
<subject>
|
||||||
<BLANK LINE>
|
<BLANK LINE>
|
||||||
<body>
|
<details>
|
||||||
<BLANK LINE>
|
<BLANK LINE>
|
||||||
<footer>
|
Change-Type: <semver-type>
|
||||||
```
|
```
|
||||||
|
|
||||||
The subject should not contain more than 70 characters, including the type and
|
The subject should not contain more than 70 characters, including the type and
|
||||||
scope, and the body should be wrapped at 72 characters.
|
scope, and the body should be wrapped at 72 characters.
|
||||||
|
|
||||||
Type
|
|
||||||
----
|
|
||||||
|
|
||||||
Must be one of the following:
|
|
||||||
|
|
||||||
- `feat`: A new feature.
|
|
||||||
- `fix`: A bug fix.
|
|
||||||
- `minifix`: A minimal fix that doesn't warrant an entry in the CHANGELOG.
|
|
||||||
- `docs`: Documentation only changes.
|
|
||||||
- `style`: Changes that do not affect the meaning of the code (white-space,
|
|
||||||
formatting, missing semi-colons, JSDoc annotations, comments, etc).
|
|
||||||
- `refactor`: A code change that neither fixes a bug nor adds a feature.
|
|
||||||
- `perf`: A code change that improves performance.
|
|
||||||
- `test`: Adding missing tests.
|
|
||||||
- `chore`: Changes to the build process or auxiliary tools and libraries.
|
|
||||||
- `upgrade`: A version upgrade of a project dependency.
|
|
||||||
|
|
||||||
Scope
|
|
||||||
-----
|
|
||||||
|
|
||||||
The scope is required for types that make sense, such as `feat`, `fix`,
|
|
||||||
`test`, etc. Certain commit types, such as `chore` might not have a clearly
|
|
||||||
defined scope, in which case its better to omit it.
|
|
||||||
|
|
||||||
Subject
|
|
||||||
-------
|
|
||||||
|
|
||||||
The subject should contain a short description of the change:
|
|
||||||
|
|
||||||
- Use the imperative, present tense.
|
|
||||||
- Don't capitalize the first letter.
|
|
||||||
- No dot (.) at the end.
|
|
||||||
|
|
||||||
Footer
|
|
||||||
------
|
|
||||||
|
|
||||||
The footer contains extra information about the commit, such as tags.
|
|
||||||
|
|
||||||
**Breaking Changes** should start with the word BREAKING CHANGE: with a space
|
|
||||||
or two newlines. The rest of the commit message is then used for this.
|
|
||||||
|
|
||||||
Tags
|
Tags
|
||||||
----
|
----
|
||||||
|
|
||||||
@@ -121,125 +83,4 @@ Closes: https://github.com/balena-io/etcher/issues/XXX
|
|||||||
Fixes: https://github.com/balena-io/etcher/issues/XXX
|
Fixes: https://github.com/balena-io/etcher/issues/XXX
|
||||||
```
|
```
|
||||||
|
|
||||||
### `Change-Type: <type>`
|
|
||||||
|
|
||||||
This tag is used to determine the change type that a commit introduces. The
|
|
||||||
following types are supported:
|
|
||||||
|
|
||||||
- `major`
|
|
||||||
- `minor`
|
|
||||||
- `patch`
|
|
||||||
|
|
||||||
This tag can be omitted for commits that don't change the application from the
|
|
||||||
user's point of view, such as for refactoring commits.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```
|
|
||||||
Change-Type: major
|
|
||||||
Change-Type: minor
|
|
||||||
Change-Type: patch
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [Semantic Versioning][semver] specification for a more detailed
|
|
||||||
explanation of the meaning of these types.
|
|
||||||
|
|
||||||
### `Changelog-Entry: <message>`
|
|
||||||
|
|
||||||
This tag is used to describe the changes introduced by the commit in a more
|
|
||||||
human style that would fit the `CHANGELOG.md` better.
|
|
||||||
|
|
||||||
If the commit type is either `fix` or `feat`, the commit will take part in the
|
|
||||||
CHANGELOG. If this tag is not defined, then the commit subject will be used
|
|
||||||
instead.
|
|
||||||
|
|
||||||
You explicitly can use this tag to make a commit whose type is not `fix` nor
|
|
||||||
`feat` appear in the `CHANGELOG.md`.
|
|
||||||
|
|
||||||
Since whatever your write here will be shown *as it is* in the `CHANGELOG.md`,
|
|
||||||
take some time to write a decent entry. Consider the following guidelines:
|
|
||||||
|
|
||||||
- Use the imperative, present tense.
|
|
||||||
- Capitalize the first letter.
|
|
||||||
|
|
||||||
There is no fixed length limit for the contents of this tag, but always strive
|
|
||||||
to make as short as possible without compromising its quality.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```
|
|
||||||
Changelog-Entry: Fix EPERM errors when flashing to a GPT drive.
|
|
||||||
```
|
|
||||||
|
|
||||||
Complete examples
|
|
||||||
-----------------
|
|
||||||
|
|
||||||
```
|
|
||||||
fix(GUI): ignore extensions before the first non-compressed extension
|
|
||||||
|
|
||||||
Currently, we extract all the extensions from an image path and report back
|
|
||||||
that the image is invalid if *any* of the extensions is not valid , however
|
|
||||||
this can cause trouble with images including information between dots that are
|
|
||||||
not strictly extensions, for example:
|
|
||||||
|
|
||||||
elementaryos-0.3.2-stable-i386.20151209.iso
|
|
||||||
|
|
||||||
Etcher will consider `20151209` to be an invalid extension and therefore
|
|
||||||
will prevent such image from being selected at all.
|
|
||||||
|
|
||||||
As a way to allow these corner cases but still make use of our enforced check
|
|
||||||
controls, the validation routine now only consider extensions starting from the
|
|
||||||
first non compressed extension.
|
|
||||||
|
|
||||||
Change-Type: patch
|
|
||||||
Changelog-Entry: Don't interpret image file name information between dots as image extensions.
|
|
||||||
Fixes: https://github.com/balena-io/etcher/issues/492
|
|
||||||
```
|
|
||||||
|
|
||||||
***
|
|
||||||
|
|
||||||
```
|
|
||||||
upgrade: etcher-image-write to v5.0.2
|
|
||||||
|
|
||||||
This version contains a fix to an `EPERM` issue happening to some Windows user,
|
|
||||||
triggered by the `write` system call during the first ~5% of a flash given that
|
|
||||||
the operating system still thinks the drive has a file system.
|
|
||||||
|
|
||||||
Change-Type: patch
|
|
||||||
Changelog-Entry: Upgrade `etcher-image-write` to v5.0.2.
|
|
||||||
Link: https://github.com/balena-io-modules/etcher-image-write/blob/master/CHANGELOG.md#502---2016-06-27
|
|
||||||
Fixes: https://github.com/balena-io/etcher/issues/531
|
|
||||||
```
|
|
||||||
|
|
||||||
***
|
|
||||||
|
|
||||||
```
|
|
||||||
feat(GUI): implement update notifier functionality
|
|
||||||
|
|
||||||
Auto-update functionality is not ready for usage. As a workaround to
|
|
||||||
prevent users staying with older versions, we now check for updates at
|
|
||||||
startup, and if the user is not running the latest version, we present a
|
|
||||||
modal informing the user of the availiblity of a new version, and
|
|
||||||
provide a call to action to open the Etcher website in his web browser.
|
|
||||||
|
|
||||||
Extra features:
|
|
||||||
|
|
||||||
- The user can skip the update, and tell the program to delay the
|
|
||||||
notification for 7 days.
|
|
||||||
|
|
||||||
Misc changes:
|
|
||||||
|
|
||||||
- Center modal with flexbox, to allow more flexibility on the modal height.
|
|
||||||
interacting with the S3 server.
|
|
||||||
- Implement `ManifestBindService`, which now serves as a backend for the
|
|
||||||
`manifest-bind` directive to allow the directive's functionality to be
|
|
||||||
re-used by other services.
|
|
||||||
- Namespace checkbox styles that are specific to the settings page.
|
|
||||||
|
|
||||||
Change-Type: minor
|
|
||||||
Changelog-Entry: Check for updates and show a modal prompting the user to download the latest version.
|
|
||||||
Closes: https://github.com/balena-io/etcher/issues/396
|
|
||||||
```
|
|
||||||
|
|
||||||
[angular-commit-guidelines]: https://github.com/angular/angular.js/blob/master/CONTRIBUTING.md#commit
|
|
||||||
[semver]: http://semver.org
|
[semver]: http://semver.org
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ Developing
|
|||||||
|
|
||||||
#### Common
|
#### Common
|
||||||
|
|
||||||
- [NodeJS](https://nodejs.org) (at least v6.11)
|
- [NodeJS](https://nodejs.org) (at least v16.11)
|
||||||
- [Python 2.7](https://www.python.org)
|
- [Python 3](https://www.python.org)
|
||||||
- [jq](https://stedolan.github.io/jq/)
|
- [jq](https://stedolan.github.io/jq/)
|
||||||
- [curl](https://curl.haxx.se/)
|
- [curl](https://curl.haxx.se/)
|
||||||
- [npm](https://www.npmjs.com/) (version 6.7)
|
- [npm](https://www.npmjs.com/)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
@@ -33,16 +33,16 @@ You might need to run this with `sudo` or administrator permissions.
|
|||||||
|
|
||||||
- [NSIS v2.51](http://nsis.sourceforge.net/Main_Page) (v3.x won't work)
|
- [NSIS v2.51](http://nsis.sourceforge.net/Main_Page) (v3.x won't work)
|
||||||
- Either one of the following:
|
- Either one of the following:
|
||||||
- [Visual C++ 2015 Build Tools](http://landinghub.visualstudio.com/visual-cpp-build-tools) containing standalone compilers, libraries and scripts
|
- [Visual C++ 2019 Build Tools](https://visualstudio.microsoft.com/vs/features/cplusplus/) containing standalone compilers, libraries and scripts
|
||||||
- Install the [windows-build-tools](https://github.com/felixrieseberg/windows-build-tools) via npm with `npm install --global windows-build-tools`
|
- The [windows-build-tools](https://github.com/felixrieseberg/windows-build-tools#windows-build-tools) should be installed along with NodeJS
|
||||||
- [Visual Studio Community 2015](https://www.microsoft.com/en-us/download/details.aspx?id=48146) (free) (other editions, like Professional and Enterprise, should work too)
|
- [Visual Studio Community 2019](https://visualstudio.microsoft.com/vs/) (free) (other editions, like Professional and Enterprise, should work too)
|
||||||
**NOTE:** Visual Studio 2015 doesn't install C++ by default. You have to rerun the
|
**NOTE:** Visual Studio doesn't install C++ by default. You have to rerun the
|
||||||
setup, select "Modify" and then check `Visual C++ -> Common Tools for Visual
|
setup, select "Modify" and then check `Visual C++ -> Common Tools for Visual
|
||||||
C++ 2015` (see http://stackoverflow.com/a/31955339)
|
C++` (see http://stackoverflow.com/a/31955339)
|
||||||
- [MinGW](http://www.mingw.org)
|
- [MinGW](http://www.mingw.org)
|
||||||
|
|
||||||
You might need to `npm config set msvs_version 2015` for node-gyp to correctly detect
|
You might need to `npm config set msvs_version 2019` for node-gyp to correctly detect
|
||||||
the version of Visual Studio you're using (in this example VS2015).
|
the version of Visual Studio you're using (in this example VS2019).
|
||||||
|
|
||||||
The following MinGW packages are required:
|
The following MinGW packages are required:
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ as well.
|
|||||||
|
|
||||||
#### Linux
|
#### Linux
|
||||||
|
|
||||||
- `libudev-dev` for libusb (install with `sudo apt install libudev-dev` for example)
|
- `libudev-dev` for libusb (for example install with `sudo apt install libudev-dev`, or on fedora `systemd-devel` contains the required package)
|
||||||
|
|
||||||
### Cloning the project
|
### Cloning the project
|
||||||
|
|
||||||
@@ -70,28 +70,13 @@ git clone --recursive https://github.com/balena-io/etcher
|
|||||||
cd etcher
|
cd etcher
|
||||||
```
|
```
|
||||||
|
|
||||||
### Installing npm dependencies
|
|
||||||
|
|
||||||
**NOTE:** Please make use of the following command to install npm dependencies rather
|
|
||||||
than simply running `npm install` given that we need to do extra configuration
|
|
||||||
to make sure native dependencies are correctly compiled for Electron, otherwise
|
|
||||||
the application might not run successfully.
|
|
||||||
|
|
||||||
If you're on Windows, **run the command from the _Developer Command Prompt for
|
|
||||||
VS2015_**, to ensure all Visual Studio command utilities are available in the
|
|
||||||
`%PATH%`.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
make electron-develop
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running the application
|
### Running the application
|
||||||
|
|
||||||
#### GUI
|
#### GUI
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Build the GUI
|
# Build the GUI
|
||||||
make webpack
|
npm run webpack #or npm run build
|
||||||
# Start Electron
|
# Start Electron
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
@@ -119,10 +104,6 @@ systems as they can before sending a pull request.
|
|||||||
*The test suite is run automatically by CI servers when you send a pull
|
*The test suite is run automatically by CI servers when you send a pull
|
||||||
request.*
|
request.*
|
||||||
|
|
||||||
We also rely on various `make` targets to perform some common tasks:
|
|
||||||
|
|
||||||
- `make lint`: Run the linter.
|
|
||||||
- `make sass`: Compile SCSS files.
|
|
||||||
|
|
||||||
We make use of [EditorConfig] to communicate indentation, line endings and
|
We make use of [EditorConfig] to communicate indentation, line endings and
|
||||||
other text editing default. We encourage you to install the relevant plugin in
|
other text editing default. We encourage you to install the relevant plugin in
|
||||||
@@ -132,20 +113,7 @@ process.
|
|||||||
Updating a dependency
|
Updating a dependency
|
||||||
---------------------
|
---------------------
|
||||||
|
|
||||||
Given we use [npm shrinkwrap][shrinkwrap], we have to take extra steps to make
|
- Commit *both* `package.json` and `package-lock.json`.
|
||||||
sure the `npm-shrinkwrap.json` file gets updated correctly when we update a
|
|
||||||
dependency.
|
|
||||||
|
|
||||||
Use the following steps to ensure everything goes flawlessly:
|
|
||||||
|
|
||||||
- Run `make electron-develop` to ensure you don't have extraneous dependencies
|
|
||||||
you might have brought during development, or you are running older
|
|
||||||
dependencies because you come from another branch or reference.
|
|
||||||
|
|
||||||
- Install the new version of the dependency. For example: `npm install --save
|
|
||||||
<package>@<version>`. This will update the `npm-shrinkwrap.json` file.
|
|
||||||
|
|
||||||
- Commit *both* `package.json` and `npm-shrinkwrap.json`.
|
|
||||||
|
|
||||||
Diffing Binaries
|
Diffing Binaries
|
||||||
----------------
|
----------------
|
||||||
|
|||||||
@@ -37,10 +37,16 @@ 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.
|
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.
|
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.
|
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?
|
## 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).
|
||||||
|
|
||||||
|
## Can I use the Flash With Etcher button on my site?
|
||||||
|
|
||||||
|
You can use the Flash with Etcher button on your site or blog, if you have an OS that you want your users to be able to easily flash using Etcher, add the following code where you want to button to be:
|
||||||
|
|
||||||
|
`<a href="https://efp.balena.io/open-image-url?imageUrl=<your image URL>"><img src="http://balena.io/flash-with-etcher.png" /></a>`
|
||||||
@@ -8,10 +8,14 @@ Releasing
|
|||||||
|
|
||||||
### Release Types
|
### Release Types
|
||||||
|
|
||||||
- **snapshot** (default): A continues snapshot of current master, made by the CI services
|
- **draft**: A continues snapshot of current master, made by the CI services
|
||||||
- **production**: Full releases
|
- **pre-release** (default): A continues snapshot of current master, made by the CI services
|
||||||
|
- **release**: Full releases
|
||||||
|
|
||||||
|
Draft release is created from each PR, tagged with the branch name.
|
||||||
|
All merged PR will generate a new tag/version as a *pre-release*.
|
||||||
|
Mark the pre-release as final when it is necessary, then distribute the packages in alternative channels as necessary.
|
||||||
|
|
||||||
### Flight Plan
|
|
||||||
|
|
||||||
#### Preparation
|
#### Preparation
|
||||||
|
|
||||||
@@ -31,11 +35,10 @@ Releasing
|
|||||||
- [Post release note to forums](https://forums.balena.io/c/etcher)
|
- [Post release note to forums](https://forums.balena.io/c/etcher)
|
||||||
- [Submit Windows binaries to Symantec for whitelisting](#submitting-binaries-to-symantec)
|
- [Submit Windows binaries to Symantec for whitelisting](#submitting-binaries-to-symantec)
|
||||||
- [Update the website](https://github.com/balena-io/etcher-homepage)
|
- [Update the website](https://github.com/balena-io/etcher-homepage)
|
||||||
- Wait 2-3 hours for analytics (Sentry, Mixpanel) to trickle in and check for elevated error rates, or regressions
|
- Wait 2-3 hours for analytics (Sentry, Amplitude) to trickle in and check for elevated error rates, or regressions
|
||||||
- If regressions arise; pull the release, and release a patched version, else:
|
- If regressions arise; pull the release, and release a patched version, else:
|
||||||
- [Upload deb & rpm packages to Bintray](#uploading-packages-to-bintray)
|
- [Upload deb & rpm packages to Cloudfront](#uploading-packages-to-cloudfront)
|
||||||
- [Upload build artifacts to Amazon S3](#uploading-binaries-to-amazon-s3)
|
- Post changelog with `#release-notes` tag on internal chat
|
||||||
- Post changelog with `#release-notes` tag on Flowdock
|
|
||||||
- If this release packs noteworthy major changes:
|
- If this release packs noteworthy major changes:
|
||||||
- Write a blog post about it, and / or
|
- Write a blog post about it, and / or
|
||||||
- Write about it to the Etcher mailing list
|
- Write about it to the Etcher mailing list
|
||||||
@@ -48,7 +51,7 @@ Make sure to set the analytics tokens when generating production release binarie
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
export ANALYTICS_SENTRY_TOKEN="xxxxxx"
|
export ANALYTICS_SENTRY_TOKEN="xxxxxx"
|
||||||
export ANALYTICS_MIXPANEL_TOKEN="xxxxxx"
|
export ANALYTICS_AMPLITUDE_TOKEN="xxxxxx"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Linux
|
#### Linux
|
||||||
@@ -57,46 +60,16 @@ export ANALYTICS_MIXPANEL_TOKEN="xxxxxx"
|
|||||||
|
|
||||||
**NOTE:** Make sure to adjust the path as necessary (here the Etcher repository has been cloned to `/home/$USER/code/etcher`)
|
**NOTE:** Make sure to adjust the path as necessary (here the Etcher repository has been cloned to `/home/$USER/code/etcher`)
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/build/docker/run-command.sh -r x64 -s . -c "make distclean"
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Generating artifacts
|
##### Generating artifacts
|
||||||
|
|
||||||
```bash
|
The artifacts are generated by the CI and published as draft-release or pre-release.
|
||||||
# x64
|
`electron-builder` is used to create the packaged application.
|
||||||
|
|
||||||
# Build Debian packages
|
|
||||||
./scripts/build/docker/run-command.sh -r x64 -s . -c "make electron-develop && make RELEASE_TYPE=production electron-installer-debian"
|
|
||||||
# Build RPM packages
|
|
||||||
./scripts/build/docker/run-command.sh -r x64 -s . -c "make electron-develop && make RELEASE_TYPE=production electron-installer-redhat"
|
|
||||||
# Build AppImages
|
|
||||||
./scripts/build/docker/run-command.sh -r x64 -s . -c "make electron-develop && make RELEASE_TYPE=production electron-installer-appimage"
|
|
||||||
|
|
||||||
# x86
|
|
||||||
|
|
||||||
# Build Debian packages
|
|
||||||
./scripts/build/docker/run-command.sh -r x86 -s . -c "make electron-develop && make RELEASE_TYPE=production electron-installer-debian"
|
|
||||||
# Build RPM packages
|
|
||||||
./scripts/build/docker/run-command.sh -r x86 -s . -c "make electron-develop && make RELEASE_TYPE=production electron-installer-redhat"
|
|
||||||
# Build AppImages
|
|
||||||
./scripts/build/docker/run-command.sh -r x86 -s . -c "make electron-develop && make RELEASE_TYPE=production electron-installer-appimage"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Mac OS
|
#### Mac OS
|
||||||
|
|
||||||
**ATTENTION:** For production releases you'll need the code-signing key,
|
**ATTENTION:** For production releases you'll need the code-signing key,
|
||||||
and set `CSC_NAME` to generate signed binaries on Mac OS.
|
and set `CSC_NAME` to generate signed binaries on Mac OS.
|
||||||
|
|
||||||
```bash
|
|
||||||
make electron-develop
|
|
||||||
|
|
||||||
# Build the zip
|
|
||||||
make RELEASE_TYPE=production electron-installer-app-zip
|
|
||||||
# Build the dmg
|
|
||||||
make RELEASE_TYPE=production electron-installer-dmg
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Windows
|
#### Windows
|
||||||
|
|
||||||
**ATTENTION:** For production releases you'll need the code-signing key,
|
**ATTENTION:** For production releases you'll need the code-signing key,
|
||||||
@@ -105,38 +78,10 @@ and set `CSC_LINK`, and `CSC_KEY_PASSWORD` to generate signed binaries on Window
|
|||||||
**NOTE:**
|
**NOTE:**
|
||||||
- Keep in mind to also generate artifacts for x86, with `TARGET_ARCH=x86`.
|
- Keep in mind to also generate artifacts for x86, with `TARGET_ARCH=x86`.
|
||||||
|
|
||||||
```bash
|
|
||||||
make electron-develop
|
|
||||||
|
|
||||||
# Build the Portable version
|
### Uploading packages to Cloudfront
|
||||||
make RELEASE_TYPE=production electron-installer-portable
|
|
||||||
# Build the Installer
|
|
||||||
make RELEASE_TYPE=production electron-installer-nsis
|
|
||||||
```
|
|
||||||
|
|
||||||
### Uploading packages to Bintray
|
Log in to cloudfront and upload the `rpm` and `deb` files.
|
||||||
|
|
||||||
```bash
|
|
||||||
export BINTRAY_USER="username@account"
|
|
||||||
export BINTRAY_API_KEY="youruserapikey"
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/publish/bintray.sh -c "etcher" -t "production" -v "1.2.1" -o "etcher" -p "debian" -y "debian" -r "x64" -f "dist/etcher-electron_1.2.1_amd64.deb"
|
|
||||||
./scripts/publish/bintray.sh -c "etcher" -t "production" -v "1.2.1" -o "etcher" -p "debian" -y "debian" -r "x86" -f "dist/etcher-electron_1.2.1_i386.deb"
|
|
||||||
./scripts/publish/bintray.sh -c "etcher" -t "production" -v "1.2.1" -o "etcher" -p "redhat" -y "redhat" -r "x64" -f "dist/etcher-electron-1.2.1.x86_64.rpm"
|
|
||||||
./scripts/publish/bintray.sh -c "etcher" -t "production" -v "1.2.1" -o "etcher" -p "redhat" -y "redhat" -r "x86" -f "dist/etcher-electron-1.2.1.i686.rpm"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Uploading binaries to Amazon S3
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export S3_KEY="..."
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/publish/aws-s3.sh -b "balena-production-downloads" -v "1.2.1" -p "etcher" -f "dist/<filename>"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dealing with a Problematic Release
|
### Dealing with a Problematic Release
|
||||||
|
|
||||||
|
|||||||
@@ -112,4 +112,4 @@ Analytics
|
|||||||
- [ ] Disable analytics, open DevTools Network pane or a packet sniffer, and
|
- [ ] Disable analytics, open DevTools Network pane or a packet sniffer, and
|
||||||
check that no request is sent
|
check that no request is sent
|
||||||
- [ ] **Disable analytics, refresh application from DevTools (using Cmd-R or
|
- [ ] **Disable analytics, refresh application from DevTools (using Cmd-R or
|
||||||
F5), and check that initial events are not sent to Mixpanel**
|
F5), and check that initial events are not sent to Amplitude**
|
||||||
|
|||||||
@@ -7,44 +7,9 @@ systems.
|
|||||||
Release Types
|
Release Types
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
Etcher supports **production** and **snapshot** release types. Each is
|
Etcher supports **pre-release** and **final** release types as does Github. Each is
|
||||||
published to a different S3 bucket, and production release types are code
|
published to Github releases.
|
||||||
signed, while snapshot release types aren't and include a short git commit-hash
|
The release version is generated automatically from the commit messasges.
|
||||||
as a build number. For example, `1.0.0-beta.19` is a production release type,
|
|
||||||
while `1.0.0-beta.19+531ab82` is a snapshot release type.
|
|
||||||
|
|
||||||
In terms of comparison: `1.0.0-beta.19` (production) < `1.0.0-beta.19+531ab82`
|
|
||||||
(snapshot) < `1.0.0-rc.1` (production) < `1.0.0-rc.1+7fde24a` (snapshot) <
|
|
||||||
`1.0.0` (production) < `1.0.0+2201e5f` (snapshot). Keep in mind that if you're
|
|
||||||
running a production release type, you'll only be prompted to update to
|
|
||||||
production release types, and if you're running a snapshot release type, you'll
|
|
||||||
only be prompted to update to other snapshot release types.
|
|
||||||
|
|
||||||
The build system creates (and publishes) snapshot release types by default, but
|
|
||||||
you can build a specific release type by setting the `RELEASE_TYPE` make
|
|
||||||
variable. For example:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
make <target> RELEASE_TYPE=snapshot
|
|
||||||
make <target> RELEASE_TYPE=production
|
|
||||||
```
|
|
||||||
|
|
||||||
We can control the version range a specific Etcher version will consider when
|
|
||||||
showing the update notification dialog by tweaking the `updates.semverRange`
|
|
||||||
property of `package.json`.
|
|
||||||
|
|
||||||
Update Channels
|
|
||||||
---------------
|
|
||||||
|
|
||||||
Etcher has a setting to include the unstable update channel. If this option is
|
|
||||||
set, Etcher will consider both stable and unstable versions when showing the
|
|
||||||
update notifier dialog. Unstable versions are the ones that contain a `beta`
|
|
||||||
pre-release tag. For example:
|
|
||||||
|
|
||||||
- Production unstable version: `1.4.0-beta.1`
|
|
||||||
- Snapshot unstable version: `1.4.0-beta.1+7fde24a`
|
|
||||||
- Production stable version: `1.4.0`
|
|
||||||
- Snapshot stable version: `1.4.0+7fde24a`
|
|
||||||
|
|
||||||
Signing
|
Signing
|
||||||
-------
|
-------
|
||||||
@@ -73,63 +38,19 @@ Packaging
|
|||||||
|
|
||||||
The resulting installers will be saved to `dist/out`.
|
The resulting installers will be saved to `dist/out`.
|
||||||
|
|
||||||
Run the following commands:
|
Run the following commands on all platforms with the right arguments:
|
||||||
|
|
||||||
### OS X
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
make electron-installer-dmg
|
./node_modules/electron-builder build <...>
|
||||||
make electron-installer-app-zip
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### GNU/Linux
|
|
||||||
|
|
||||||
```sh
|
Publishing to Cloudfront
|
||||||
make electron-installer-appimage
|
|
||||||
make electron-installer-debian
|
|
||||||
```
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
|
|
||||||
```sh
|
|
||||||
make electron-installer-zip
|
|
||||||
make electron-installer-nsis
|
|
||||||
```
|
|
||||||
|
|
||||||
Publishing to Bintray
|
|
||||||
---------------------
|
---------------------
|
||||||
|
|
||||||
We publish GNU/Linux Debian packages to [Bintray][bintray].
|
We publish GNU/Linux Debian packages to [Cloudfront][cloudfront].
|
||||||
|
|
||||||
Make sure you set the following environment variables:
|
Log in to cloudfront and upload the `rpm` and `deb` files.
|
||||||
|
|
||||||
- `BINTRAY_USER`
|
|
||||||
- `BINTRAY_API_KEY`
|
|
||||||
|
|
||||||
Run the following command:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
make publish-bintray-debian
|
|
||||||
```
|
|
||||||
|
|
||||||
Publishing to S3
|
|
||||||
----------------
|
|
||||||
|
|
||||||
- [AWS CLI][aws-cli]
|
|
||||||
|
|
||||||
Make sure you have the [AWS CLI tool][aws-cli] installed and configured to
|
|
||||||
access balena.io's production or snapshot S3 bucket.
|
|
||||||
|
|
||||||
Run the following command to publish all files for the current combination of
|
|
||||||
_platform_ and _arch_ (building them if necessary):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
make publish-aws-s3
|
|
||||||
```
|
|
||||||
|
|
||||||
Also add links to each AWS S3 file in [GitHub Releases][github-releases]. See
|
|
||||||
[`v1.0.0-beta.17`](https://github.com/balena-io/etcher/releases/tag/v1.0.0-beta.17)
|
|
||||||
as an example.
|
|
||||||
|
|
||||||
Publishing to Homebrew Cask
|
Publishing to Homebrew Cask
|
||||||
---------------------------
|
---------------------------
|
||||||
@@ -147,8 +68,12 @@ Post messages to the [Etcher forum][balena-forum-etcher] announcing the new vers
|
|||||||
of Etcher, and including the relevant section of the Changelog.
|
of Etcher, and including the relevant section of the Changelog.
|
||||||
|
|
||||||
[aws-cli]: https://aws.amazon.com/cli
|
[aws-cli]: https://aws.amazon.com/cli
|
||||||
[bintray]: https://bintray.com
|
[cloudfront]: https://cloudfront.com
|
||||||
[etcher-cask-file]: https://github.com/caskroom/homebrew-cask/blob/master/Casks/balenaetcher.rb
|
[etcher-cask-file]: https://github.com/caskroom/homebrew-cask/blob/master/Casks/balenaetcher.rb
|
||||||
[homebrew-cask]: https://github.com/caskroom/homebrew-cask
|
[homebrew-cask]: https://github.com/caskroom/homebrew-cask
|
||||||
[balena-forum-etcher]: https://forums.balena.io/c/etcher
|
[balena-forum-etcher]: https://forums.balena.io/c/etcher
|
||||||
[github-releases]: https://github.com/balena-io/etcher/releases
|
[github-releases]: https://github.com/balena-io/etcher/releases
|
||||||
|
|
||||||
|
Updating EFP / Success-Banner
|
||||||
|
-----------------------------
|
||||||
|
Etcher Featured Project is automatically run based on an algorithm which promoted projects from the balena marketplace which have been contributed by the community, the algorithm prioritises projects which give uses the best experience. Editing both EFP and the Etcher Success-Banner can only be done by someone from balena, instruction are on the [Etcher-EFP repo (private)](https://github.com/balena-io/etcher-efp)
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
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
|
Make sure to mention the following information to help us provide better
|
||||||
support:
|
support:
|
||||||
|
|
||||||
- The Etcher version you're running.
|
- The BalenaEtcher version you're running.
|
||||||
|
|
||||||
- The operating system you're running Etcher in.
|
- The operating system you're running Etcher in.
|
||||||
|
|
||||||
@@ -25,10 +32,12 @@ support:
|
|||||||
GitHub
|
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
|
tracker][issues] and if there isn't a ticket covering it, [create
|
||||||
one][new-issue].
|
one][new-issue].
|
||||||
|
|
||||||
[discourse]: https://forums.balena.io/c/etcher
|
[discourse]: https://forums.balena.io/c/etcher
|
||||||
[issues]: https://github.com/balena-io/etcher/issues
|
[issues]: https://github.com/balena-io/etcher/issues
|
||||||
[new-issue]: https://github.com/balena-io/etcher/issues/new
|
[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
|
||||||
@@ -3,6 +3,11 @@ Etcher User Documentation
|
|||||||
|
|
||||||
This document contains how-tos and FAQs oriented to Etcher users.
|
This document contains how-tos and FAQs oriented to Etcher users.
|
||||||
|
|
||||||
|
Config
|
||||||
|
------
|
||||||
|
Etcher's configuration is saved to the `config.json` file in the apps folder.
|
||||||
|
Not all the options are surfaced to the UI. You may edit this file to tweak settings even before launching the app.
|
||||||
|
|
||||||
Why is my drive not bootable?
|
Why is my drive not bootable?
|
||||||
-----------------------------
|
-----------------------------
|
||||||
|
|
||||||
@@ -160,13 +165,25 @@ pre-installed in all modern Windows versions.
|
|||||||
- Run `clean`. This command will completely clean your drive by erasing any
|
- Run `clean`. This command will completely clean your drive by erasing any
|
||||||
existent filesystem.
|
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
|
### OS X
|
||||||
|
|
||||||
Run the following command in `Terminal.app`, replacing `N` by the corresponding
|
Run the following command in `Terminal.app`, replacing `N` by the corresponding
|
||||||
disk number, which you can find by running `diskutil list`:
|
disk number, which you can find by running `diskutil list`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
diskutil eraseDisk free UNTITLED /dev/diskN
|
diskutil eraseDisk FAT32 UNTITLED MBRFormat /dev/diskN
|
||||||
```
|
```
|
||||||
|
|
||||||
### GNU/Linux
|
### GNU/Linux
|
||||||
@@ -206,3 +223,5 @@ macOS 10.10 (Yosemite) and newer versions][electron-supported-platforms].
|
|||||||
[unetbootin]: https://unetbootin.github.io
|
[unetbootin]: https://unetbootin.github.io
|
||||||
[windows-iot-dashboard]: https://developer.microsoft.com/en-us/windows/iot/downloads
|
[windows-iot-dashboard]: https://developer.microsoft.com/en-us/windows/iot/downloads
|
||||||
[woeusb]: https://github.com/slacka/WoeUSB
|
[woeusb]: https://github.com/slacka/WoeUSB
|
||||||
|
|
||||||
|
See [PUBLISHING](/docs/PUBLISHING.md) for more details about release types.
|
||||||
@@ -1,39 +1,23 @@
|
|||||||
|
# https://www.electron.build/configuration/configuration
|
||||||
appId: io.balena.etcher
|
appId: io.balena.etcher
|
||||||
copyright: Copyright 2016-2019 Balena Ltd
|
copyright: Copyright 2016-2023 Balena Ltd
|
||||||
productName: balenaEtcher
|
productName: balenaEtcher
|
||||||
npmRebuild: true
|
afterPack: ./afterPack.js
|
||||||
nodeGypRebuild: true
|
afterSign: ./afterSignHook.js
|
||||||
publish: null
|
|
||||||
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
|
|
||||||
mac:
|
|
||||||
asar: false
|
asar: false
|
||||||
|
files:
|
||||||
|
- generated
|
||||||
|
- lib/shared/catalina-sudo/sudo-askpass.osascript-zh.js
|
||||||
|
- lib/shared/catalina-sudo/sudo-askpass.osascript-en.js
|
||||||
|
mac:
|
||||||
icon: assets/icon.icns
|
icon: assets/icon.icns
|
||||||
category: public.app-category.developer-tools
|
category: public.app-category.developer-tools
|
||||||
hardenedRuntime: true
|
hardenedRuntime: true
|
||||||
entitlements: "entitlements.mac.plist"
|
entitlements: "entitlements.mac.plist"
|
||||||
entitlementsInherit: "entitlements.mac.plist"
|
entitlementsInherit: "entitlements.mac.plist"
|
||||||
|
artifactName: "${productName}-${version}.${ext}"
|
||||||
|
target:
|
||||||
|
- dmg
|
||||||
dmg:
|
dmg:
|
||||||
background: assets/dmg/background.tiff
|
background: assets/dmg/background.tiff
|
||||||
icon: assets/icon.icns
|
icon: assets/icon.icns
|
||||||
@@ -50,6 +34,10 @@ dmg:
|
|||||||
height: 405
|
height: 405
|
||||||
win:
|
win:
|
||||||
icon: assets/icon.ico
|
icon: assets/icon.ico
|
||||||
|
target:
|
||||||
|
- zip
|
||||||
|
- nsis
|
||||||
|
- portable
|
||||||
nsis:
|
nsis:
|
||||||
oneClick: true
|
oneClick: true
|
||||||
runAfterFinish: true
|
runAfterFinish: true
|
||||||
@@ -62,17 +50,23 @@ portable:
|
|||||||
artifactName: "${productName}-Portable-${version}.${ext}"
|
artifactName: "${productName}-Portable-${version}.${ext}"
|
||||||
requestExecutionLevel: user
|
requestExecutionLevel: user
|
||||||
linux:
|
linux:
|
||||||
|
icon: assets/iconset
|
||||||
|
target:
|
||||||
|
- AppImage
|
||||||
|
- rpm
|
||||||
|
- deb
|
||||||
category: Utility
|
category: Utility
|
||||||
packageCategory: utils
|
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.
|
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:
|
deb:
|
||||||
priority: optional
|
priority: optional
|
||||||
|
compression: bzip2
|
||||||
depends:
|
depends:
|
||||||
- gconf2
|
|
||||||
- gconf-service
|
- gconf-service
|
||||||
- libappindicator1
|
- gconf2
|
||||||
- libasound2
|
- libasound2
|
||||||
- libatk1.0-0
|
- libatk1.0-0
|
||||||
- libc6
|
- libc6
|
||||||
@@ -82,6 +76,7 @@ deb:
|
|||||||
- libexpat1
|
- libexpat1
|
||||||
- libfontconfig1
|
- libfontconfig1
|
||||||
- libfreetype6
|
- libfreetype6
|
||||||
|
- libgbm1
|
||||||
- libgcc1
|
- libgcc1
|
||||||
- libgconf-2-4
|
- libgconf-2-4
|
||||||
- libgdk-pixbuf2.0-0
|
- libgdk-pixbuf2.0-0
|
||||||
@@ -91,7 +86,7 @@ deb:
|
|||||||
- libnotify4
|
- libnotify4
|
||||||
- libnspr4
|
- libnspr4
|
||||||
- libnss3
|
- libnss3
|
||||||
- libpango1.0-0
|
- libpango1.0-0 | libpango-1.0-0
|
||||||
- libstdc++6
|
- libstdc++6
|
||||||
- libx11-6
|
- libx11-6
|
||||||
- libxcomposite1
|
- libxcomposite1
|
||||||
@@ -105,7 +100,11 @@ deb:
|
|||||||
- libxss1
|
- libxss1
|
||||||
- libxtst6
|
- libxtst6
|
||||||
- polkit-1-auth-agent | policykit-1-gnome | polkit-kde-1
|
- polkit-1-auth-agent | policykit-1-gnome | polkit-kde-1
|
||||||
|
afterInstall: "./after-install.tpl"
|
||||||
rpm:
|
rpm:
|
||||||
depends:
|
depends:
|
||||||
- lsb
|
- util-linux
|
||||||
- libXScrnSaver
|
protocols:
|
||||||
|
name: etcher
|
||||||
|
schemes:
|
||||||
|
- etcher
|
||||||
|
|||||||
@@ -14,5 +14,11 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.get-task-allow</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-executable-page-protection</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -15,14 +15,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as electron from 'electron';
|
import * as electron from 'electron';
|
||||||
import * as sdk from 'etcher-sdk';
|
import * as remote from '@electron/remote';
|
||||||
import * as _ from 'lodash';
|
import { debounce, capitalize, Dictionary, values } from 'lodash';
|
||||||
import outdent from 'outdent';
|
import outdent from 'outdent';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import * as ReactDOM from 'react-dom';
|
import * as ReactDOM from 'react-dom';
|
||||||
import * as uuidV4 from 'uuid/v4';
|
import { v4 as uuidV4 } from 'uuid';
|
||||||
|
|
||||||
import * as packageJSON from '../../../package.json';
|
import * as packageJSON from '../../../package.json';
|
||||||
|
import { DrivelistDrive } from '../../shared/drive-constraints';
|
||||||
import * as EXIT_CODES from '../../shared/exit-codes';
|
import * as EXIT_CODES from '../../shared/exit-codes';
|
||||||
import * as messages from '../../shared/messages';
|
import * as messages from '../../shared/messages';
|
||||||
import * as availableDrives from './models/available-drives';
|
import * as availableDrives from './models/available-drives';
|
||||||
@@ -30,21 +31,22 @@ import * as flashState from './models/flash-state';
|
|||||||
import * as settings from './models/settings';
|
import * as settings from './models/settings';
|
||||||
import { Actions, observe, store } from './models/store';
|
import { Actions, observe, store } from './models/store';
|
||||||
import * as analytics from './modules/analytics';
|
import * as analytics from './modules/analytics';
|
||||||
import { scanner as driveScanner } from './modules/drive-scanner';
|
import { startApiAndSpawnChild } from './modules/api';
|
||||||
import * as exceptionReporter from './modules/exception-reporter';
|
import * as exceptionReporter from './modules/exception-reporter';
|
||||||
import { updateLock } from './modules/update-lock';
|
|
||||||
import * as osDialog from './os/dialog';
|
import * as osDialog from './os/dialog';
|
||||||
import * as windowProgress from './os/window-progress';
|
import * as windowProgress from './os/window-progress';
|
||||||
import MainPage from './pages/main/MainPage';
|
import MainPage from './pages/main/MainPage';
|
||||||
|
import './css/main.css';
|
||||||
|
import * as i18next from 'i18next';
|
||||||
|
import { promises } from 'dns';
|
||||||
|
import { SourceMetadata } from '../../shared/typings/source-selector';
|
||||||
|
|
||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
'unhandledrejection',
|
'unhandledrejection',
|
||||||
(event: PromiseRejectionEvent | any) => {
|
(event: PromiseRejectionEvent | any) => {
|
||||||
// Promise: event.reason
|
// Promise: event.reason
|
||||||
// Bluebird: event.detail.reason
|
|
||||||
// Anything else: event
|
// Anything else: event
|
||||||
const error =
|
const error = event.reason || event;
|
||||||
event.reason || (event.detail && event.detail.reason) || event;
|
|
||||||
analytics.logException(error);
|
analytics.logException(error);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
},
|
},
|
||||||
@@ -85,203 +87,89 @@ const currentVersion = packageJSON.version;
|
|||||||
analytics.logEvent('Application start', {
|
analytics.logEvent('Application start', {
|
||||||
packageType: packageJSON.packageType,
|
packageType: packageJSON.packageType,
|
||||||
version: currentVersion,
|
version: currentVersion,
|
||||||
applicationSessionUuid,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const debouncedLog = debounce(console.log, 1000, { maxWait: 1000 });
|
||||||
|
|
||||||
|
function pluralize(word: string, quantity: number) {
|
||||||
|
return `${quantity} ${word}${quantity === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
|
||||||
observe(() => {
|
observe(() => {
|
||||||
if (!flashState.isFlashing()) {
|
if (!flashState.isFlashing()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentFlashState = flashState.getFlashState();
|
const currentFlashState = flashState.getFlashState();
|
||||||
const stateType =
|
windowProgress.set(currentFlashState);
|
||||||
!currentFlashState.flashing && currentFlashState.verifying
|
|
||||||
? `Verifying ${currentFlashState.verifying}`
|
|
||||||
: `Flashing ${currentFlashState.flashing}`;
|
|
||||||
|
|
||||||
|
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()`
|
// NOTE: There is usually a short time period between the `isFlashing()`
|
||||||
// property being set, and the flashing actually starting, which
|
// property being set, and the flashing actually starting, which
|
||||||
// might cause some non-sense flashing state logs including
|
// might cause some non-sense flashing state logs including
|
||||||
// `undefined` values.
|
// `undefined` values.
|
||||||
analytics.logDebug(
|
debouncedLog(outdent({ newline: ' ' })`
|
||||||
`${stateType} devices, ` +
|
${capitalize(currentFlashState.type)}
|
||||||
`${currentFlashState.percentage}% at ${currentFlashState.speed} MB/s ` +
|
${active},
|
||||||
`(total ${currentFlashState.totalSpeed} MB/s) ` +
|
${currentFlashState.percentage}%
|
||||||
`eta in ${currentFlashState.eta}s ` +
|
at
|
||||||
`with ${currentFlashState.failed} failed devices`,
|
${(currentFlashState.speed || 0).toFixed(2)}
|
||||||
);
|
MB/s
|
||||||
|
(total ${(currentFlashState.speed * currentFlashState.active).toFixed(2)} MB/s)
|
||||||
windowProgress.set(currentFlashState);
|
${eta}
|
||||||
|
with
|
||||||
|
${pluralize('failed device', currentFlashState.failed)}
|
||||||
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
function setDrives(drives: Dictionary<DrivelistDrive>) {
|
||||||
* @summary The radix used by USB ID numbers
|
// prevent setting drives while flashing otherwise we might lose some while we unmount them
|
||||||
*/
|
if (!flashState.isFlashing()) {
|
||||||
const USB_ID_RADIX = 16;
|
availableDrives.setDrives(values(drives));
|
||||||
|
}
|
||||||
/**
|
|
||||||
* @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')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Spwaning the child process without privileges to get the drives list
|
||||||
* @summary Product ID of BCM2708
|
// TODO: clean up this mess of exports
|
||||||
*/
|
export let requestMetadata: any;
|
||||||
const USB_PRODUCT_ID_BCM2708_BOOT = 0x2763;
|
|
||||||
|
|
||||||
/**
|
// start the api and spawn the child process
|
||||||
* @summary Product ID of BCM2710
|
startApiAndSpawnChild({
|
||||||
*/
|
withPrivileges: false,
|
||||||
const USB_PRODUCT_ID_BCM2710_BOOT = 0x2764;
|
}).then(({ emit, registerHandler }) => {
|
||||||
|
// start scanning
|
||||||
|
emit('scan');
|
||||||
|
|
||||||
/**
|
// make the sourceMetada awaitable to be used on source selection
|
||||||
* @summary Compute module descriptions
|
requestMetadata = async (params: any): Promise<SourceMetadata> => {
|
||||||
*/
|
emit('sourceMetadata', JSON.stringify(params));
|
||||||
const COMPUTE_MODULE_DESCRIPTIONS: _.Dictionary<string> = {
|
|
||||||
[USB_PRODUCT_ID_BCM2708_BOOT]: 'Compute Module 1',
|
return new Promise((resolve) =>
|
||||||
[USB_PRODUCT_ID_BCM2710_BOOT]: 'Compute Module 3',
|
registerHandler('sourceMetadata', (data: any) => {
|
||||||
|
resolve(JSON.parse(data));
|
||||||
|
}),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const BLACKLISTED_DRIVES = settings.has('driveBlacklist')
|
registerHandler('drives', (data: any) => {
|
||||||
? settings.get('driveBlacklist').split(',')
|
setDrives(JSON.parse(data));
|
||||||
: [];
|
|
||||||
|
|
||||||
function driveIsAllowed(drive: {
|
|
||||||
devicePath: string;
|
|
||||||
device: string;
|
|
||||||
raw: string;
|
|
||||||
}) {
|
|
||||||
return !(
|
|
||||||
BLACKLISTED_DRIVES.includes(drive.devicePath) ||
|
|
||||||
BLACKLISTED_DRIVES.includes(drive.device) ||
|
|
||||||
BLACKLISTED_DRIVES.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.org/documentation/hardware/computemodule/cm-emmc-flashing.md',
|
|
||||||
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<any>) {
|
|
||||||
availableDrives.setDrives(_.values(drives));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDrives() {
|
|
||||||
return _.keyBy(availableDrives.getDrives() || [], 'device');
|
|
||||||
}
|
|
||||||
|
|
||||||
function addDrive(drive: Drive) {
|
|
||||||
const preparedDrive = prepareDrive(drive);
|
|
||||||
if (!driveIsAllowed(preparedDrive)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const drives = getDrives();
|
|
||||||
drives[preparedDrive.device] = preparedDrive;
|
|
||||||
setDrives(drives);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeDrive(drive: Drive) {
|
|
||||||
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) {
|
|
||||||
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();
|
|
||||||
|
|
||||||
let popupExists = false;
|
let popupExists = false;
|
||||||
|
|
||||||
window.addEventListener('beforeunload', async event => {
|
analytics.initAnalytics();
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', async (event) => {
|
||||||
if (!flashState.isFlashing() || popupExists) {
|
if (!flashState.isFlashing() || popupExists) {
|
||||||
analytics.logEvent('Close application', {
|
analytics.logEvent('Close application', {
|
||||||
isFlashing: flashState.isFlashing(),
|
isFlashing: flashState.isFlashing(),
|
||||||
applicationSessionUuid,
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -292,28 +180,23 @@ window.addEventListener('beforeunload', async event => {
|
|||||||
// Don't open any more popups
|
// Don't open any more popups
|
||||||
popupExists = true;
|
popupExists = true;
|
||||||
|
|
||||||
analytics.logEvent('Close attempt while flashing', {
|
analytics.logEvent('Close attempt while flashing');
|
||||||
applicationSessionUuid,
|
|
||||||
flashingWorkflowUuid,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const confirmed = await osDialog.showWarning({
|
const confirmed = await osDialog.showWarning({
|
||||||
confirmationLabel: 'Yes, quit',
|
confirmationLabel: i18next.t('yesExit'),
|
||||||
rejectionLabel: 'Cancel',
|
rejectionLabel: i18next.t('cancel'),
|
||||||
title: 'Are you sure you want to close Etcher?',
|
title: i18next.t('reallyExit'),
|
||||||
description: messages.warning.exitWhileFlashing(),
|
description: messages.warning.exitWhileFlashing(),
|
||||||
});
|
});
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
analytics.logEvent('Close confirmed while flashing', {
|
analytics.logEvent('Close confirmed while flashing', {
|
||||||
flashInstanceUuid: flashState.getFlashUuid(),
|
flashInstanceUuid: flashState.getFlashUuid(),
|
||||||
applicationSessionUuid,
|
|
||||||
flashingWorkflowUuid,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// This circumvents the 'beforeunload' event unlike
|
// This circumvents the 'beforeunload' event unlike
|
||||||
// electron.remote.app.quit() which does not.
|
// remote.app.quit() which does not.
|
||||||
electron.remote.process.exit(EXIT_CODES.SUCCESS);
|
remote.process.exit(EXIT_CODES.SUCCESS);
|
||||||
}
|
}
|
||||||
|
|
||||||
analytics.logEvent('Close rejected while flashing', {
|
analytics.logEvent('Close rejected while flashing', {
|
||||||
@@ -321,21 +204,31 @@ window.addEventListener('beforeunload', async event => {
|
|||||||
flashingWorkflowUuid,
|
flashingWorkflowUuid,
|
||||||
});
|
});
|
||||||
popupExists = false;
|
popupExists = false;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
exceptionReporter.report(error);
|
exceptionReporter.report(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function extendLock() {
|
export async function main() {
|
||||||
updateLock.extend();
|
try {
|
||||||
|
const { init: ledsInit } = require('./models/leds');
|
||||||
|
await ledsInit();
|
||||||
|
} catch (error: any) {
|
||||||
|
exceptionReporter.report(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('click', extendLock);
|
ReactDOM.render(
|
||||||
window.addEventListener('touchstart', extendLock);
|
React.createElement(MainPage),
|
||||||
|
document.getElementById('main'),
|
||||||
// Initial update lock acquisition
|
// callback to set the correct zoomFactor for webviews as well
|
||||||
extendLock();
|
async () => {
|
||||||
|
const fullscreen = await settings.get('fullscreen');
|
||||||
settings.load().catch(exceptionReporter.report);
|
const width = fullscreen ? window.screen.width : window.outerWidth;
|
||||||
|
try {
|
||||||
ReactDOM.render(React.createElement(MainPage), document.getElementById('main'));
|
electron.webFrame.setZoomFactor(width / settings.DEFAULT_WIDTH);
|
||||||
|
} catch (err) {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,292 +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 { Drive as DrivelistDrive } from 'drivelist';
|
|
||||||
import * as _ from 'lodash';
|
|
||||||
import * as React from 'react';
|
|
||||||
import { Modal } from 'rendition';
|
|
||||||
|
|
||||||
import {
|
|
||||||
COMPATIBILITY_STATUS_TYPES,
|
|
||||||
getDriveImageCompatibilityStatuses,
|
|
||||||
hasListDriveImageCompatibilityStatus,
|
|
||||||
isDriveValid,
|
|
||||||
} from '../../../../shared/drive-constraints';
|
|
||||||
import { bytesToClosestUnit } from '../../../../shared/units';
|
|
||||||
import { getDrives, hasAvailableDrives } from '../../models/available-drives';
|
|
||||||
import * as selectionState from '../../models/selection-state';
|
|
||||||
import { store } from '../../models/store';
|
|
||||||
import * as analytics from '../../modules/analytics';
|
|
||||||
import { open as openExternal } from '../../os/open-external/services/open-external';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @summary Determine if we can change a drive's selection state
|
|
||||||
*/
|
|
||||||
function shouldChangeDriveSelectionState(drive: DrivelistDrive) {
|
|
||||||
return isDriveValid(drive, selectionState.getImage());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @summary Toggle a drive selection
|
|
||||||
*/
|
|
||||||
function toggleDrive(drive: DrivelistDrive) {
|
|
||||||
const canChangeDriveSelectionState = shouldChangeDriveSelectionState(drive);
|
|
||||||
|
|
||||||
if (canChangeDriveSelectionState) {
|
|
||||||
analytics.logEvent('Toggle drive', {
|
|
||||||
drive,
|
|
||||||
previouslySelected: selectionState.isDriveSelected(drive.device),
|
|
||||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
|
||||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
|
|
||||||
});
|
|
||||||
|
|
||||||
selectionState.toggleDrive(drive.device);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @summary Get a drive's compatibility status object(s)
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* Given a drive, return its compatibility status with the selected image,
|
|
||||||
* containing the status type (ERROR, WARNING), and accompanying
|
|
||||||
* status message.
|
|
||||||
*/
|
|
||||||
function getDriveStatuses(
|
|
||||||
drive: DrivelistDrive,
|
|
||||||
): Array<{ type: number; message: string }> {
|
|
||||||
return getDriveImageCompatibilityStatuses(drive, selectionState.getImage());
|
|
||||||
}
|
|
||||||
|
|
||||||
function keyboardToggleDrive(
|
|
||||||
drive: DrivelistDrive,
|
|
||||||
event: React.KeyboardEvent<HTMLDivElement>,
|
|
||||||
) {
|
|
||||||
const ENTER = 13;
|
|
||||||
const SPACE = 32;
|
|
||||||
if (_.includes([ENTER, SPACE], event.keyCode)) {
|
|
||||||
toggleDrive(drive);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DriverlessDrive {
|
|
||||||
link: string;
|
|
||||||
linkTitle: string;
|
|
||||||
linkMessage: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DriveSelectorModal({ close }: { close: () => void }) {
|
|
||||||
const defaultMissingDriversModalState: { drive?: DriverlessDrive } = {};
|
|
||||||
const [missingDriversModal, setMissingDriversModal] = React.useState(
|
|
||||||
defaultMissingDriversModalState,
|
|
||||||
);
|
|
||||||
const [drives, setDrives] = React.useState(getDrives());
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const unsubscribe = store.subscribe(() => {
|
|
||||||
setDrives(getDrives());
|
|
||||||
});
|
|
||||||
return unsubscribe;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @summary Prompt the user to install missing usbboot drivers
|
|
||||||
*/
|
|
||||||
function installMissingDrivers(drive: {
|
|
||||||
link: string;
|
|
||||||
linkTitle: string;
|
|
||||||
linkMessage: string;
|
|
||||||
}) {
|
|
||||||
if (drive.link) {
|
|
||||||
analytics.logEvent('Open driver link modal', {
|
|
||||||
url: drive.link,
|
|
||||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
|
||||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
|
|
||||||
});
|
|
||||||
setMissingDriversModal({ drive });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @summary Select a drive and close the modal
|
|
||||||
*/
|
|
||||||
async function selectDriveAndClose(drive: DrivelistDrive) {
|
|
||||||
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"
|
|
||||||
// @ts-ignore (FIXME: not a valid <li> attribute but used by css rule)
|
|
||||||
disabled={!isDriveValid(drive, selectionState.getImage())}
|
|
||||||
onDoubleClick={() => selectDriveAndClose(drive)}
|
|
||||||
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"
|
|
||||||
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"
|
|
||||||
// @ts-ignore (FIXME: not a valid <span> attribute but used by css rule)
|
|
||||||
disabled={!selectionState.isDriveSelected(drive.device)}
|
|
||||||
></span>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{!hasAvailableDrives() && (
|
|
||||||
<li className="list-group-item">
|
|
||||||
<div>
|
|
||||||
<b>Connect a drive!</b>
|
|
||||||
<div>No removable drive detected.</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{missingDriversModal.drive !== undefined && (
|
|
||||||
<Modal
|
|
||||||
width={400}
|
|
||||||
title={missingDriversModal.drive.linkTitle}
|
|
||||||
cancel={() => setMissingDriversModal({})}
|
|
||||||
done={() => {
|
|
||||||
try {
|
|
||||||
if (missingDriversModal.drive !== undefined) {
|
|
||||||
openExternal(missingDriversModal.drive.link);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
analytics.logException(error);
|
|
||||||
} finally {
|
|
||||||
setMissingDriversModal({});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
action={'Yes, continue'}
|
|
||||||
cancelButtonProps={{
|
|
||||||
children: 'Cancel',
|
|
||||||
}}
|
|
||||||
children={
|
|
||||||
missingDriversModal.drive.linkMessage ||
|
|
||||||
`Etcher will open ${missingDriversModal.drive.link} in your browser`
|
|
||||||
}
|
|
||||||
></Modal>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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,143 +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 { Drive as DrivelistDrive } from 'drivelist';
|
|
||||||
import * as _ from 'lodash';
|
|
||||||
import * as React from 'react';
|
|
||||||
import { Txt } from 'rendition';
|
|
||||||
import { default as styled } from 'styled-components';
|
|
||||||
|
|
||||||
import {
|
|
||||||
getDriveImageCompatibilityStatuses,
|
|
||||||
Image,
|
|
||||||
} from '../../../../shared/drive-constraints';
|
|
||||||
import { bytesToClosestUnit } from '../../../../shared/units';
|
|
||||||
import { getSelectedDrives } from '../../models/selection-state';
|
|
||||||
import {
|
|
||||||
ChangeButton,
|
|
||||||
DetailsText,
|
|
||||||
StepButton,
|
|
||||||
StepNameButton,
|
|
||||||
} from '../../styled-components';
|
|
||||||
import { middleEllipsis } from '../../utils/middle-ellipsis';
|
|
||||||
|
|
||||||
const TargetDetail = styled(props => <Txt.span {...props}></Txt.span>)`
|
|
||||||
float: ${({ float }) => float};
|
|
||||||
`;
|
|
||||||
|
|
||||||
interface TargetSelectorProps {
|
|
||||||
targets: any[];
|
|
||||||
disabled: boolean;
|
|
||||||
openDriveSelector: () => any;
|
|
||||||
reselectDrive: () => any;
|
|
||||||
flashing: boolean;
|
|
||||||
show: boolean;
|
|
||||||
tooltip: string;
|
|
||||||
image: Image;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DriveCompatibilityWarning(props: {
|
|
||||||
drive: DrivelistDrive;
|
|
||||||
image: Image;
|
|
||||||
}) {
|
|
||||||
const compatibilityWarnings = getDriveImageCompatibilityStatuses(
|
|
||||||
props.drive,
|
|
||||||
props.image,
|
|
||||||
);
|
|
||||||
if (compatibilityWarnings.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const messages = _.map(compatibilityWarnings, 'message');
|
|
||||||
return (
|
|
||||||
<Txt.span
|
|
||||||
className="glyphicon glyphicon-exclamation-sign"
|
|
||||||
ml={2}
|
|
||||||
tooltip={messages.join(', ')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TargetSelector(props: TargetSelectorProps) {
|
|
||||||
const targets = getSelectedDrives();
|
|
||||||
|
|
||||||
if (targets.length === 1) {
|
|
||||||
const target = targets[0];
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<StepNameButton plain tooltip={props.tooltip}>
|
|
||||||
{middleEllipsis(target.description, 20)}
|
|
||||||
</StepNameButton>
|
|
||||||
{!props.flashing && (
|
|
||||||
<ChangeButton plain mb={14} onClick={props.reselectDrive}>
|
|
||||||
Change
|
|
||||||
</ChangeButton>
|
|
||||||
)}
|
|
||||||
<DetailsText>
|
|
||||||
<DriveCompatibilityWarning drive={target} image={props.image} />
|
|
||||||
{bytesToClosestUnit(target.size)}
|
|
||||||
</DetailsText>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
|
||||||
>
|
|
||||||
<Txt.span>
|
|
||||||
<DriveCompatibilityWarning drive={target} image={props.image} />
|
|
||||||
<TargetDetail float="left">
|
|
||||||
{middleEllipsis(target.description, 14)}
|
|
||||||
</TargetDetail>
|
|
||||||
<TargetDetail float="right">
|
|
||||||
{bytesToClosestUnit(target.size)}
|
|
||||||
</TargetDetail>
|
|
||||||
</Txt.span>
|
|
||||||
</DetailsText>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<StepNameButton plain tooltip={props.tooltip}>
|
|
||||||
{targets.length} Targets
|
|
||||||
</StepNameButton>
|
|
||||||
{!props.flashing && (
|
|
||||||
<ChangeButton plain onClick={props.reselectDrive} mb={14}>
|
|
||||||
Change
|
|
||||||
</ChangeButton>
|
|
||||||
)}
|
|
||||||
{targetsTemplate}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StepButton
|
|
||||||
tabindex={targets.length > 0 ? -1 : 2}
|
|
||||||
disabled={props.disabled}
|
|
||||||
onClick={props.openDriveSelector}
|
|
||||||
>
|
|
||||||
Select target
|
|
||||||
</StepButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
|
|
||||||
import * as settings from '../../models/settings';
|
|
||||||
import * as analytics from '../../modules/analytics';
|
|
||||||
import { SafeWebview } from '../safe-webview/safe-webview';
|
|
||||||
|
|
||||||
interface FeaturedProjectProps {
|
|
||||||
onWebviewShow: (isWebviewShowing: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FeaturedProjectState {
|
|
||||||
endpoint: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FeaturedProject extends React.Component<
|
|
||||||
FeaturedProjectProps,
|
|
||||||
FeaturedProjectState
|
|
||||||
> {
|
|
||||||
constructor(props: FeaturedProjectProps) {
|
|
||||||
super(props);
|
|
||||||
this.state = { endpoint: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
public async componentDidMount() {
|
|
||||||
try {
|
|
||||||
await settings.load();
|
|
||||||
const endpoint =
|
|
||||||
settings.get('featuredProjectEndpoint') ||
|
|
||||||
'https://assets.balena.io/etcher-featured/index.html';
|
|
||||||
this.setState({ endpoint });
|
|
||||||
} catch (error) {
|
|
||||||
analytics.logException(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
return this.state.endpoint ? (
|
|
||||||
<SafeWebview src={this.state.endpoint} {...this.props}></SafeWebview>
|
|
||||||
) : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,121 +14,110 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as _ from 'lodash';
|
|
||||||
import * as React from 'react';
|
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 flashState from '../../models/flash-state';
|
||||||
import * as selectionState from '../../models/selection-state';
|
import * as selectionState from '../../models/selection-state';
|
||||||
import { store } from '../../models/store';
|
import * as settings from '../../models/settings';
|
||||||
|
import { Actions, store } from '../../models/store';
|
||||||
import * as analytics from '../../modules/analytics';
|
import * as analytics from '../../modules/analytics';
|
||||||
import { updateLock } from '../../modules/update-lock';
|
|
||||||
import { open as openExternal } from '../../os/open-external/services/open-external';
|
|
||||||
import { FlashAnother } from '../flash-another/flash-another';
|
import { FlashAnother } from '../flash-another/flash-another';
|
||||||
import { FlashResults } from '../flash-results/flash-results';
|
import { FlashResults, FlashError } from '../flash-results/flash-results';
|
||||||
import { SVGIcon } from '../svg-icon/svg-icon';
|
import { SafeWebview } from '../safe-webview/safe-webview';
|
||||||
|
|
||||||
const restart = (options: any, goToMain: () => void) => {
|
function restart(goToMain: () => void) {
|
||||||
const {
|
|
||||||
applicationSessionUuid,
|
|
||||||
flashingWorkflowUuid,
|
|
||||||
} = store.getState().toJS();
|
|
||||||
if (!options.preserveImage) {
|
|
||||||
selectionState.deselectImage();
|
|
||||||
}
|
|
||||||
selectionState.deselectAllDrives();
|
selectionState.deselectAllDrives();
|
||||||
analytics.logEvent('Restart', {
|
analytics.logEvent('Restart');
|
||||||
...options,
|
|
||||||
applicationSessionUuid,
|
|
||||||
flashingWorkflowUuid,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Re-enable lock release on inactivity
|
|
||||||
updateLock.resume();
|
|
||||||
|
|
||||||
// Reset the flashing workflow uuid
|
// Reset the flashing workflow uuid
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: 'SET_FLASHING_WORKFLOW_UUID',
|
type: Actions.SET_FLASHING_WORKFLOW_UUID,
|
||||||
data: uuidV4(),
|
data: uuidV4(),
|
||||||
});
|
});
|
||||||
|
|
||||||
goToMain();
|
goToMain();
|
||||||
};
|
}
|
||||||
|
|
||||||
const formattedErrors = () => {
|
async function getSuccessBannerURL() {
|
||||||
const errors = _.map(
|
return (
|
||||||
_.get(flashState.getFlashResults(), ['results', 'errors']),
|
(await settings.get('successBannerURL')) ??
|
||||||
error => {
|
'https://efp.balena.io/success-banner?borderTop=false&darkBackground=true'
|
||||||
return `${error.device}: ${error.message || error.code}`;
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
return errors.join('\n');
|
}
|
||||||
};
|
|
||||||
|
|
||||||
function FinishPage({ goToMain }: { goToMain: () => void }) {
|
function FinishPage({ goToMain }: { goToMain: () => void }) {
|
||||||
// @ts-ignore
|
const [webviewShowing, setWebviewShowing] = React.useState(false);
|
||||||
const results = flashState.getFlashResults().results || {};
|
const [successBannerURL, setSuccessBannerURL] = React.useState('');
|
||||||
const progressMessage = messages.progress;
|
(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 (
|
return (
|
||||||
<div className="page-finish row around-xs">
|
<Flex height="100%" justifyContent="space-between">
|
||||||
<div className="col-xs">
|
<Flex
|
||||||
<div className="box center">
|
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
|
<FlashResults
|
||||||
|
image={selectionState.getImage()?.name}
|
||||||
results={results}
|
results={results}
|
||||||
message={progressMessage}
|
skip={skip}
|
||||||
errors={formattedErrors}
|
errors={errors}
|
||||||
></FlashResults>
|
mb="32px"
|
||||||
|
goToMain={goToMain}
|
||||||
|
/>
|
||||||
|
|
||||||
<FlashAnother
|
<FlashAnother
|
||||||
onClick={(options: any) => restart(options, goToMain)}
|
onClick={() => {
|
||||||
></FlashAnother>
|
restart(goToMain);
|
||||||
</div>
|
}}
|
||||||
|
/>
|
||||||
<div className="box center">
|
</Flex>
|
||||||
<div className="fallback-banner">
|
{successBannerURL.length && (
|
||||||
<div className="caption caption-big">
|
<SafeWebview
|
||||||
Thanks for using
|
src={successBannerURL}
|
||||||
<span
|
onWebviewShow={setWebviewShowing}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{
|
||||||
onClick={() =>
|
display: webviewShowing ? 'flex' : 'none',
|
||||||
openExternal(
|
position: 'absolute',
|
||||||
'https://balena.io/etcher?ref=etcher_offline_banner',
|
right: 0,
|
||||||
)
|
bottom: 0,
|
||||||
}
|
width: '63.8vw',
|
||||||
>
|
height: '100vh',
|
||||||
<SVGIcon
|
}}
|
||||||
paths={['../../assets/etcher.svg']}
|
/>
|
||||||
width="165px"
|
)}
|
||||||
height="auto"
|
</Flex>
|
||||||
></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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,30 +15,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as React from 'react';
|
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>`
|
import { BaseButton } from '../../styled-components';
|
||||||
${position}
|
import * as i18next from 'i18next';
|
||||||
${right}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export interface FlashAnotherProps {
|
export interface FlashAnotherProps {
|
||||||
onClick: (options: { preserveImage: boolean }) => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FlashAnother = (props: FlashAnotherProps) => {
|
export const FlashAnother = (props: FlashAnotherProps) => {
|
||||||
return (
|
return (
|
||||||
<ThemedProvider>
|
<BaseButton primary onClick={props.onClick}>
|
||||||
<Div position="absolute" right="152px">
|
{i18next.t('flash.another')}
|
||||||
<BaseButton
|
|
||||||
primary
|
|
||||||
onClick={props.onClick.bind(null, { preserveImage: true })}
|
|
||||||
>
|
|
||||||
Flash Another
|
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</Div>
|
|
||||||
</ThemedProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,52 +14,231 @@
|
|||||||
* limitations under the License.
|
* 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 * as React from 'react';
|
||||||
|
import { Flex, FlexProps, Link, TableColumn, Txt } from 'rendition';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { left, position, space, top } from 'styled-system';
|
|
||||||
import { Underline } from '../../styled-components';
|
|
||||||
|
|
||||||
const Div: any = styled.div<any>`
|
import { progress } from '../../../../shared/messages';
|
||||||
${position}
|
import { bytesToMegabytes } from '../../../../shared/units';
|
||||||
${top}
|
|
||||||
${left}
|
import FlashSvg from '../../../assets/flash.svg';
|
||||||
${space}
|
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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
const DoneIcon = (props: {
|
||||||
export const FlashResults: any = ({
|
skipped: boolean;
|
||||||
errors,
|
color: string;
|
||||||
results,
|
allFailed: boolean;
|
||||||
message,
|
|
||||||
}: {
|
|
||||||
errors: () => string;
|
|
||||||
results: any;
|
|
||||||
message: any;
|
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
const svgProps = {
|
||||||
<Div position="absolute" left="153px" top="66px">
|
width: '28px',
|
||||||
<div className="inline-flex title">
|
fill: props.color,
|
||||||
<span className="tick tick--success space-right-medium"></span>
|
style: {
|
||||||
<h3>Flash Complete!</h3>
|
marginTop: '-25px',
|
||||||
</div>
|
marginLeft: '13px',
|
||||||
<Div className="results" mt="11px" mr="0" mb="0" ml="40px">
|
zIndex: 1,
|
||||||
<Underline tooltip={errors()}>
|
},
|
||||||
{_.map(results.devices, (quantity, type) => {
|
};
|
||||||
return quantity ? (
|
return props.allFailed && !props.skipped ? (
|
||||||
<div
|
<TimesCircleSvg {...svgProps} />
|
||||||
key={type}
|
) : (
|
||||||
className={`target-status-line target-status-${type}`}
|
<CheckCircleSvg {...svgProps} />
|
||||||
>
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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,413 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as sdk from 'etcher-sdk';
|
|
||||||
import * as _ from 'lodash';
|
|
||||||
import { GPTPartition, MBRPartition } from 'partitioninfo';
|
|
||||||
import * as path from 'path';
|
|
||||||
import * as React from 'react';
|
|
||||||
import { default as Dropzone } from 'react-dropzone';
|
|
||||||
import { Modal } from 'rendition';
|
|
||||||
import { default as 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 shared from '../../../../shared/units';
|
|
||||||
import * as selectionState from '../../models/selection-state';
|
|
||||||
import { observe, store } 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,
|
|
||||||
Footer,
|
|
||||||
StepButton,
|
|
||||||
StepNameButton,
|
|
||||||
StepSelection,
|
|
||||||
Underline,
|
|
||||||
} from '../../styled-components';
|
|
||||||
import { middleEllipsis } from '../../utils/middle-ellipsis';
|
|
||||||
import { SVGIcon } from '../svg-icon/svg-icon';
|
|
||||||
|
|
||||||
// TODO move these styles to rendition
|
|
||||||
const ModalText = styled.p`
|
|
||||||
a {
|
|
||||||
color: rgb(0, 174, 239);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: rgb(0, 139, 191);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const mainSupportedExtensions = _.intersection(
|
|
||||||
['img', 'iso', 'zip'],
|
|
||||||
supportedFormats.getAllExtensions(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const extraSupportedExtensions = _.difference(
|
|
||||||
supportedFormats.getAllExtensions(),
|
|
||||||
mainSupportedExtensions,
|
|
||||||
).sort();
|
|
||||||
|
|
||||||
function getState() {
|
|
||||||
return {
|
|
||||||
hasImage: selectionState.hasImage(),
|
|
||||||
imageName: selectionState.getImageName(),
|
|
||||||
imageSize: selectionState.getImageSize(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImageSelectorProps {
|
|
||||||
flashing: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImageSelectorState {
|
|
||||||
hasImage: boolean;
|
|
||||||
imageName: string;
|
|
||||||
imageSize: number;
|
|
||||||
warning: { message: string; title: string | null } | null;
|
|
||||||
showImageDetails: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ImageSelector extends React.Component<
|
|
||||||
ImageSelectorProps,
|
|
||||||
ImageSelectorState
|
|
||||||
> {
|
|
||||||
private unsubscribe: () => void;
|
|
||||||
|
|
||||||
constructor(props: ImageSelectorProps) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public componentDidMount() {
|
|
||||||
this.unsubscribe = observe(() => {
|
|
||||||
this.setState(getState());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public componentWillUnmount() {
|
|
||||||
this.unsubscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
private reselectImage() {
|
|
||||||
analytics.logEvent('Reselect image', {
|
|
||||||
previousImage: selectionState.getImage(),
|
|
||||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
|
||||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.openImageSelector();
|
|
||||||
}
|
|
||||||
|
|
||||||
private selectImage(
|
|
||||||
image: sdk.sourceDestination.Metadata & {
|
|
||||||
path: string;
|
|
||||||
extension: string;
|
|
||||||
hasMBR: boolean;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
if (!supportedFormats.isSupportedImage(image.path)) {
|
|
||||||
const invalidImageError = errors.createUserError({
|
|
||||||
title: 'Invalid image',
|
|
||||||
description: messages.error.invalidImage(image.path),
|
|
||||||
});
|
|
||||||
|
|
||||||
osDialog.showError(invalidImageError);
|
|
||||||
analytics.logEvent(
|
|
||||||
'Invalid image',
|
|
||||||
_.merge(
|
|
||||||
{
|
|
||||||
applicationSessionUuid: store.getState().toJS()
|
|
||||||
.applicationSessionUuid,
|
|
||||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
|
|
||||||
},
|
|
||||||
image,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectionState.selectImage(image);
|
|
||||||
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: {
|
|
||||||
...image,
|
|
||||||
logo: Boolean(image.logo),
|
|
||||||
blockMap: Boolean(image.blockMap),
|
|
||||||
},
|
|
||||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
|
||||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
exceptionReporter.report(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async selectImageByPath(imagePath: string) {
|
|
||||||
try {
|
|
||||||
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()) as sdk.sourceDestination.Metadata & {
|
|
||||||
hasMBR: boolean;
|
|
||||||
partitions: MBRPartition[] | GPTPartition[];
|
|
||||||
path: string;
|
|
||||||
extension: string;
|
|
||||||
};
|
|
||||||
const partitionTable = await innerSource.getPartitionTable();
|
|
||||||
if (partitionTable) {
|
|
||||||
metadata.hasMBR = true;
|
|
||||||
metadata.partitions = partitionTable.partitions;
|
|
||||||
} else {
|
|
||||||
metadata.hasMBR = false;
|
|
||||||
}
|
|
||||||
metadata.path = imagePath;
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async openImageSelector() {
|
|
||||||
analytics.logEvent('Open image selector', {
|
|
||||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
|
||||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
|
|
||||||
});
|
|
||||||
|
|
||||||
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', {
|
|
||||||
applicationSessionUuid: store.getState().toJS()
|
|
||||||
.applicationSessionUuid,
|
|
||||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.selectImageByPath(imagePath);
|
|
||||||
} catch (error) {
|
|
||||||
exceptionReporter.report(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleOnDrop(acceptedFiles: Array<{ path: string }>) {
|
|
||||||
const [file] = acceptedFiles;
|
|
||||||
|
|
||||||
if (file) {
|
|
||||||
this.selectImageByPath(file.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private 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
|
|
||||||
public 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 (
|
|
||||||
<>
|
|
||||||
<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 ? (
|
|
||||||
<>
|
|
||||||
<StepNameButton
|
|
||||||
plain
|
|
||||||
onClick={this.showSelectedImageDetails}
|
|
||||||
tooltip={imageBasename}
|
|
||||||
>
|
|
||||||
{middleEllipsis(imageName || imageBasename, 20)}
|
|
||||||
</StepNameButton>
|
|
||||||
{!flashing && (
|
|
||||||
<ChangeButton plain mb={14} onClick={this.reselectImage}>
|
|
||||||
Change
|
|
||||||
</ChangeButton>
|
|
||||||
)}
|
|
||||||
<DetailsText>
|
|
||||||
{shared.bytesToClosestUnit(imageSize)}
|
|
||||||
</DetailsText>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<StepSelection>
|
|
||||||
<StepButton onClick={this.openImageSelector}>
|
|
||||||
Select image
|
|
||||||
</StepButton>
|
|
||||||
<Footer>
|
|
||||||
{mainSupportedExtensions.join(', ')}, and{' '}
|
|
||||||
<Underline tooltip={extraSupportedExtensions.join(', ')}>
|
|
||||||
many more
|
|
||||||
</Underline>
|
|
||||||
</Footer>
|
|
||||||
</StepSelection>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{this.state.warning != null && (
|
|
||||||
<Modal
|
|
||||||
titleElement={
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,132 +14,123 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as Color from 'color';
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ProgressBar } from 'rendition';
|
import { Flex, Button, ProgressBar, Txt } from 'rendition';
|
||||||
import { css, default as styled, keyframes } from 'styled-components';
|
import { default as styled } from 'styled-components';
|
||||||
|
|
||||||
import { StepButton, StepSelection } from '../../styled-components';
|
import { fromFlashState } from '../../modules/progress-status';
|
||||||
import { colors } from '../../theme';
|
import { StepButton } from '../../styled-components';
|
||||||
|
import * as i18next from 'i18next';
|
||||||
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)`
|
const FlashProgressBar = styled(ProgressBar)`
|
||||||
> div {
|
> div {
|
||||||
width: 200px;
|
width: 100%;
|
||||||
height: 48px;
|
height: 12px;
|
||||||
color: white !important;
|
color: white !important;
|
||||||
text-shadow: none !important;
|
text-shadow: none !important;
|
||||||
|
transition-duration: 0s;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
transition-duration: 0s;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
width: 200px;
|
width: 100%;
|
||||||
height: 48px;
|
height: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border-radius: 14px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 48px;
|
line-height: 48px;
|
||||||
|
|
||||||
background: ${Color(colors.warning.background)
|
background: #2f3033;
|
||||||
.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.5, ${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;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface ProgressButtonProps {
|
interface ProgressButtonProps {
|
||||||
striped: boolean;
|
type: 'decompressing' | 'flashing' | 'verifying';
|
||||||
active: boolean;
|
active: boolean;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
label: string;
|
position: number;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
callback: () => any;
|
cancel: (type: string) => void;
|
||||||
|
callback: () => void;
|
||||||
|
warning?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const colors = {
|
||||||
* Progress Button component
|
decompressing: '#00aeef',
|
||||||
*/
|
flashing: '#da60ff',
|
||||||
export class ProgressButton extends React.Component<ProgressButtonProps> {
|
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() {
|
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) {
|
if (this.props.active) {
|
||||||
if (this.props.striped) {
|
|
||||||
return (
|
return (
|
||||||
<StepSelection>
|
<>
|
||||||
<FlashProgressBarValidating
|
<Flex
|
||||||
primary
|
alignItems="baseline"
|
||||||
emphasized
|
justifyContent="space-between"
|
||||||
value={this.props.percentage}
|
width="100%"
|
||||||
|
style={{
|
||||||
|
marginTop: 42,
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{this.props.label}
|
<Flex>
|
||||||
</FlashProgressBarValidating>
|
<Txt color="#fff">{status} </Txt>
|
||||||
</StepSelection>
|
<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 (
|
return (
|
||||||
<StepSelection>
|
|
||||||
<FlashProgressBar warning emphasized value={this.props.percentage}>
|
|
||||||
{this.props.label}
|
|
||||||
</FlashProgressBar>
|
|
||||||
</StepSelection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StepSelection>
|
|
||||||
<StepButton
|
<StepButton
|
||||||
|
primary={!warning}
|
||||||
|
warning={warning}
|
||||||
onClick={this.props.callback}
|
onClick={this.props.callback}
|
||||||
disabled={this.props.disabled}
|
disabled={this.props.disabled}
|
||||||
|
style={{
|
||||||
|
marginTop: 30,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{this.props.label}
|
{i18next.t('flash.flashNow')}
|
||||||
</StepButton>
|
</StepButton>
|
||||||
</StepSelection>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,81 +15,60 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { default as styled } from 'styled-components';
|
import { Flex, Txt } from 'rendition';
|
||||||
import { color } from 'styled-system';
|
|
||||||
|
|
||||||
|
import DriveSvg from '../../../assets/drive.svg';
|
||||||
|
import ImageSvg from '../../../assets/image.svg';
|
||||||
import { SVGIcon } from '../svg-icon/svg-icon';
|
import { SVGIcon } from '../svg-icon/svg-icon';
|
||||||
|
import { middleEllipsis } from '../../utils/middle-ellipsis';
|
||||||
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}
|
|
||||||
`;
|
|
||||||
|
|
||||||
interface ReducedFlashingInfosProps {
|
interface ReducedFlashingInfosProps {
|
||||||
imageLogo: string;
|
imageLogo?: string;
|
||||||
imageName: string;
|
imageName?: string;
|
||||||
imageSize: string;
|
imageSize: string;
|
||||||
driveTitle: string;
|
driveTitle: string;
|
||||||
shouldShow: boolean;
|
driveLabel: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ReducedFlashingInfos extends React.Component<
|
export class ReducedFlashingInfos extends React.Component<ReducedFlashingInfosProps> {
|
||||||
ReducedFlashingInfosProps
|
|
||||||
> {
|
|
||||||
constructor(props: ReducedFlashingInfosProps) {
|
constructor(props: ReducedFlashingInfosProps) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {};
|
this.state = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return this.props.shouldShow ? (
|
const { imageName = '' } = this.props;
|
||||||
<Div>
|
return (
|
||||||
<Span className="step-name">
|
<Flex
|
||||||
|
flexDirection="column"
|
||||||
|
style={this.props.style ? this.props.style : undefined}
|
||||||
|
>
|
||||||
|
<Flex mb={16}>
|
||||||
<SVGIcon
|
<SVGIcon
|
||||||
disabled
|
disabled
|
||||||
contents={[this.props.imageLogo]}
|
width="21px"
|
||||||
paths={['../../assets/image.svg']}
|
height="21px"
|
||||||
width="20px"
|
contents={this.props.imageLogo}
|
||||||
></SVGIcon>
|
fallback={ImageSvg}
|
||||||
<Span>{this.props.imageName}</Span>
|
style={{ marginRight: '9px' }}
|
||||||
<Span color="#7e8085">{this.props.imageSize}</Span>
|
/>
|
||||||
</Span>
|
<Txt
|
||||||
|
style={{ marginRight: '9px' }}
|
||||||
|
tooltip={{ text: imageName, placement: 'right' }}
|
||||||
|
>
|
||||||
|
{middleEllipsis(imageName, 16)}
|
||||||
|
</Txt>
|
||||||
|
<Txt color="#7e8085">{this.props.imageSize}</Txt>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
<Span className="step-name">
|
<Flex>
|
||||||
<SVGIcon
|
<DriveSvg width="21px" height="21px" style={{ marginRight: '9px' }} />
|
||||||
disabled
|
<Txt tooltip={{ text: this.props.driveLabel, placement: 'right' }}>
|
||||||
paths={['../../assets/drive.svg']}
|
{middleEllipsis(this.props.driveTitle, 16)}
|
||||||
width="20px"
|
</Txt>
|
||||||
></SVGIcon>
|
</Flex>
|
||||||
<Span>{this.props.driveTitle}</Span>
|
</Flex>
|
||||||
</Span>
|
);
|
||||||
</Div>
|
|
||||||
) : null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as electron from 'electron';
|
import * as electron from 'electron';
|
||||||
|
import * as remote from '@electron/remote';
|
||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import * as packageJSON from '../../../../../package.json';
|
import * as packageJSON from '../../../../../package.json';
|
||||||
import * as settings from '../../models/settings';
|
import * as settings from '../../models/settings';
|
||||||
import { store } from '../../models/store';
|
|
||||||
import * as analytics from '../../modules/analytics';
|
import * as analytics from '../../modules/analytics';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,10 +59,9 @@ const API_VERSION = '2';
|
|||||||
interface SafeWebviewProps {
|
interface SafeWebviewProps {
|
||||||
// The website source URL
|
// The website source URL
|
||||||
src: string;
|
src: string;
|
||||||
// @summary Refresh the webview
|
|
||||||
refreshNow?: boolean;
|
|
||||||
// Webview lifecycle event
|
// Webview lifecycle event
|
||||||
onWebviewShow?: (isWebviewShowing: boolean) => void;
|
onWebviewShow?: (isWebviewShowing: boolean) => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SafeWebviewState {
|
interface SafeWebviewState {
|
||||||
@@ -92,14 +91,15 @@ export class SafeWebview extends React.PureComponent<
|
|||||||
url.searchParams.set(API_VERSION_PARAM, API_VERSION);
|
url.searchParams.set(API_VERSION_PARAM, API_VERSION);
|
||||||
url.searchParams.set(
|
url.searchParams.set(
|
||||||
OPT_OUT_ANALYTICS_PARAM,
|
OPT_OUT_ANALYTICS_PARAM,
|
||||||
(!settings.get('errorReporting')).toString(),
|
(!settings.getSync('errorReporting')).toString(),
|
||||||
);
|
);
|
||||||
this.entryHref = url.href;
|
this.entryHref = url.href;
|
||||||
// Events steal 'this'
|
// Events steal 'this'
|
||||||
|
this.handleDomReady = _.bind(this.handleDomReady, this);
|
||||||
this.didFailLoad = _.bind(this.didFailLoad, this);
|
this.didFailLoad = _.bind(this.didFailLoad, this);
|
||||||
this.didGetResponseDetails = _.bind(this.didGetResponseDetails, this);
|
this.didGetResponseDetails = _.bind(this.didGetResponseDetails, this);
|
||||||
// Make a persistent electron session for the webview
|
// Make a persistent electron session for the webview
|
||||||
this.session = electron.remote.session.fromPartition(ELECTRON_SESSION, {
|
this.session = remote.session.fromPartition(ELECTRON_SESSION, {
|
||||||
// Disable the cache for the session such that new content shows up when refreshing
|
// Disable the cache for the session such that new content shows up when refreshing
|
||||||
cache: false,
|
cache: false,
|
||||||
});
|
});
|
||||||
@@ -110,15 +110,20 @@ export class SafeWebview extends React.PureComponent<
|
|||||||
}
|
}
|
||||||
|
|
||||||
public render() {
|
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 (
|
return (
|
||||||
<webview
|
<webview
|
||||||
ref={this.webviewRef}
|
ref={this.webviewRef}
|
||||||
partition={ELECTRON_SESSION}
|
partition={ELECTRON_SESSION}
|
||||||
style={{
|
style={style}
|
||||||
flex: this.state.shouldShow ? undefined : '0 1',
|
// @ts-ignore
|
||||||
width: this.state.shouldShow ? undefined : '0',
|
allowpopups="true"
|
||||||
height: this.state.shouldShow ? undefined : '0',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -132,8 +137,8 @@ export class SafeWebview extends React.PureComponent<
|
|||||||
this.didFailLoad,
|
this.didFailLoad,
|
||||||
);
|
);
|
||||||
this.webviewRef.current.addEventListener(
|
this.webviewRef.current.addEventListener(
|
||||||
'new-window',
|
'dom-ready',
|
||||||
SafeWebview.newWindow,
|
this.handleDomReady,
|
||||||
);
|
);
|
||||||
this.webviewRef.current.addEventListener(
|
this.webviewRef.current.addEventListener(
|
||||||
'console-message',
|
'console-message',
|
||||||
@@ -155,8 +160,8 @@ export class SafeWebview extends React.PureComponent<
|
|||||||
this.didFailLoad,
|
this.didFailLoad,
|
||||||
);
|
);
|
||||||
this.webviewRef.current.removeEventListener(
|
this.webviewRef.current.removeEventListener(
|
||||||
'new-window',
|
'dom-ready',
|
||||||
SafeWebview.newWindow,
|
this.handleDomReady,
|
||||||
);
|
);
|
||||||
this.webviewRef.current.removeEventListener(
|
this.webviewRef.current.removeEventListener(
|
||||||
'console-message',
|
'console-message',
|
||||||
@@ -166,6 +171,15 @@ export class SafeWebview extends React.PureComponent<
|
|||||||
this.session.webRequest.onCompleted(null);
|
this.session.webRequest.onCompleted(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleDomReady() {
|
||||||
|
const webview = this.webviewRef.current;
|
||||||
|
if (webview == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = webview.getWebContentsId();
|
||||||
|
electron.ipcRenderer.send('webview-dom-ready', id);
|
||||||
|
}
|
||||||
|
|
||||||
// Set the element state to hidden
|
// Set the element state to hidden
|
||||||
public didFailLoad() {
|
public didFailLoad() {
|
||||||
this.setState({
|
this.setState({
|
||||||
@@ -182,10 +196,9 @@ export class SafeWebview extends React.PureComponent<
|
|||||||
// only care about this event if it's a request for the main frame
|
// only care about this event if it's a request for the main frame
|
||||||
if (event.resourceType === 'mainFrame') {
|
if (event.resourceType === 'mainFrame') {
|
||||||
const HTTP_OK = 200;
|
const HTTP_OK = 200;
|
||||||
|
const { webContents, ...webviewEvent } = event;
|
||||||
analytics.logEvent('SafeWebview loaded', {
|
analytics.logEvent('SafeWebview loaded', {
|
||||||
event,
|
...webviewEvent,
|
||||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
|
||||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
|
|
||||||
});
|
});
|
||||||
this.setState({
|
this.setState({
|
||||||
shouldShow: event.statusCode === HTTP_OK,
|
shouldShow: event.statusCode === HTTP_OK,
|
||||||
@@ -195,19 +208,4 @@ export class SafeWebview extends React.PureComponent<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open link in browser if it's opened as a 'foreground-tab'
|
|
||||||
public static newWindow(event: electron.NewWindowEvent) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,210 +14,143 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { faGithub } from '@fortawesome/free-brands-svg-icons';
|
import GithubSvg from '@fortawesome/fontawesome-free/svgs/brands/github.svg';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
import * as os from 'os';
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Badge, Checkbox, Modal } from 'rendition';
|
import { Box, Checkbox, Flex, TextWithCopy, Txt } from 'rendition';
|
||||||
import styled from 'styled-components';
|
|
||||||
|
|
||||||
import { version } from '../../../../../package.json';
|
import { version, packageType } from '../../../../../package.json';
|
||||||
import * as settings from '../../models/settings';
|
import * as settings from '../../models/settings';
|
||||||
import { store } from '../../models/store';
|
|
||||||
import * as analytics from '../../modules/analytics';
|
import * as analytics from '../../modules/analytics';
|
||||||
import { open as openExternal } from '../../os/open-external/services/open-external';
|
import { open as openExternal } from '../../os/open-external/services/open-external';
|
||||||
|
import { Modal } from '../../styled-components';
|
||||||
const { useState } = React;
|
import * as i18next from 'i18next';
|
||||||
const platform = os.platform();
|
import { etcherProInfo } from '../../utils/etcher-pro-specific';
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Setting {
|
interface Setting {
|
||||||
name: string;
|
name: string;
|
||||||
label: string | JSX.Element;
|
label: string | JSX.Element;
|
||||||
options?: any;
|
|
||||||
hide?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const settingsList: Setting[] = [
|
async function getSettingsList(): Promise<Setting[]> {
|
||||||
|
const list: Setting[] = [
|
||||||
{
|
{
|
||||||
name: 'errorReporting',
|
name: 'errorReporting',
|
||||||
label: 'Anonymously report errors and usage statistics to balena.io',
|
label: i18next.t('settings.errorReporting'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'unmountOnSuccess',
|
name: 'autoBlockmapping',
|
||||||
/**
|
label: i18next.t('settings.trimExtPartitions'),
|
||||||
* 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',
|
|
||||||
},
|
|
||||||
hide: settings.get('disableUnsafeMode'),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
if (['appimage', 'nsis', 'dmg'].includes(packageType)) {
|
||||||
|
list.push({
|
||||||
|
name: 'updatesEnabled',
|
||||||
|
label: i18next.t('settings.autoUpdate'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
toggleModal: (value: boolean) => void;
|
toggleModal: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsModal: any = styled(
|
const EPInfo = etcherProInfo();
|
||||||
({ 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 toggleSetting = async (setting: string, options?: any) => {
|
const InfoBox = (props: any) => (
|
||||||
const value = currentSettings[setting];
|
<Box fontSize={14}>
|
||||||
const dangerous = !_.isUndefined(options);
|
<Txt>{props.label}</Txt>
|
||||||
|
<TextWithCopy code text={props.value} copy={props.value} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
analytics.logEvent('Toggle setting', {
|
export function SettingsModal({ toggleModal }: SettingsModalProps) {
|
||||||
setting,
|
const [settingsList, setCurrentSettingsList] = React.useState<Setting[]>([]);
|
||||||
value,
|
React.useEffect(() => {
|
||||||
dangerous,
|
(async () => {
|
||||||
// @ts-ignore
|
if (settingsList.length === 0) {
|
||||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
setCurrentSettingsList(await getSettingsList());
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
const [currentSettings, setCurrentSettings] = React.useState<
|
||||||
|
_.Dictionary<boolean>
|
||||||
|
>({});
|
||||||
|
React.useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
if (_.isEmpty(currentSettings)) {
|
||||||
|
setCurrentSettings(await settings.getAll());
|
||||||
|
}
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (value || !dangerous) {
|
const toggleSetting = async (setting: string) => {
|
||||||
|
const value = currentSettings[setting];
|
||||||
|
analytics.logEvent('Toggle setting', { setting, value });
|
||||||
await settings.set(setting, !value);
|
await settings.set(setting, !value);
|
||||||
setCurrentSettings({
|
setCurrentSettings({
|
||||||
...currentSettings,
|
...currentSettings,
|
||||||
[setting]: !value,
|
[setting]: !value,
|
||||||
});
|
});
|
||||||
setWarning({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show warning since it's a dangerous setting
|
|
||||||
setWarning({
|
|
||||||
setting,
|
|
||||||
settingValue: value,
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
id="settings-modal"
|
titleElement={
|
||||||
title="Settings"
|
<Txt fontSize={24} mb={24}>
|
||||||
|
{i18next.t('settings.settings')}
|
||||||
|
</Txt>
|
||||||
|
}
|
||||||
done={() => toggleModal(false)}
|
done={() => toggleModal(false)}
|
||||||
style={{
|
|
||||||
width: 780,
|
|
||||||
height: 420,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div>
|
<Flex flexDirection="column">
|
||||||
{_.map(settingsList, (setting: Setting, i: number) => {
|
{settingsList.map((setting: Setting, i: number) => {
|
||||||
return setting.hide ? null : (
|
return (
|
||||||
<div key={setting.name}>
|
<Flex key={setting.name} mb={14}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
toggle
|
toggle
|
||||||
tabIndex={6 + i}
|
tabIndex={6 + i}
|
||||||
label={setting.label}
|
label={setting.label}
|
||||||
checked={currentSettings[setting.name]}
|
checked={currentSettings[setting.name]}
|
||||||
onChange={() => toggleSetting(setting.name, setting.options)}
|
onChange={() => toggleSetting(setting.name)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Flex>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div>
|
{EPInfo !== undefined && (
|
||||||
<span
|
<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>
|
||||||
|
)}
|
||||||
|
<Flex
|
||||||
|
mt={18}
|
||||||
|
alignItems="center"
|
||||||
|
color="#00aeef"
|
||||||
|
style={{
|
||||||
|
width: 'fit-content',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
openExternal(
|
openExternal(
|
||||||
'https://github.com/balena-io/etcher/blob/master/CHANGELOG.md',
|
'https://github.com/balena-io/etcher/blob/master/CHANGELOG.md',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faGithub} /> {version}
|
<GithubSvg
|
||||||
</span>
|
height="1em"
|
||||||
</div>
|
fill="currentColor"
|
||||||
</div>
|
style={{ marginRight: 8 }}
|
||||||
|
|
||||||
{_.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({});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<Txt style={{ borderBottom: '1px solid #00aeef' }}>{version}</Txt>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
},
|
|
||||||
)`
|
|
||||||
> div:nth-child(3) {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
`;
|
|
||||||
|
|||||||
790
lib/gui/app/components/source-selector/source-selector.tsx
Normal file
790
lib/gui/app/components/source-selector/source-selector.tsx
Normal file
@@ -0,0 +1,790 @@
|
|||||||
|
/*
|
||||||
|
* 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 { ipcRenderer, IpcRendererEvent } from 'electron';
|
||||||
|
import { uniqBy, isNil } from 'lodash';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as prettyBytes from 'pretty-bytes';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { requestMetadata } from '../../app';
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 { isJson } from '../../../../shared/utils';
|
||||||
|
import {
|
||||||
|
SourceMetadata,
|
||||||
|
Authentication,
|
||||||
|
Source,
|
||||||
|
} from '../../../../shared/typings/source-selector';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) ? 'Http' : 'File',
|
||||||
|
).promise;
|
||||||
|
this.setState({ imageLoading: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 metadata: SourceMetadata | undefined;
|
||||||
|
if (isString(selected)) {
|
||||||
|
if (
|
||||||
|
SourceType === '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'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// this will send an event down the ipcMain asking for metadata
|
||||||
|
// we'll get the response through an event
|
||||||
|
|
||||||
|
metadata = await requestMetadata({ selected, SourceType, auth });
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} 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: 'BlockDevice',
|
||||||
|
drive: selected,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata !== undefined) {
|
||||||
|
metadata.auth = auth;
|
||||||
|
metadata.SourceType = SourceType;
|
||||||
|
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 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, '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, '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,
|
||||||
|
'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, '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, 'BlockDevice');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,13 +14,8 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as _ from 'lodash';
|
|
||||||
import * as path from 'path';
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import * as analytics from '../../modules/analytics';
|
|
||||||
|
|
||||||
const domParser = new window.DOMParser();
|
const domParser = new window.DOMParser();
|
||||||
|
|
||||||
const DEFAULT_SIZE = '40px';
|
const DEFAULT_SIZE = '40px';
|
||||||
@@ -28,115 +23,52 @@ const DEFAULT_SIZE = '40px';
|
|||||||
/**
|
/**
|
||||||
* @summary Try to parse SVG contents and return it data encoded
|
* @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
|
|
||||||
*/
|
*/
|
||||||
function tryParseSVGContents(contents: string) {
|
function tryParseSVGContents(contents?: string): string | undefined {
|
||||||
|
if (contents === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const doc = domParser.parseFromString(contents, 'image/svg+xml');
|
const doc = domParser.parseFromString(contents, 'image/svg+xml');
|
||||||
const parserError = doc.querySelector('parsererror');
|
const parserError = doc.querySelector('parsererror');
|
||||||
const svg = doc.querySelector('svg');
|
const svg = doc.querySelector('svg');
|
||||||
|
|
||||||
if (!parserError && svg) {
|
if (!parserError && svg) {
|
||||||
return `data:image/svg+xml,${encodeURIComponent(svg.outerHTML)}`;
|
return `data:image/svg+xml,${encodeURIComponent(svg.outerHTML)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SVGIconProps {
|
interface SVGIconProps {
|
||||||
// Paths to SVG files to be tried in succession if any fails
|
// Optional string representing the SVG contents to be tried
|
||||||
paths: string[];
|
contents?: string;
|
||||||
// List of embedded SVG contents to be tried in succession if any fails
|
// Fallback SVG element to show if `contents` is invalid/undefined
|
||||||
contents?: string[];
|
fallback: React.FunctionComponent<React.SVGProps<HTMLOrSVGElement>>;
|
||||||
// SVG image width unit
|
// SVG image width unit
|
||||||
width?: string;
|
width?: string;
|
||||||
// SVG image height unit
|
// SVG image height unit
|
||||||
height?: string;
|
height?: string;
|
||||||
// Should the element visually appear grayed out and disabled?
|
// Should the element visually appear grayed out and disabled?
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary SVG element that takes both filepaths and file contents
|
* @summary SVG element that takes file contents
|
||||||
*/
|
*/
|
||||||
export class SVGIcon extends React.Component<SVGIconProps> {
|
export class SVGIcon extends React.PureComponent<SVGIconProps> {
|
||||||
public render() {
|
public render() {
|
||||||
// __dirname behaves strangely inside a Webpack bundle,
|
const svgData = tryParseSVGContents(this.props.contents);
|
||||||
// so we need to provide different base directories
|
const { width, height, style = {} } = this.props;
|
||||||
// depending on whether __dirname is absolute or not,
|
style.width = width || DEFAULT_SIZE;
|
||||||
// which helps detecting a Webpack bundle.
|
style.height = height || DEFAULT_SIZE;
|
||||||
// We use global.__dirname inside a Webpack bundle since
|
if (svgData !== undefined) {
|
||||||
// that's the only way to get the "real" __dirname.
|
|
||||||
let baseDirectory: string;
|
|
||||||
if (path.isAbsolute(__dirname)) {
|
|
||||||
baseDirectory = path.join(__dirname, '..');
|
|
||||||
} else {
|
|
||||||
// @ts-ignore
|
|
||||||
baseDirectory = 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 = 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 (
|
return (
|
||||||
<img
|
<img
|
||||||
className="svg-icon"
|
className={this.props.disabled ? 'disabled' : ''}
|
||||||
style={{
|
style={style}
|
||||||
width,
|
|
||||||
height,
|
|
||||||
}}
|
|
||||||
src={svgData}
|
src={svgData}
|
||||||
// @ts-ignore
|
/>
|
||||||
disabled={this.props.disabled}
|
|
||||||
></img>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Prevent text selection */
|
@font-face {
|
||||||
body {
|
font-family: "SourceSansPro";
|
||||||
-webkit-user-select: none;
|
src: url("./fonts/SourceSansPro-Regular.ttf") format("truetype");
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
/* Allow window to be dragged from anywhere */
|
font-family: "SourceSansPro";
|
||||||
#app-header {
|
src: url("./fonts/SourceSansPro-SemiBold.ttf") format("truetype");
|
||||||
-webkit-app-region: drag;
|
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,
|
html,
|
||||||
body {
|
body {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
/* Prevent white flash when running application */
|
||||||
|
background-color: #4d5057;
|
||||||
|
|
||||||
|
/* Prevent WebView bounce effect in OS X */
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
/* Prevent text selection */
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
overflow: hidden;
|
-webkit-user-select: none;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,11 +51,16 @@ body {
|
|||||||
a:focus,
|
a:focus,
|
||||||
input:focus,
|
input:focus,
|
||||||
button:focus,
|
button:focus,
|
||||||
[tabindex]:focus {
|
[tabindex]:focus,
|
||||||
|
input[type="checkbox"] + div {
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Titles don't have margins on desktop apps */
|
.disabled {
|
||||||
h1, h2, h3, h4, h5, h6 {
|
opacity: 0.4;
|
||||||
margin: 0;
|
}
|
||||||
|
|
||||||
|
#rendition-tooltip-root > div {
|
||||||
|
font-family: "SourceSansPro", sans-serif;
|
||||||
}
|
}
|
||||||
44
lib/gui/app/i18n.ts
Normal file
44
lib/gui/app/i18n.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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 const supportedLocales = ['en', 'zh'];
|
||||||
|
|
||||||
|
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.
|
||||||
162
lib/gui/app/i18n/en.ts
Normal file
162
lib/gui/app/i18n/en.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
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;
|
||||||
154
lib/gui/app/i18n/zh-TW.ts
Normal file
154
lib/gui/app/i18n/zh-TW.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
const translation = {
|
||||||
|
translation: {
|
||||||
|
continue: '繼續',
|
||||||
|
ok: '好',
|
||||||
|
cancel: '取消',
|
||||||
|
skip: '跳過',
|
||||||
|
sure: '我確定',
|
||||||
|
warning: '請注意!',
|
||||||
|
attention: '請注意',
|
||||||
|
failed: '失敗',
|
||||||
|
completed: '完成',
|
||||||
|
yesContinue: '是的,繼續',
|
||||||
|
reallyExit: '真的要現在結束 Etcher 嗎?',
|
||||||
|
yesExit: '是的,可以結束',
|
||||||
|
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: '更改',
|
||||||
|
},
|
||||||
|
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: '系統資訊',
|
||||||
|
trimExtPartitions: '修改原始映像檔上未分配的空間(在 ext 類型分割區中)',
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
edit: '編輯',
|
||||||
|
view: '預覽',
|
||||||
|
devTool: '打開開發者工具',
|
||||||
|
window: '視窗',
|
||||||
|
help: '協助',
|
||||||
|
pro: 'Etcher 專業版',
|
||||||
|
website: 'Etcher 的官網',
|
||||||
|
issue: '提交 issue',
|
||||||
|
about: '關於 Etcher',
|
||||||
|
hide: '隱藏 Etcher',
|
||||||
|
hideOthers: '隱藏其它視窗',
|
||||||
|
unhide: '取消隱藏',
|
||||||
|
quit: '結束 Etcher',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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,13 +2,11 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Etcher</title>
|
<title>balenaEtcher</title>
|
||||||
<link rel="stylesheet" type="text/css" href="../../../node_modules/flexboxgrid/dist/flexboxgrid.css">
|
<link rel="stylesheet" type="text/css" href="index.css">
|
||||||
<link rel="stylesheet" type="text/css" href="../css/main.css">
|
|
||||||
<link rel="stylesheet" type="text/css" href="../css/desktop.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main id="main"></main>
|
<main id="main"></main>
|
||||||
<script src="../../../generated/gui.js"></script>
|
<script src="gui.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -14,21 +14,20 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as _ from 'lodash';
|
import { DrivelistDrive } from '../../../shared/drive-constraints';
|
||||||
|
|
||||||
import { Actions, store } from './store';
|
import { Actions, store } from './store';
|
||||||
|
|
||||||
export function hasAvailableDrives() {
|
export function hasAvailableDrives() {
|
||||||
return !_.isEmpty(getDrives());
|
return getDrives().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setDrives(drives: any[]) {
|
export function setDrives(drives: any[]) {
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: Actions.SET_AVAILABLE_DRIVES,
|
type: Actions.SET_AVAILABLE_TARGETS,
|
||||||
data: drives,
|
data: drives,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDrives(): any[] {
|
export function getDrives(): DrivelistDrive[] {
|
||||||
return store.getState().toJS().availableDrives;
|
return store.getState().toJS().availableDrives;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,10 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as electron from 'electron';
|
||||||
import * as sdk from 'etcher-sdk';
|
import * as sdk from 'etcher-sdk';
|
||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
|
import { DrivelistDrive } from '../../../shared/drive-constraints';
|
||||||
import { bytesToMegabytes } from '../../../shared/units';
|
import { bytesToMegabytes } from '../../../shared/units';
|
||||||
import { Actions, store } from './store';
|
import { Actions, store } from './store';
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ import { Actions, store } from './store';
|
|||||||
export function resetState() {
|
export function resetState() {
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: Actions.RESET_FLASH_STATE,
|
type: Actions.RESET_FLASH_STATE,
|
||||||
|
data: {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,8 +46,17 @@ export function isFlashing(): boolean {
|
|||||||
* start a flash process.
|
* start a flash process.
|
||||||
*/
|
*/
|
||||||
export function setFlashingFlag() {
|
export function setFlashingFlag() {
|
||||||
|
// see https://github.com/balenablocks/balena-electron-env/blob/4fce9c461f294d4a768db8f247eea6f75d7b08b0/README.md#remote-methods
|
||||||
|
try {
|
||||||
|
electron.ipcRenderer.invoke('disable-screensaver');
|
||||||
|
} catch (error) {
|
||||||
|
console.log(
|
||||||
|
"Can't disable-screensaver, we're probably not running on a balena-electron env",
|
||||||
|
);
|
||||||
|
}
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: Actions.SET_FLASHING_FLAG,
|
type: Actions.SET_FLASHING_FLAG,
|
||||||
|
data: {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +75,41 @@ export function unsetFlashingFlag(results: {
|
|||||||
type: Actions.UNSET_FLASHING_FLAG,
|
type: Actions.UNSET_FLASHING_FLAG,
|
||||||
data: results,
|
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),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,7 +120,8 @@ export function setProgressState(
|
|||||||
) {
|
) {
|
||||||
// Preserve only one decimal place
|
// Preserve only one decimal place
|
||||||
const PRECISION = 1;
|
const PRECISION = 1;
|
||||||
const data = _.assign({}, state, {
|
const data = {
|
||||||
|
...state,
|
||||||
percentage:
|
percentage:
|
||||||
state.percentage !== undefined && _.isFinite(state.percentage)
|
state.percentage !== undefined && _.isFinite(state.percentage)
|
||||||
? Math.floor(state.percentage)
|
? Math.floor(state.percentage)
|
||||||
@@ -87,15 +134,7 @@ export function setProgressState(
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
|
};
|
||||||
totalSpeed: _.attempt(() => {
|
|
||||||
if (_.isFinite(state.totalSpeed)) {
|
|
||||||
return _.round(bytesToMegabytes(state.totalSpeed), PRECISION);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: Actions.SET_FLASH_STATE,
|
type: Actions.SET_FLASH_STATE,
|
||||||
@@ -108,10 +147,7 @@ export function getFlashResults() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getFlashState() {
|
export function getFlashState() {
|
||||||
return store
|
return store.getState().get('flashState').toJS();
|
||||||
.getState()
|
|
||||||
.get('flashState')
|
|
||||||
.toJS();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wasLastFlashCancelled() {
|
export function wasLastFlashCancelled() {
|
||||||
|
|||||||
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,73 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as electron from 'electron';
|
|
||||||
import { promises as fs } from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
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
|
|
||||||
*
|
|
||||||
* NOTE: The ternary is due to this module being loaded both,
|
|
||||||
* Electron's main process and renderer process
|
|
||||||
*/
|
|
||||||
const USER_DATA_DIR = electron.app
|
|
||||||
? electron.app.getPath('userData')
|
|
||||||
: electron.remote.app.getPath('userData');
|
|
||||||
|
|
||||||
const CONFIG_PATH = path.join(USER_DATA_DIR, 'config.json');
|
|
||||||
|
|
||||||
async function readConfigFile(filename: string): Promise<any> {
|
|
||||||
let contents = '{}';
|
|
||||||
try {
|
|
||||||
contents = await fs.readFile(filename, { encoding: 'utf8' });
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== 'ENOENT') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.parse(contents);
|
|
||||||
} catch (parseError) {
|
|
||||||
console.error(parseError);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeConfigFile(filename: string, data: any): Promise<any> {
|
|
||||||
await fs.writeFile(filename, JSON.stringify(data, null, JSON_INDENT));
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readAll(): Promise<any> {
|
|
||||||
return await readConfigFile(CONFIG_PATH);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function writeAll(settings: any): Promise<any> {
|
|
||||||
return await writeConfigFile(CONFIG_PATH, settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clear(): Promise<void> {
|
|
||||||
await fs.unlink(CONFIG_PATH);
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DrivelistDrive } from '../../../shared/drive-constraints';
|
||||||
/*
|
/*
|
||||||
* Copyright 2016 balena.io
|
* Copyright 2016 balena.io
|
||||||
*
|
*
|
||||||
@@ -14,7 +15,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as _ from 'lodash';
|
import { SourceMetadata } from '../components/source-selector/source-selector';
|
||||||
|
|
||||||
import * as availableDrives from './available-drives';
|
import * as availableDrives from './available-drives';
|
||||||
import { Actions, store } from './store';
|
import { Actions, store } from './store';
|
||||||
@@ -24,7 +25,7 @@ import { Actions, store } from './store';
|
|||||||
*/
|
*/
|
||||||
export function selectDrive(driveDevice: string) {
|
export function selectDrive(driveDevice: string) {
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: Actions.SELECT_DRIVE,
|
type: Actions.SELECT_TARGET,
|
||||||
data: driveDevice,
|
data: driveDevice,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -40,10 +41,10 @@ export function toggleDrive(driveDevice: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectImage(image: any) {
|
export function selectSource(source: SourceMetadata) {
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: Actions.SELECT_IMAGE,
|
type: Actions.SELECT_SOURCE,
|
||||||
data: image,
|
data: source,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,59 +52,24 @@ export function selectImage(image: any) {
|
|||||||
* @summary Get all selected drives' devices
|
* @summary Get all selected drives' devices
|
||||||
*/
|
*/
|
||||||
export function getSelectedDevices(): string[] {
|
export function getSelectedDevices(): string[] {
|
||||||
return store
|
return store.getState().getIn(['selection', 'devices']).toJS();
|
||||||
.getState()
|
|
||||||
.getIn(['selection', 'devices'])
|
|
||||||
.toJS();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Get all selected drive objects
|
* @summary Get all selected drive objects
|
||||||
*/
|
*/
|
||||||
export function getSelectedDrives(): any[] {
|
export function getSelectedDrives(): DrivelistDrive[] {
|
||||||
const drives = availableDrives.getDrives();
|
const selectedDevices = getSelectedDevices();
|
||||||
return _.map(getSelectedDevices(), device => {
|
return availableDrives
|
||||||
return _.find(drives, { device });
|
.getDrives()
|
||||||
});
|
.filter((drive) => selectedDevices.includes(drive.device));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Get the selected image
|
* @summary Get the selected image
|
||||||
*/
|
*/
|
||||||
export function getImage() {
|
export function getImage(): SourceMetadata | undefined {
|
||||||
return _.get(store.getState().toJS(), ['selection', 'image']);
|
return store.getState().toJS().selection.image;
|
||||||
}
|
|
||||||
|
|
||||||
export function getImagePath(): string {
|
|
||||||
return _.get(store.getState().toJS(), ['selection', 'image', 'path']);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getImageSize(): number {
|
|
||||||
return _.get(store.getState().toJS(), ['selection', 'image', 'size']);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getImageUrl(): string {
|
|
||||||
return _.get(store.getState().toJS(), ['selection', 'image', 'url']);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getImageName(): string {
|
|
||||||
return _.get(store.getState().toJS(), ['selection', 'image', 'name']);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getImageLogo(): string {
|
|
||||||
return _.get(store.getState().toJS(), ['selection', 'image', 'logo']);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getImageSupportUrl(): string {
|
|
||||||
return _.get(store.getState().toJS(), ['selection', 'image', 'supportUrl']);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getImageRecommendedDriveSize(): number {
|
|
||||||
return _.get(store.getState().toJS(), [
|
|
||||||
'selection',
|
|
||||||
'image',
|
|
||||||
'recommendedDriveSize',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,7 +83,7 @@ export function hasDrive(): boolean {
|
|||||||
* @summary Check if there is a selected image
|
* @summary Check if there is a selected image
|
||||||
*/
|
*/
|
||||||
export function hasImage(): boolean {
|
export function hasImage(): boolean {
|
||||||
return Boolean(getImage());
|
return getImage() !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,19 +91,20 @@ export function hasImage(): boolean {
|
|||||||
*/
|
*/
|
||||||
export function deselectDrive(driveDevice: string) {
|
export function deselectDrive(driveDevice: string) {
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: Actions.DESELECT_DRIVE,
|
type: Actions.DESELECT_TARGET,
|
||||||
data: driveDevice,
|
data: driveDevice,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deselectImage() {
|
export function deselectImage() {
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: Actions.DESELECT_IMAGE,
|
type: Actions.DESELECT_SOURCE,
|
||||||
|
data: {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deselectAllDrives() {
|
export function deselectAllDrives() {
|
||||||
_.each(getSelectedDevices(), deselectDrive);
|
getSelectedDevices().forEach(deselectDrive);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -157,5 +124,5 @@ export function isDriveSelected(driveDevice: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedDriveDevices = getSelectedDevices();
|
const selectedDriveDevices = getSelectedDevices();
|
||||||
return _.includes(selectedDriveDevices, driveDevice);
|
return selectedDriveDevices.includes(driveDevice);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,127 +15,112 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as _debug from 'debug';
|
import * as _debug from 'debug';
|
||||||
|
import * as electron from 'electron';
|
||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
import * as packageJSON from '../../../../package.json';
|
import * as packageJSON from '../../../../package.json';
|
||||||
import * as errors from '../../../shared/errors';
|
|
||||||
import * as localSettings from './local-settings';
|
|
||||||
|
|
||||||
const debug = _debug('etcher:models:settings');
|
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
|
||||||
|
*/
|
||||||
|
function getConfigPath() {
|
||||||
|
const app = electron.app || require('@electron/remote').app;
|
||||||
|
return join(app.getPath('userData'), '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(getConfigPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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> = {
|
const DEFAULT_SETTINGS: _.Dictionary<any> = {
|
||||||
unsafeMode: false,
|
|
||||||
errorReporting: true,
|
errorReporting: true,
|
||||||
unmountOnSuccess: true,
|
updatesEnabled: ['appimage', 'nsis', 'dmg'].includes(packageJSON.packageType),
|
||||||
validateWriteOnSuccess: true,
|
|
||||||
trim: false,
|
|
||||||
updatesEnabled:
|
|
||||||
packageJSON.updates.enabled &&
|
|
||||||
!_.includes(['rpm', 'deb'], packageJSON.packageType),
|
|
||||||
lastSleptUpdateNotifier: null,
|
|
||||||
lastSleptUpdateNotifierVersion: null,
|
|
||||||
desktopNotifications: true,
|
desktopNotifications: true,
|
||||||
|
autoBlockmapping: true,
|
||||||
|
decompressFirst: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let settings = _.cloneDeep(DEFAULT_SETTINGS);
|
const settings = _.cloneDeep(DEFAULT_SETTINGS);
|
||||||
|
|
||||||
/**
|
async function load(): Promise<void> {
|
||||||
* @summary Reset settings to their default values
|
|
||||||
*/
|
|
||||||
export async function reset(): Promise<void> {
|
|
||||||
debug('reset');
|
|
||||||
// TODO: Remove default settings from config file (?)
|
|
||||||
settings = _.cloneDeep(DEFAULT_SETTINGS);
|
|
||||||
return await localSettings.writeAll(settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @summary Extend the current settings
|
|
||||||
*/
|
|
||||||
export async function assign(value: _.Dictionary<any>): Promise<void> {
|
|
||||||
debug('assign', value);
|
|
||||||
if (_.isNil(value)) {
|
|
||||||
throw errors.createError({
|
|
||||||
title: 'Missing settings',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_.isPlainObject(value)) {
|
|
||||||
throw errors.createError({
|
|
||||||
title: 'Settings must be an object',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const newSettings = _.assign({}, settings, value);
|
|
||||||
|
|
||||||
const updatedSettings = await localSettings.writeAll(newSettings);
|
|
||||||
// NOTE: Only update in memory settings when successfully written
|
|
||||||
settings = updatedSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @summary Extend the application state with the local settings
|
|
||||||
*/
|
|
||||||
export async function load(): Promise<void> {
|
|
||||||
debug('load');
|
debug('load');
|
||||||
const loadedSettings = await localSettings.readAll();
|
const loadedSettings = await readAll();
|
||||||
_.assign(settings, loadedSettings);
|
_.assign(settings, loadedSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const loaded = load();
|
||||||
* @summary Set a setting value
|
|
||||||
*/
|
export async function set(
|
||||||
export async function set(key: string, value: any): Promise<void> {
|
key: string,
|
||||||
|
value: any,
|
||||||
|
writeConfigFileFn = writeConfigFile,
|
||||||
|
): Promise<void> {
|
||||||
debug('set', key, value);
|
debug('set', key, value);
|
||||||
if (_.isNil(key)) {
|
await loaded;
|
||||||
throw errors.createError({
|
|
||||||
title: 'Missing setting key',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_.isString(key)) {
|
|
||||||
throw errors.createError({
|
|
||||||
title: `Invalid setting key: ${key}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousValue = settings[key];
|
const previousValue = settings[key];
|
||||||
settings[key] = value;
|
settings[key] = value;
|
||||||
try {
|
try {
|
||||||
await localSettings.writeAll(settings);
|
await writeConfigFileFn(getConfigPath(), settings);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
// Revert to previous value if persisting settings failed
|
// Revert to previous value if persisting settings failed
|
||||||
settings[key] = previousValue;
|
settings[key] = previousValue;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function get(key: string): Promise<any> {
|
||||||
* @summary Get a setting value
|
await loaded;
|
||||||
*/
|
return getSync(key);
|
||||||
export function get(key: string): any {
|
|
||||||
return _.cloneDeep(_.get(settings, [key]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function getSync(key: string): any {
|
||||||
* @summary Check if setting value exists
|
return _.cloneDeep(settings[key]);
|
||||||
*/
|
|
||||||
export function has(key: string): boolean {
|
|
||||||
return settings[key] != null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getAll() {
|
||||||
* @summary Get all setting values
|
|
||||||
*/
|
|
||||||
export function getAll() {
|
|
||||||
debug('getAll');
|
debug('getAll');
|
||||||
|
await loaded;
|
||||||
return _.cloneDeep(settings);
|
return _.cloneDeep(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @summary Get the default setting values
|
|
||||||
*/
|
|
||||||
export function getDefaults() {
|
|
||||||
debug('getDefaults');
|
|
||||||
return _.cloneDeep(DEFAULT_SETTINGS);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,13 +16,12 @@
|
|||||||
|
|
||||||
import * as Immutable from 'immutable';
|
import * as Immutable from 'immutable';
|
||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
|
import { basename } from 'path';
|
||||||
import * as redux from 'redux';
|
import * as redux from 'redux';
|
||||||
import * as uuidV4 from 'uuid/v4';
|
import { v4 as uuidV4 } from 'uuid';
|
||||||
|
|
||||||
import * as constraints from '../../../shared/drive-constraints';
|
import * as constraints from '../../../shared/drive-constraints';
|
||||||
import * as errors from '../../../shared/errors';
|
import * as errors from '../../../shared/errors';
|
||||||
import * as fileExtensions from '../../../shared/file-extensions';
|
|
||||||
import * as supportedFormats from '../../../shared/supported-formats';
|
|
||||||
import * as utils from '../../../shared/utils';
|
import * as utils from '../../../shared/utils';
|
||||||
import * as settings from './settings';
|
import * as settings from './settings';
|
||||||
|
|
||||||
@@ -34,7 +33,7 @@ function verifyNoNilFields(
|
|||||||
fields: string[],
|
fields: string[],
|
||||||
name: string,
|
name: string,
|
||||||
) {
|
) {
|
||||||
const nilFields = _.filter(fields, field => {
|
const nilFields = _.filter(fields, (field) => {
|
||||||
return _.isNil(_.get(object, field));
|
return _.isNil(_.get(object, field));
|
||||||
});
|
});
|
||||||
if (nilFields.length) {
|
if (nilFields.length) {
|
||||||
@@ -45,7 +44,7 @@ function verifyNoNilFields(
|
|||||||
/**
|
/**
|
||||||
* @summary FLASH_STATE fields that can't be nil
|
* @summary FLASH_STATE fields that can't be nil
|
||||||
*/
|
*/
|
||||||
const flashStateNoNilFields = ['speed', 'totalSpeed'];
|
const flashStateNoNilFields = ['speed'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary SELECT_IMAGE fields that can't be nil
|
* @summary SELECT_IMAGE fields that can't be nil
|
||||||
@@ -55,7 +54,7 @@ const selectImageNoNilFields = ['path', 'extension'];
|
|||||||
/**
|
/**
|
||||||
* @summary Application default state
|
* @summary Application default state
|
||||||
*/
|
*/
|
||||||
const DEFAULT_STATE = Immutable.fromJS({
|
export const DEFAULT_STATE = Immutable.fromJS({
|
||||||
applicationSessionUuid: '',
|
applicationSessionUuid: '',
|
||||||
flashingWorkflowUuid: '',
|
flashingWorkflowUuid: '',
|
||||||
availableDrives: [],
|
availableDrives: [],
|
||||||
@@ -63,31 +62,34 @@ const DEFAULT_STATE = Immutable.fromJS({
|
|||||||
devices: Immutable.OrderedSet(),
|
devices: Immutable.OrderedSet(),
|
||||||
},
|
},
|
||||||
isFlashing: false,
|
isFlashing: false,
|
||||||
|
devicePaths: [],
|
||||||
|
failedDeviceErrors: [],
|
||||||
flashResults: {},
|
flashResults: {},
|
||||||
flashState: {
|
flashState: {
|
||||||
flashing: 0,
|
active: 0,
|
||||||
verifying: 0,
|
|
||||||
successful: 0,
|
|
||||||
failed: 0,
|
failed: 0,
|
||||||
percentage: 0,
|
percentage: 0,
|
||||||
speed: null,
|
speed: null,
|
||||||
totalSpeed: null,
|
averageSpeed: null,
|
||||||
},
|
},
|
||||||
|
lastAverageFlashingSpeed: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Application supported action messages
|
* @summary Application supported action messages
|
||||||
*/
|
*/
|
||||||
export enum Actions {
|
export enum Actions {
|
||||||
SET_AVAILABLE_DRIVES,
|
SET_DEVICE_PATHS,
|
||||||
|
SET_FAILED_DEVICE_ERRORS,
|
||||||
|
SET_AVAILABLE_TARGETS,
|
||||||
SET_FLASH_STATE,
|
SET_FLASH_STATE,
|
||||||
RESET_FLASH_STATE,
|
RESET_FLASH_STATE,
|
||||||
SET_FLASHING_FLAG,
|
SET_FLASHING_FLAG,
|
||||||
UNSET_FLASHING_FLAG,
|
UNSET_FLASHING_FLAG,
|
||||||
SELECT_DRIVE,
|
SELECT_TARGET,
|
||||||
SELECT_IMAGE,
|
SELECT_SOURCE,
|
||||||
DESELECT_DRIVE,
|
DESELECT_TARGET,
|
||||||
DESELECT_IMAGE,
|
DESELECT_SOURCE,
|
||||||
SET_APPLICATION_SESSION_UUID,
|
SET_APPLICATION_SESSION_UUID,
|
||||||
SET_FLASHING_WORKFLOW_UUID,
|
SET_FLASHING_WORKFLOW_UUID,
|
||||||
}
|
}
|
||||||
@@ -115,7 +117,7 @@ function storeReducer(
|
|||||||
action: Action,
|
action: Action,
|
||||||
): typeof DEFAULT_STATE {
|
): typeof DEFAULT_STATE {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case Actions.SET_AVAILABLE_DRIVES: {
|
case Actions.SET_AVAILABLE_TARGETS: {
|
||||||
// Type: action.data : Array<DriveObject>
|
// Type: action.data : Array<DriveObject>
|
||||||
|
|
||||||
if (!action.data) {
|
if (!action.data) {
|
||||||
@@ -124,7 +126,7 @@ function storeReducer(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const drives = action.data;
|
let drives = action.data;
|
||||||
|
|
||||||
if (!_.isArray(drives) || !_.every(drives, _.isObject)) {
|
if (!_.isArray(drives) || !_.every(drives, _.isObject)) {
|
||||||
throw errors.createError({
|
throw errors.createError({
|
||||||
@@ -132,6 +134,20 @@ function storeReducer(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 newState = state.set('availableDrives', Immutable.fromJS(drives));
|
||||||
const selectedDevices = newState.getIn(['selection', 'devices']).toJS();
|
const selectedDevices = newState.getIn(['selection', 'devices']).toJS();
|
||||||
|
|
||||||
@@ -148,7 +164,7 @@ function storeReducer(
|
|||||||
) {
|
) {
|
||||||
// Deselect this drive gone from availableDrives
|
// Deselect this drive gone from availableDrives
|
||||||
return storeReducer(accState, {
|
return storeReducer(accState, {
|
||||||
type: Actions.DESELECT_DRIVE,
|
type: Actions.DESELECT_TARGET,
|
||||||
data: device,
|
data: device,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -159,7 +175,7 @@ function storeReducer(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const shouldAutoselectAll = Boolean(
|
const shouldAutoselectAll = Boolean(
|
||||||
settings.get('disableExplicitDriveSelection'),
|
settings.getSync('autoSelectAllDrives'),
|
||||||
);
|
);
|
||||||
const AUTOSELECT_DRIVE_COUNT = 1;
|
const AUTOSELECT_DRIVE_COUNT = 1;
|
||||||
const nonStaleSelectedDevices = nonStaleNewState
|
const nonStaleSelectedDevices = nonStaleNewState
|
||||||
@@ -181,29 +197,24 @@ function storeReducer(
|
|||||||
drives,
|
drives,
|
||||||
(accState, drive) => {
|
(accState, drive) => {
|
||||||
if (
|
if (
|
||||||
_.every([
|
constraints.isDriveValid(drive, image) &&
|
||||||
constraints.isDriveValid(drive, image),
|
!drive.isReadOnly &&
|
||||||
constraints.isDriveSizeRecommended(drive, image),
|
constraints.isDriveSizeRecommended(drive, image) &&
|
||||||
|
// We don't want to auto-select large drives except if autoSelectAllDrives is true
|
||||||
// We don't want to auto-select large drives
|
(!constraints.isDriveSizeLarge(drive) || shouldAutoselectAll) &&
|
||||||
!constraints.isDriveSizeLarge(drive),
|
// We don't want to auto-select system drives
|
||||||
|
!constraints.isSystemDrive(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
|
// Auto-select this drive
|
||||||
return storeReducer(accState, {
|
return storeReducer(accState, {
|
||||||
type: Actions.SELECT_DRIVE,
|
type: Actions.SELECT_TARGET,
|
||||||
data: drive.device,
|
data: drive.device,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deselect this drive in case it still is selected
|
// Deselect this drive in case it still is selected
|
||||||
return storeReducer(accState, {
|
return storeReducer(accState, {
|
||||||
type: Actions.DESELECT_DRIVE,
|
type: Actions.DESELECT_TARGET,
|
||||||
data: drive.device,
|
data: drive.device,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -225,17 +236,7 @@ function storeReducer(
|
|||||||
|
|
||||||
verifyNoNilFields(action.data, flashStateNoNilFields, 'flash');
|
verifyNoNilFields(action.data, flashStateNoNilFields, 'flash');
|
||||||
|
|
||||||
if (
|
if (!_.every(_.pick(action.data, ['active', 'failed']), _.isFinite)) {
|
||||||
!_.every(
|
|
||||||
_.pick(action.data, [
|
|
||||||
'flashing',
|
|
||||||
'verifying',
|
|
||||||
'successful',
|
|
||||||
'failed',
|
|
||||||
]),
|
|
||||||
_.isFinite,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw errors.createError({
|
throw errors.createError({
|
||||||
title: 'State quantity field(s) not finite number',
|
title: 'State quantity field(s) not finite number',
|
||||||
});
|
});
|
||||||
@@ -256,7 +257,11 @@ function storeReducer(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return state.set('flashState', Immutable.fromJS(action.data));
|
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: {
|
case Actions.RESET_FLASH_STATE: {
|
||||||
@@ -264,6 +269,12 @@ function storeReducer(
|
|||||||
.set('isFlashing', false)
|
.set('isFlashing', false)
|
||||||
.set('flashState', DEFAULT_STATE.get('flashState'))
|
.set('flashState', DEFAULT_STATE.get('flashState'))
|
||||||
.set('flashResults', DEFAULT_STATE.get('flashResults'))
|
.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');
|
.delete('flashUuid');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +296,7 @@ function storeReducer(
|
|||||||
|
|
||||||
_.defaults(action.data, {
|
_.defaults(action.data, {
|
||||||
cancelled: false,
|
cancelled: false,
|
||||||
|
skip: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!_.isBoolean(action.data.cancelled)) {
|
if (!_.isBoolean(action.data.cancelled)) {
|
||||||
@@ -319,13 +331,25 @@ function storeReducer(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
return state
|
||||||
.set('isFlashing', false)
|
.set('isFlashing', false)
|
||||||
.set('flashResults', Immutable.fromJS(action.data))
|
.set('flashResults', Immutable.fromJS(action.data))
|
||||||
.set('flashState', DEFAULT_STATE.get('flashState'));
|
.set('flashState', DEFAULT_STATE.get('flashState'));
|
||||||
}
|
}
|
||||||
|
|
||||||
case Actions.SELECT_DRIVE: {
|
case Actions.SELECT_TARGET: {
|
||||||
// Type: action.data : String
|
// Type: action.data : String
|
||||||
|
|
||||||
const device = action.data;
|
const device = action.data;
|
||||||
@@ -375,10 +399,12 @@ function storeReducer(
|
|||||||
// with image-stream / supported-formats, and have *one*
|
// with image-stream / supported-formats, and have *one*
|
||||||
// place where all the image extension / format handling
|
// place where all the image extension / format handling
|
||||||
// takes place, to avoid having to check 2+ locations with different logic
|
// takes place, to avoid having to check 2+ locations with different logic
|
||||||
case Actions.SELECT_IMAGE: {
|
case Actions.SELECT_SOURCE: {
|
||||||
// Type: action.data : ImageObject
|
// Type: action.data : ImageObject
|
||||||
|
|
||||||
|
if (!action.data.drive) {
|
||||||
verifyNoNilFields(action.data, selectImageNoNilFields, 'image');
|
verifyNoNilFields(action.data, selectImageNoNilFields, 'image');
|
||||||
|
}
|
||||||
|
|
||||||
if (!_.isString(action.data.path)) {
|
if (!_.isString(action.data.path)) {
|
||||||
throw errors.createError({
|
throw errors.createError({
|
||||||
@@ -386,51 +412,6 @@ function storeReducer(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
const MINIMUM_IMAGE_SIZE = 0;
|
||||||
|
|
||||||
if (action.data.size !== undefined) {
|
if (action.data.size !== undefined) {
|
||||||
@@ -485,7 +466,7 @@ function storeReducer(
|
|||||||
!constraints.isDriveSizeRecommended(drive, action.data)
|
!constraints.isDriveSizeRecommended(drive, action.data)
|
||||||
) {
|
) {
|
||||||
return storeReducer(accState, {
|
return storeReducer(accState, {
|
||||||
type: Actions.DESELECT_DRIVE,
|
type: Actions.DESELECT_TARGET,
|
||||||
data: device,
|
data: device,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -496,7 +477,7 @@ function storeReducer(
|
|||||||
).setIn(['selection', 'image'], Immutable.fromJS(action.data));
|
).setIn(['selection', 'image'], Immutable.fromJS(action.data));
|
||||||
}
|
}
|
||||||
|
|
||||||
case Actions.DESELECT_DRIVE: {
|
case Actions.DESELECT_TARGET: {
|
||||||
// Type: action.data : String
|
// Type: action.data : String
|
||||||
|
|
||||||
if (!action.data) {
|
if (!action.data) {
|
||||||
@@ -520,7 +501,7 @@ function storeReducer(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case Actions.DESELECT_IMAGE: {
|
case Actions.DESELECT_SOURCE: {
|
||||||
return state.deleteIn(['selection', 'image']);
|
return state.deleteIn(['selection', 'image']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,6 +513,14 @@ function storeReducer(
|
|||||||
return state.set('flashingWorkflowUuid', action.data);
|
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: {
|
default: {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,94 +15,189 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
import * as resinCorvus from 'resin-corvus/browser';
|
import { Client, createClient, createNoopClient } from 'analytics-client';
|
||||||
|
import * as SentryRenderer from '@sentry/electron/renderer';
|
||||||
import * as packageJSON from '../../../../package.json';
|
|
||||||
import { getConfig, hasProps } from '../../../shared/utils';
|
|
||||||
import * as settings from '../models/settings';
|
import * as settings from '../models/settings';
|
||||||
|
import { store } from '../models/store';
|
||||||
|
import * as packageJSON from '../../../../package.json';
|
||||||
|
|
||||||
const sentryToken =
|
type AnalyticsPayload = _.Dictionary<any>;
|
||||||
settings.get('analyticsSentryToken') ||
|
|
||||||
_.get(packageJSON, ['analytics', 'sentry', 'token']);
|
|
||||||
const mixpanelToken =
|
|
||||||
settings.get('analyticsMixpanelToken') ||
|
|
||||||
_.get(packageJSON, ['analytics', 'mixpanel', 'token']);
|
|
||||||
|
|
||||||
const configUrl =
|
const clearUserPath = (filename: string): string => {
|
||||||
settings.get('configUrl') || 'https://balena.io/etcher/static/config.json';
|
const generatedFile = filename.split('generated').reverse()[0];
|
||||||
|
return generatedFile !== filename ? `generated${generatedFile}` : filename;
|
||||||
const DEFAULT_PROBABILITY = 0.1;
|
|
||||||
|
|
||||||
const services = {
|
|
||||||
sentry: sentryToken,
|
|
||||||
mixpanel: mixpanelToken,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
resinCorvus.install({
|
export const anonymizeSentryData = (
|
||||||
services,
|
event: SentryRenderer.Event,
|
||||||
options: {
|
): SentryRenderer.Event => {
|
||||||
release: packageJSON.version,
|
event.exception?.values?.forEach((exception) => {
|
||||||
shouldReport: () => {
|
exception.stacktrace?.frames?.forEach((frame) => {
|
||||||
return settings.get('errorReporting');
|
if (frame.filename) {
|
||||||
},
|
frame.filename = clearUserPath(frame.filename);
|
||||||
mixpanelDeferred: true,
|
}
|
||||||
},
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let mixpanelSample = DEFAULT_PROBABILITY;
|
event.breadcrumbs?.forEach((breadcrumb) => {
|
||||||
|
if (breadcrumb.data?.url) {
|
||||||
|
breadcrumb.data.url = clearUserPath(breadcrumb.data.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (event.request?.url) {
|
||||||
|
event.request.url = clearUserPath(event.request.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return event;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractPathRegex = /(.*)(^|\s)(file\:\/\/)?(\w\:)?([\\\/].+)/;
|
||||||
|
const etcherSegmentMarkers = ['app.asar', 'Resources'];
|
||||||
|
|
||||||
|
export const anonymizePath = (input: string) => {
|
||||||
|
// First, extract a part of the value that matches a path pattern.
|
||||||
|
const match = extractPathRegex.exec(input);
|
||||||
|
if (match === null) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
const mainPart = match[5];
|
||||||
|
const space = match[2];
|
||||||
|
const beginning = match[1];
|
||||||
|
const uriPrefix = match[3] || '';
|
||||||
|
|
||||||
|
// We have to deal with both Windows and POSIX here.
|
||||||
|
// The path starts with its separator (we work with absolute paths).
|
||||||
|
const sep = mainPart[0];
|
||||||
|
const segments = mainPart.split(sep);
|
||||||
|
|
||||||
|
// Moving from the end, find the first marker and cut the path from there.
|
||||||
|
const startCutIndex = _.findLastIndex(segments, (segment) =>
|
||||||
|
etcherSegmentMarkers.includes(segment),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
beginning +
|
||||||
|
space +
|
||||||
|
uriPrefix +
|
||||||
|
'[PERSONAL PATH]' +
|
||||||
|
sep +
|
||||||
|
segments.splice(startCutIndex).join(sep)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeAnonymizePath = (input: string) => {
|
||||||
|
try {
|
||||||
|
return anonymizePath(input);
|
||||||
|
} catch (e) {
|
||||||
|
return '[ANONYMIZE PATH FAILED]';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sensitiveEtcherProperties = [
|
||||||
|
'error.description',
|
||||||
|
'error.message',
|
||||||
|
'error.stack',
|
||||||
|
'image',
|
||||||
|
'image.path',
|
||||||
|
'path',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const anonymizeAnalyticsPayload = (
|
||||||
|
data: AnalyticsPayload,
|
||||||
|
): AnalyticsPayload => {
|
||||||
|
for (const prop of sensitiveEtcherProperties) {
|
||||||
|
const value = data[prop];
|
||||||
|
if (value != null) {
|
||||||
|
data[prop] = safeAnonymizePath(value.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
let analyticsClient: Client;
|
||||||
/**
|
/**
|
||||||
* @summary Init analytics configurations
|
* @summary Init analytics configurations
|
||||||
*/
|
*/
|
||||||
async function initConfig() {
|
export const initAnalytics = _.once(() => {
|
||||||
let validatedConfig = null;
|
const dsn =
|
||||||
try {
|
settings.getSync('analyticsSentryToken') ||
|
||||||
const config = await getConfig(configUrl);
|
_.get(packageJSON, ['analytics', 'sentry', 'token']);
|
||||||
const mixpanel = _.get(config, ['analytics', 'mixpanel'], {});
|
SentryRenderer.init({ dsn, beforeSend: anonymizeSentryData });
|
||||||
mixpanelSample = mixpanel.probability || DEFAULT_PROBABILITY;
|
|
||||||
if (isClientEligible(mixpanelSample)) {
|
|
||||||
validatedConfig = validateMixpanelConfig(mixpanel);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
resinCorvus.logException(err);
|
|
||||||
}
|
|
||||||
resinCorvus.setConfigs({
|
|
||||||
mixpanel: validatedConfig,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
initConfig();
|
const projectName =
|
||||||
|
settings.getSync('analyticsAmplitudeToken') ||
|
||||||
|
_.get(packageJSON, ['analytics', 'amplitude', 'token']);
|
||||||
|
|
||||||
/**
|
const clientConfig = {
|
||||||
* @summary Check that the client is eligible for analytics
|
projectName,
|
||||||
*/
|
endpoint: 'data.balena-cloud.com',
|
||||||
function isClientEligible(probability: number) {
|
componentName: 'etcher',
|
||||||
return Math.random() < probability;
|
componentVersion: packageJSON.version,
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @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 (hasProps(config, ['HTTP_PROTOCOL', 'api_host'])) {
|
analyticsClient = projectName
|
||||||
mixpanelConfig.api_host = `${config.HTTP_PROTOCOL}://${config.api_host}`;
|
? createClient(clientConfig)
|
||||||
|
: createNoopClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
const getCircularReplacer = () => {
|
||||||
|
const seen = new WeakSet();
|
||||||
|
return (key: any, value: any) => {
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
if (seen.has(value)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return mixpanelConfig;
|
seen.add(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function flattenObject(obj: any) {
|
||||||
|
const toReturn: AnalyticsPayload = {};
|
||||||
|
|
||||||
|
for (const i in obj) {
|
||||||
|
if (!obj.hasOwnProperty(i)) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
if (Array.isArray(obj[i])) {
|
||||||
* @summary Log a debug message
|
toReturn[i] = obj[i];
|
||||||
*
|
continue;
|
||||||
* @description
|
}
|
||||||
* This function sends the debug message to error reporting services.
|
|
||||||
*/
|
if (typeof obj[i] === 'object' && obj[i] !== null) {
|
||||||
export const logDebug = resinCorvus.logDebug;
|
const flatObject = flattenObject(obj[i]);
|
||||||
|
for (const x in flatObject) {
|
||||||
|
if (!flatObject.hasOwnProperty(x)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
toReturn[i.toLowerCase() + '.' + x.toLowerCase()] = flatObject[x];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toReturn[i] = obj[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return toReturn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEvent(data: any): AnalyticsPayload {
|
||||||
|
const event = JSON.parse(JSON.stringify(data, getCircularReplacer()));
|
||||||
|
return anonymizeAnalyticsPayload(flattenObject(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportAnalytics(message: string, data: AnalyticsPayload = {}) {
|
||||||
|
const { applicationSessionUuid, flashingWorkflowUuid } = store
|
||||||
|
.getState()
|
||||||
|
.toJS();
|
||||||
|
|
||||||
|
const event = formatEvent({
|
||||||
|
...data,
|
||||||
|
applicationSessionUuid,
|
||||||
|
flashingWorkflowUuid,
|
||||||
|
});
|
||||||
|
analyticsClient.track(message, event);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Log an event
|
* @summary Log an event
|
||||||
@@ -110,8 +205,12 @@ export const logDebug = resinCorvus.logDebug;
|
|||||||
* @description
|
* @description
|
||||||
* This function sends the debug message to product analytics services.
|
* This function sends the debug message to product analytics services.
|
||||||
*/
|
*/
|
||||||
export function logEvent(message: string, data: any) {
|
export async function logEvent(message: string, data: AnalyticsPayload = {}) {
|
||||||
resinCorvus.logEvent(message, { ...data, sample: mixpanelSample });
|
const shouldReportAnalytics = await settings.get('errorReporting');
|
||||||
|
if (shouldReportAnalytics) {
|
||||||
|
initAnalytics();
|
||||||
|
reportAnalytics(message, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,4 +219,11 @@ export function logEvent(message: string, data: any) {
|
|||||||
* @description
|
* @description
|
||||||
* This function logs an exception to error reporting services.
|
* This function logs an exception to error reporting services.
|
||||||
*/
|
*/
|
||||||
export const logException = resinCorvus.logException;
|
export function logException(error: any) {
|
||||||
|
const shouldReportErrors = settings.getSync('errorReporting');
|
||||||
|
console.error(error);
|
||||||
|
if (shouldReportErrors) {
|
||||||
|
initAnalytics();
|
||||||
|
SentryRenderer.captureException(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
184
lib/gui/app/modules/api.ts
Normal file
184
lib/gui/app/modules/api.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
/** This function will :
|
||||||
|
* - start the ipc server (api)
|
||||||
|
* - spawn the child process (privileged or not)
|
||||||
|
* - wait for the child process to connect to the api
|
||||||
|
* - return a promise that will resolve with the emit function for the api
|
||||||
|
*
|
||||||
|
* //TODO:
|
||||||
|
* - this should be refactored to reverse the control flow:
|
||||||
|
* - the child process should be the server
|
||||||
|
* - this should be the client
|
||||||
|
* - replace the current node-ipc api with a websocket api
|
||||||
|
* - centralise the api for both the writer and the scanner instead of having two instances running
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as ipc from 'node-ipc';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as packageJSON from '../../../../package.json';
|
||||||
|
import * as permissions from '../../../shared/permissions';
|
||||||
|
import { getAppPath } from '../../../shared/get-app-path';
|
||||||
|
import * as errors from '../../../shared/errors';
|
||||||
|
|
||||||
|
const THREADS_PER_CPU = 16;
|
||||||
|
|
||||||
|
// NOTE: Ensure this isn't disabled, as it will cause
|
||||||
|
// the stdout maxBuffer size to be exceeded when flashing
|
||||||
|
ipc.config.silent = true;
|
||||||
|
|
||||||
|
function writerArgv(): string[] {
|
||||||
|
let entryPoint = path.join(getAppPath(), 'generated', 'etcher-util');
|
||||||
|
// 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 [entryPoint];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writerEnv(
|
||||||
|
IPC_CLIENT_ID: string,
|
||||||
|
IPC_SERVER_ID: string,
|
||||||
|
IPC_SOCKET_ROOT: string,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
IPC_SERVER_ID,
|
||||||
|
IPC_CLIENT_ID,
|
||||||
|
IPC_SOCKET_ROOT,
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spawnChild({
|
||||||
|
withPrivileges,
|
||||||
|
IPC_CLIENT_ID,
|
||||||
|
IPC_SERVER_ID,
|
||||||
|
IPC_SOCKET_ROOT,
|
||||||
|
}: {
|
||||||
|
withPrivileges: boolean;
|
||||||
|
IPC_CLIENT_ID: string;
|
||||||
|
IPC_SERVER_ID: string;
|
||||||
|
IPC_SOCKET_ROOT: string;
|
||||||
|
}) {
|
||||||
|
const argv = writerArgv();
|
||||||
|
const env = writerEnv(IPC_CLIENT_ID, IPC_SERVER_ID, IPC_SOCKET_ROOT);
|
||||||
|
if (withPrivileges) {
|
||||||
|
return await permissions.elevateCommand(argv, {
|
||||||
|
applicationName: packageJSON.displayName,
|
||||||
|
environment: env,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const process = await spawn(argv[0], argv.slice(1), {
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
return { cancelled: false, process };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminateServer(server: any) {
|
||||||
|
// 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 server.sockets) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
server.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: replace the custom ipc events by one generic "message" for all communication with the backend
|
||||||
|
function startApiAndSpawnChild({
|
||||||
|
withPrivileges,
|
||||||
|
}: {
|
||||||
|
withPrivileges: boolean;
|
||||||
|
}): Promise<any> {
|
||||||
|
// There might be multiple Etcher instances running at
|
||||||
|
// the same time, also we might spawn multiple child and api so we must ensure each IPC
|
||||||
|
// server/client has a different name.
|
||||||
|
const IPC_SERVER_ID = `etcher-server-${process.pid}-${Date.now()}-${
|
||||||
|
withPrivileges ? 'privileged' : 'unprivileged'
|
||||||
|
}}}`;
|
||||||
|
const IPC_CLIENT_ID = `etcher-client-${process.pid}-${Date.now()}-${
|
||||||
|
withPrivileges ? 'privileged' : 'unprivileged'
|
||||||
|
}}`;
|
||||||
|
|
||||||
|
const IPC_SOCKET_ROOT = path.join(
|
||||||
|
process.env.XDG_RUNTIME_DIR || os.tmpdir(),
|
||||||
|
path.sep,
|
||||||
|
);
|
||||||
|
|
||||||
|
ipc.config.id = IPC_SERVER_ID;
|
||||||
|
ipc.config.socketRoot = IPC_SOCKET_ROOT;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ipc.serve();
|
||||||
|
|
||||||
|
// log is special message which brings back the logs from the child process and prints them to the console
|
||||||
|
ipc.server.on('log', (message: string) => {
|
||||||
|
console.log(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
// api to register more handlers with callbacks
|
||||||
|
const registerHandler = (event: string, handler: any) => {
|
||||||
|
ipc.server.on(event, handler);
|
||||||
|
};
|
||||||
|
|
||||||
|
// once api is ready (means child process is connected) we pass the emit and terminate function to the caller
|
||||||
|
ipc.server.on('ready', (_: any, socket) => {
|
||||||
|
const emit = (channel: string, data: any) => {
|
||||||
|
ipc.server.emit(socket, channel, data);
|
||||||
|
};
|
||||||
|
resolve({
|
||||||
|
emit,
|
||||||
|
terminateServer: () => terminateServer(ipc.server),
|
||||||
|
registerHandler,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// on api error we terminate
|
||||||
|
ipc.server.on('error', (error: any) => {
|
||||||
|
terminateServer(ipc.server);
|
||||||
|
const errorObject = errors.fromJSON(error);
|
||||||
|
reject(errorObject);
|
||||||
|
});
|
||||||
|
|
||||||
|
// when the api is started we spawn the child process
|
||||||
|
ipc.server.on('start', async () => {
|
||||||
|
try {
|
||||||
|
const results = await spawnChild({
|
||||||
|
withPrivileges,
|
||||||
|
IPC_CLIENT_ID,
|
||||||
|
IPC_SERVER_ID,
|
||||||
|
IPC_SOCKET_ROOT,
|
||||||
|
});
|
||||||
|
// this will happen if the child is spawned withPrivileges and privileges has been rejected
|
||||||
|
if (results.cancelled) {
|
||||||
|
reject();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// start the server
|
||||||
|
ipc.server.start();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { startApiAndSpawnChild };
|
||||||
@@ -15,41 +15,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Drive as DrivelistDrive } from 'drivelist';
|
import { Drive as DrivelistDrive } from 'drivelist';
|
||||||
import * as electron from 'electron';
|
|
||||||
import * as sdk from 'etcher-sdk';
|
import * as sdk from 'etcher-sdk';
|
||||||
import * as _ from 'lodash';
|
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 errors from '../../../shared/errors';
|
||||||
import * as permissions from '../../../shared/permissions';
|
import { SourceMetadata } from '../../../shared/typings/source-selector';
|
||||||
import * as flashState from '../models/flash-state';
|
import * as flashState from '../models/flash-state';
|
||||||
import * as selectionState from '../models/selection-state';
|
import * as selectionState from '../models/selection-state';
|
||||||
import * as settings from '../models/settings';
|
import * as settings from '../models/settings';
|
||||||
import { store } from '../models/store';
|
|
||||||
import * as analytics from '../modules/analytics';
|
import * as analytics from '../modules/analytics';
|
||||||
import * as windowProgress from '../os/window-progress';
|
import * as windowProgress from '../os/window-progress';
|
||||||
import { updateLock } from './update-lock';
|
import { startApiAndSpawnChild } from './api';
|
||||||
|
import { terminateScanningServer } from '../app';
|
||||||
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
|
* @summary Handle a flash error and log it to analytics
|
||||||
@@ -60,8 +36,6 @@ function handleErrorLogging(
|
|||||||
) {
|
) {
|
||||||
const eventData = {
|
const eventData = {
|
||||||
...analyticsData,
|
...analyticsData,
|
||||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
|
||||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
|
|
||||||
flashInstanceUuid: flashState.getFlashUuid(),
|
flashInstanceUuid: flashState.getFlashUuid(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,179 +57,147 @@ function handleErrorLogging(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function terminateServer() {
|
let cancelEmitter: (type: string) => void | undefined;
|
||||||
// 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[] {
|
interface FlashResults {
|
||||||
let entryPoint = electron.remote.app.getAppPath();
|
skip?: boolean;
|
||||||
// AppImages run over FUSE, so the files inside the mount point
|
cancelled?: boolean;
|
||||||
// can only be accessed by the user that mounted the AppImage.
|
results?: {
|
||||||
// This means we can't re-spawn Etcher as root from the same
|
bytesWritten: number;
|
||||||
// mount-point, and as a workaround, we re-mount the original
|
devices: {
|
||||||
// AppImage as root.
|
failed: number;
|
||||||
if (os.platform() === 'linux' && process.env.APPIMAGE && process.env.APPDIR) {
|
successful: number;
|
||||||
entryPoint = entryPoint.replace(process.env.APPDIR, '');
|
};
|
||||||
return [
|
errors: Error[];
|
||||||
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 {
|
async function performWrite(
|
||||||
cancelled?: boolean;
|
image: SourceMetadata,
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @summary Perform write operation
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* This function is extracted for testing purposes.
|
|
||||||
*/
|
|
||||||
export function performWrite(
|
|
||||||
image: string,
|
|
||||||
drives: DrivelistDrive[],
|
drives: DrivelistDrive[],
|
||||||
onProgress: sdk.multiWrite.OnProgressFunction,
|
onProgress: sdk.multiWrite.OnProgressFunction,
|
||||||
): Promise<{ cancelled?: boolean }> {
|
): Promise<{ cancelled?: boolean }> {
|
||||||
let cancelled = false;
|
const { autoBlockmapping, decompressFirst } = await settings.getAll();
|
||||||
ipc.serve();
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
ipc.server.on('error', error => {
|
|
||||||
terminateServer();
|
|
||||||
const errorObject = errors.fromJSON(error);
|
|
||||||
reject(errorObject);
|
|
||||||
});
|
|
||||||
|
|
||||||
ipc.server.on('log', message => {
|
console.log({ image, drives });
|
||||||
console.log(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
return await new Promise(async (resolve, reject) => {
|
||||||
const flashResults: FlashResults = {};
|
const flashResults: FlashResults = {};
|
||||||
|
|
||||||
const analyticsData = {
|
const analyticsData = {
|
||||||
image,
|
image,
|
||||||
drives,
|
drives,
|
||||||
driveCount: drives.length,
|
driveCount: drives.length,
|
||||||
uuid: flashState.getFlashUuid(),
|
uuid: flashState.getFlashUuid(),
|
||||||
flashInstanceUuid: flashState.getFlashUuid(),
|
flashInstanceUuid: flashState.getFlashUuid(),
|
||||||
unmountOnSuccess: settings.get('unmountOnSuccess'),
|
|
||||||
validateWriteOnSuccess: settings.get('validateWriteOnSuccess'),
|
|
||||||
trim: settings.get('trim'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ipc.server.on('fail', ({ error }) => {
|
const onFail = ({ device, error }) => {
|
||||||
|
console.log('fail event');
|
||||||
|
console.log(device);
|
||||||
|
console.log(error);
|
||||||
|
if (device.devicePath) {
|
||||||
|
flashState.addFailedDeviceError({ device, error });
|
||||||
|
}
|
||||||
handleErrorLogging(error, analyticsData);
|
handleErrorLogging(error, analyticsData);
|
||||||
});
|
finish();
|
||||||
|
};
|
||||||
|
|
||||||
ipc.server.on('done', event => {
|
const onDone = (event) => {
|
||||||
event.results.errors = _.map(event.results.errors, data => {
|
console.log('done event');
|
||||||
|
event.results.errors = event.results.errors.map(
|
||||||
|
(data: Dictionary<any> & { message: string }) => {
|
||||||
return errors.fromJSON(data);
|
return errors.fromJSON(data);
|
||||||
});
|
},
|
||||||
_.merge(flashResults, event);
|
);
|
||||||
});
|
flashResults.results = event.results;
|
||||||
|
finish();
|
||||||
|
};
|
||||||
|
|
||||||
ipc.server.on('abort', () => {
|
const onAbort = () => {
|
||||||
terminateServer();
|
console.log('abort event');
|
||||||
cancelled = true;
|
flashResults.cancelled = true;
|
||||||
});
|
finish();
|
||||||
|
};
|
||||||
|
|
||||||
// @ts-ignore
|
const onSkip = () => {
|
||||||
ipc.server.on('state', onProgress);
|
console.log('skip event');
|
||||||
|
flashResults.skip = true;
|
||||||
|
finish();
|
||||||
|
};
|
||||||
|
|
||||||
ipc.server.on('ready', (_data, socket) => {
|
const finish = () => {
|
||||||
ipc.server.emit(socket, 'write', {
|
|
||||||
imagePath: image,
|
|
||||||
destinations: drives,
|
|
||||||
validateWriteOnSuccess: settings.get('validateWriteOnSuccess'),
|
|
||||||
trim: settings.get('trim'),
|
|
||||||
unmountOnSuccess: settings.get('unmountOnSuccess'),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const argv = writerArgv();
|
|
||||||
|
|
||||||
ipc.server.on('start', async () => {
|
|
||||||
console.log(`Elevating command: ${_.join(argv, ' ')}`);
|
|
||||||
const env = writerEnv();
|
|
||||||
try {
|
|
||||||
const results = await permissions.elevateCommand(argv, {
|
|
||||||
applicationName: packageJSON.displayName,
|
|
||||||
environment: env,
|
|
||||||
});
|
|
||||||
flashResults.cancelled = cancelled || results.cancelled;
|
|
||||||
} 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();
|
|
||||||
}
|
|
||||||
console.log('Flash results', flashResults);
|
console.log('Flash results', flashResults);
|
||||||
|
|
||||||
// This likely means the child died halfway through
|
// The flash wasn't cancelled and we didn't get a 'done' event
|
||||||
|
// Catch unexepected situation
|
||||||
if (
|
if (
|
||||||
!flashResults.cancelled &&
|
!flashResults.cancelled &&
|
||||||
!_.get(flashResults, ['results', 'bytesWritten'])
|
!flashResults.skip &&
|
||||||
|
flashResults.results === undefined
|
||||||
) {
|
) {
|
||||||
throw errors.createUserError({
|
console.log(flashResults);
|
||||||
|
reject(
|
||||||
|
errors.createUserError({
|
||||||
title: 'The writer process ended unexpectedly',
|
title: 'The writer process ended unexpectedly',
|
||||||
description:
|
description:
|
||||||
'Please try again, and contact the Etcher team if the problem persists',
|
'Please try again, and contact the Etcher team if the problem persists',
|
||||||
code: 'ECHILDDIED',
|
}),
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Terminating IPC server');
|
||||||
|
terminateServer();
|
||||||
resolve(flashResults);
|
resolve(flashResults);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Spawn the child process with privileges and wait for the connection to be made
|
||||||
|
const { emit, registerHandler, terminateServer } =
|
||||||
|
await startApiAndSpawnChild({
|
||||||
|
withPrivileges: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear the update lock timer to prevent longer
|
registerHandler('state', onProgress);
|
||||||
// flashing timing it out, and releasing the lock
|
registerHandler('fail', onFail);
|
||||||
updateLock.pause();
|
registerHandler('done', onDone);
|
||||||
ipc.server.start();
|
registerHandler('abort', onAbort);
|
||||||
|
registerHandler('skip', onSkip);
|
||||||
|
|
||||||
|
cancelEmitter = (cancelStatus: string) => emit(cancelStatus);
|
||||||
|
|
||||||
|
// Now that we know we're connected we can instruct the child process to start the write
|
||||||
|
const paramaters = {
|
||||||
|
image,
|
||||||
|
destinations: drives,
|
||||||
|
SourceType: image.SourceType,
|
||||||
|
autoBlockmapping,
|
||||||
|
decompressFirst,
|
||||||
|
};
|
||||||
|
console.log('params', paramaters);
|
||||||
|
emit('write', paramaters);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The process continue in the event handler
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Flash an image to drives
|
* @summary Flash an image to drives
|
||||||
*/
|
*/
|
||||||
export async function flash(
|
export async function flash(
|
||||||
image: string,
|
image: SourceMetadata,
|
||||||
drives: DrivelistDrive[],
|
drives: DrivelistDrive[],
|
||||||
|
// This function is a parameter so it can be mocked in tests
|
||||||
|
write = performWrite,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (flashState.isFlashing()) {
|
if (flashState.isFlashing()) {
|
||||||
throw new Error('There is already a flash in progress');
|
throw new Error('There is already a flash in progress');
|
||||||
}
|
}
|
||||||
|
|
||||||
flashState.setFlashingFlag();
|
await flashState.setFlashingFlag();
|
||||||
|
|
||||||
|
flashState.setDevicePaths(
|
||||||
|
drives.map((d) => d.devicePath).filter((p) => p != null) as string[],
|
||||||
|
);
|
||||||
|
|
||||||
const analyticsData = {
|
const analyticsData = {
|
||||||
image,
|
image,
|
||||||
@@ -264,28 +206,24 @@ export async function flash(
|
|||||||
uuid: flashState.getFlashUuid(),
|
uuid: flashState.getFlashUuid(),
|
||||||
status: 'started',
|
status: 'started',
|
||||||
flashInstanceUuid: 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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
analytics.logEvent('Flash', analyticsData);
|
analytics.logEvent('Flash', analyticsData);
|
||||||
|
|
||||||
|
// start api and call the flasher
|
||||||
try {
|
try {
|
||||||
// Using it from exports so it can be mocked during tests
|
const result = await write(image, drives, flashState.setProgressState);
|
||||||
const result = await exports.performWrite(
|
await flashState.unsetFlashingFlag(result);
|
||||||
image,
|
} catch (error: any) {
|
||||||
drives,
|
await flashState.unsetFlashingFlag({
|
||||||
flashState.setProgressState,
|
cancelled: false,
|
||||||
);
|
errorCode: error.code,
|
||||||
flashState.unsetFlashingFlag(result);
|
});
|
||||||
} catch (error) {
|
|
||||||
flashState.unsetFlashingFlag({ cancelled: false, errorCode: error.code });
|
|
||||||
windowProgress.clear();
|
windowProgress.clear();
|
||||||
let { results } = flashState.getFlashResults();
|
|
||||||
results = results || {};
|
const { results = {} } = flashState.getFlashResults();
|
||||||
|
|
||||||
const eventData = {
|
const eventData = {
|
||||||
...analyticsData,
|
...analyticsData,
|
||||||
errors: results.errors,
|
errors: results.errors,
|
||||||
@@ -296,7 +234,9 @@ export async function flash(
|
|||||||
analytics.logEvent('Write failed', eventData);
|
analytics.logEvent('Write failed', eventData);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
windowProgress.clear();
|
windowProgress.clear();
|
||||||
|
|
||||||
if (flashState.wasLastFlashCancelled()) {
|
if (flashState.wasLastFlashCancelled()) {
|
||||||
const eventData = {
|
const eventData = {
|
||||||
...analyticsData,
|
...analyticsData,
|
||||||
@@ -304,12 +244,14 @@ export async function flash(
|
|||||||
};
|
};
|
||||||
analytics.logEvent('Elevation cancelled', eventData);
|
analytics.logEvent('Elevation cancelled', eventData);
|
||||||
} else {
|
} else {
|
||||||
const { results } = flashState.getFlashResults();
|
const { results = {} } = flashState.getFlashResults();
|
||||||
const eventData = {
|
const eventData = {
|
||||||
...analyticsData,
|
...analyticsData,
|
||||||
errors: results.errors,
|
errors: results.errors,
|
||||||
devices: results.devices,
|
devices: results.devices,
|
||||||
status: 'finished',
|
status: 'finished',
|
||||||
|
bytesWritten: results.bytesWritten,
|
||||||
|
sourceMetadata: results.sourceMetadata,
|
||||||
};
|
};
|
||||||
analytics.logEvent('Done', eventData);
|
analytics.logEvent('Done', eventData);
|
||||||
}
|
}
|
||||||
@@ -317,34 +259,22 @@ export async function flash(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Cancel write operation
|
* @summary Cancel write operation
|
||||||
|
* //TODO: find a better solution to handle cancellation
|
||||||
*/
|
*/
|
||||||
export function cancel() {
|
export async function cancel(type: string) {
|
||||||
|
const status = type.toLowerCase();
|
||||||
const drives = selectionState.getSelectedDevices();
|
const drives = selectionState.getSelectedDevices();
|
||||||
const analyticsData = {
|
const analyticsData = {
|
||||||
image: selectionState.getImagePath(),
|
image: selectionState.getImage()?.path,
|
||||||
drives,
|
drives,
|
||||||
driveCount: drives.length,
|
driveCount: drives.length,
|
||||||
uuid: flashState.getFlashUuid(),
|
uuid: flashState.getFlashUuid(),
|
||||||
flashInstanceUuid: flashState.getFlashUuid(),
|
flashInstanceUuid: flashState.getFlashUuid(),
|
||||||
unmountOnSuccess: settings.get('unmountOnSuccess'),
|
status,
|
||||||
validateWriteOnSuccess: settings.get('validateWriteOnSuccess'),
|
|
||||||
trim: settings.get('trim'),
|
|
||||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
|
||||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
|
|
||||||
status: 'cancel',
|
|
||||||
};
|
};
|
||||||
analytics.logEvent('Cancel', analyticsData);
|
analytics.logEvent('Cancel', analyticsData);
|
||||||
|
|
||||||
// Re-enable lock release on inactivity
|
if (cancelEmitter) {
|
||||||
updateLock.resume();
|
cancelEmitter(status);
|
||||||
|
|
||||||
try {
|
|
||||||
// @ts-ignore (no Server.sockets in @types/node-ipc)
|
|
||||||
const [socket] = ipc.server.sockets;
|
|
||||||
if (socket !== undefined) {
|
|
||||||
ipc.server.emit(socket, 'cancel');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
analytics.logException(error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,67 +14,74 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { bytesToClosestUnit } from '../../../shared/units';
|
import * as prettyBytes from 'pretty-bytes';
|
||||||
import * as settings from '../models/settings';
|
import * as i18next from 'i18next';
|
||||||
|
|
||||||
export interface FlashState {
|
export interface FlashState {
|
||||||
flashing: number;
|
active: number;
|
||||||
verifying: number;
|
|
||||||
successful: number;
|
|
||||||
failed: number;
|
failed: number;
|
||||||
percentage?: number;
|
percentage?: number;
|
||||||
speed: number;
|
speed: number;
|
||||||
position: number;
|
position: number;
|
||||||
|
type?: 'decompressing' | 'flashing' | 'verifying';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function fromFlashState({
|
||||||
* @summary Make the progress status subtitle string
|
type,
|
||||||
*
|
percentage,
|
||||||
* @param {Object} state - flashing metadata
|
position,
|
||||||
*
|
}: Pick<FlashState, 'type' | 'percentage' | 'position'>): {
|
||||||
* @returns {String}
|
status: string;
|
||||||
*
|
position?: string;
|
||||||
* @example
|
} {
|
||||||
* const status = progressStatus.fromFlashState({
|
if (type === undefined) {
|
||||||
* flashing: 1,
|
return { status: i18next.t('progress.starting') };
|
||||||
* verifying: 0,
|
} else if (type === 'decompressing') {
|
||||||
* successful: 0,
|
if (percentage == null) {
|
||||||
* failed: 0,
|
return { status: i18next.t('progress.decompressing') };
|
||||||
* percentage: 55,
|
} else {
|
||||||
* speed: 2049
|
return {
|
||||||
* })
|
position: `${percentage}%`,
|
||||||
*
|
status: i18next.t('progress.decompressing'),
|
||||||
* console.log(status)
|
};
|
||||||
* // '55% Flashing'
|
}
|
||||||
*/
|
} else if (type === 'flashing') {
|
||||||
export function fromFlashState(state: FlashState): string {
|
if (percentage != null) {
|
||||||
const isFlashing = Boolean(state.flashing);
|
if (percentage < 100) {
|
||||||
const isValidating = !isFlashing && Boolean(state.verifying);
|
return {
|
||||||
const shouldValidate = settings.get('validateWriteOnSuccess');
|
position: `${percentage}%`,
|
||||||
const shouldUnmount = settings.get('unmountOnSuccess');
|
status: i18next.t('progress.flashing'),
|
||||||
|
};
|
||||||
if (state.percentage === 0 && !state.speed) {
|
} else {
|
||||||
if (isValidating) {
|
return { status: i18next.t('progress.finishing') };
|
||||||
return 'Validating...';
|
}
|
||||||
|
} 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') };
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Starting...';
|
export function titleFromFlashState(
|
||||||
} else if (state.percentage === 100) {
|
state: Pick<FlashState, 'type' | 'percentage' | 'position'>,
|
||||||
if ((isValidating || !shouldValidate) && shouldUnmount) {
|
): string {
|
||||||
return 'Unmounting...';
|
const { status, position } = fromFlashState(state);
|
||||||
|
if (position !== undefined) {
|
||||||
|
return `${position} ${status}`;
|
||||||
}
|
}
|
||||||
|
return status;
|
||||||
return 'Finishing...';
|
|
||||||
} else if (isFlashing) {
|
|
||||||
if (state.percentage != null) {
|
|
||||||
return `${state.percentage}% Flashing`;
|
|
||||||
}
|
|
||||||
return `${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)}`);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,188 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as _debug from 'debug';
|
|
||||||
import * as electron from 'electron';
|
|
||||||
import { EventEmitter } from 'events';
|
|
||||||
import * as createInactivityTimer from 'inactivity-timer';
|
|
||||||
|
|
||||||
import * as settings from '../models/settings';
|
|
||||||
import { logException } from './analytics';
|
|
||||||
|
|
||||||
const debug = _debug('etcher:update-lock');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
|
|
||||||
class UpdateLock extends EventEmitter {
|
|
||||||
private paused: boolean;
|
|
||||||
private lockTimer: any;
|
|
||||||
|
|
||||||
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 static onInactive() {
|
|
||||||
if (settings.get('resinUpdateLock')) {
|
|
||||||
UpdateLock.check((checkError: Error, isLocked: boolean) => {
|
|
||||||
debug('inactive-check', Boolean(checkError));
|
|
||||||
if (checkError) {
|
|
||||||
logException(checkError);
|
|
||||||
}
|
|
||||||
if (isLocked) {
|
|
||||||
UpdateLock.release((error?: Error) => {
|
|
||||||
debug('inactive-release', Boolean(error));
|
|
||||||
if (error) {
|
|
||||||
logException(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @summary Acquire the update lock
|
|
||||||
*/
|
|
||||||
private static acquire(callback: (error?: Error) => void) {
|
|
||||||
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
|
|
||||||
*/
|
|
||||||
public static release(callback: (error?: Error) => void) {
|
|
||||||
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
|
|
||||||
* @param {Function} callback - callback(error, isLocked)
|
|
||||||
* @example
|
|
||||||
* UpdateLock.check((error, isLocked) => {
|
|
||||||
* if (isLocked) {
|
|
||||||
* // ...
|
|
||||||
* }
|
|
||||||
* })
|
|
||||||
*/
|
|
||||||
private static check(
|
|
||||||
callback: (error: Error | null, isLocked?: boolean) => void,
|
|
||||||
) {
|
|
||||||
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
|
|
||||||
*/
|
|
||||||
public 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) {
|
|
||||||
logException(checkError);
|
|
||||||
}
|
|
||||||
if (!isLocked) {
|
|
||||||
UpdateLock.acquire(error => {
|
|
||||||
if (error) {
|
|
||||||
logException(error);
|
|
||||||
}
|
|
||||||
debug('extend-acquire', Boolean(error));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @summary Clear the lock timer
|
|
||||||
*/
|
|
||||||
private clearTimer() {
|
|
||||||
debug('clear');
|
|
||||||
this.lockTimer.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @summary Clear the lock timer, and pause extension, avoiding triggering until resume()d
|
|
||||||
*/
|
|
||||||
public pause() {
|
|
||||||
debug('pause');
|
|
||||||
this.paused = true;
|
|
||||||
this.clearTimer();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @summary Un-pause lock extension, and restart the timer
|
|
||||||
*/
|
|
||||||
public resume() {
|
|
||||||
debug('resume');
|
|
||||||
this.paused = false;
|
|
||||||
this.extend();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateLock = new UpdateLock();
|
|
||||||
@@ -15,10 +15,25 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as electron from 'electron';
|
import * as electron from 'electron';
|
||||||
|
import * as remote from '@electron/remote';
|
||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
|
|
||||||
import * as errors from '../../../shared/errors';
|
import * as errors from '../../../shared/errors';
|
||||||
import { getAllExtensions } from '../../../shared/supported-formats';
|
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
|
* @summary Open an image selection dialog
|
||||||
@@ -27,6 +42,7 @@ import { getAllExtensions } from '../../../shared/supported-formats';
|
|||||||
* Notice that by image, we mean *.img/*.iso/*.zip/etc files.
|
* Notice that by image, we mean *.img/*.iso/*.zip/etc files.
|
||||||
*/
|
*/
|
||||||
export async function selectImage(): Promise<string | undefined> {
|
export async function selectImage(): Promise<string | undefined> {
|
||||||
|
await mountSourceDrive();
|
||||||
const options: electron.OpenDialogOptions = {
|
const options: electron.OpenDialogOptions = {
|
||||||
// This variable is set when running in GNU/Linux from
|
// This variable is set when running in GNU/Linux from
|
||||||
// inside an AppImage, and represents the working directory
|
// inside an AppImage, and represents the working directory
|
||||||
@@ -39,16 +55,18 @@ export async function selectImage(): Promise<string | undefined> {
|
|||||||
properties: ['openFile', 'treatPackageAsDirectory'],
|
properties: ['openFile', 'treatPackageAsDirectory'],
|
||||||
filters: [
|
filters: [
|
||||||
{
|
{
|
||||||
name: 'OS Images',
|
name: i18next.t('source.osImages'),
|
||||||
extensions: [...getAllExtensions()].sort(),
|
extensions: SUPPORTED_EXTENSIONS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18next.t('source.allFiles'),
|
||||||
|
extensions: ['*'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const currentWindow = electron.remote.getCurrentWindow();
|
const currentWindow = remote.getCurrentWindow();
|
||||||
const [file] = (await electron.remote.dialog.showOpenDialog(
|
const [file] = (await remote.dialog.showOpenDialog(currentWindow, options))
|
||||||
currentWindow,
|
.filePaths;
|
||||||
options,
|
|
||||||
)).filePaths;
|
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,8 +80,8 @@ export async function showWarning(options: {
|
|||||||
description: string;
|
description: string;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
_.defaults(options, {
|
_.defaults(options, {
|
||||||
confirmationLabel: 'OK',
|
confirmationLabel: i18next.t('ok'),
|
||||||
rejectionLabel: 'Cancel',
|
rejectionLabel: i18next.t('cancel'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const BUTTONS = [options.confirmationLabel, options.rejectionLabel];
|
const BUTTONS = [options.confirmationLabel, options.rejectionLabel];
|
||||||
@@ -74,14 +92,14 @@ export async function showWarning(options: {
|
|||||||
);
|
);
|
||||||
const BUTTON_REJECTION_INDEX = _.indexOf(BUTTONS, options.rejectionLabel);
|
const BUTTON_REJECTION_INDEX = _.indexOf(BUTTONS, options.rejectionLabel);
|
||||||
|
|
||||||
const { response } = await electron.remote.dialog.showMessageBox(
|
const { response } = await remote.dialog.showMessageBox(
|
||||||
electron.remote.getCurrentWindow(),
|
remote.getCurrentWindow(),
|
||||||
{
|
{
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
buttons: BUTTONS,
|
buttons: BUTTONS,
|
||||||
defaultId: BUTTON_REJECTION_INDEX,
|
defaultId: BUTTON_REJECTION_INDEX,
|
||||||
cancelId: BUTTON_REJECTION_INDEX,
|
cancelId: BUTTON_REJECTION_INDEX,
|
||||||
title: 'Attention',
|
title: i18next.t('attention'),
|
||||||
message: options.title,
|
message: options.title,
|
||||||
detail: options.description,
|
detail: options.description,
|
||||||
},
|
},
|
||||||
@@ -95,5 +113,5 @@ export async function showWarning(options: {
|
|||||||
export function showError(error: Error) {
|
export function showError(error: Error) {
|
||||||
const title = errors.getTitle(error);
|
const title = errors.getTitle(error);
|
||||||
const message = errors.getDescription(error);
|
const message = errors.getDescription(error);
|
||||||
electron.remote.dialog.showErrorBox(title, message);
|
remote.dialog.showErrorBox(title, message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,22 +14,22 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as electron from 'electron';
|
import * as remote from '@electron/remote';
|
||||||
|
|
||||||
import * as settings from '../models/settings';
|
import * as settings from '../models/settings';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Send a notification
|
* @summary Send a notification
|
||||||
*/
|
*/
|
||||||
export function send(title: string, body: string, icon: string) {
|
export async function send(title: string, body: string, icon: string) {
|
||||||
// Bail out if desktop notifications are disabled
|
// Bail out if desktop notifications are disabled
|
||||||
if (!settings.get('desktopNotifications')) {
|
if (!(await settings.get('desktopNotifications'))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `app.dock` is only defined in OS X
|
// `app.dock` is only defined in OS X
|
||||||
if (electron.remote.app.dock) {
|
if (remote.app.dock) {
|
||||||
electron.remote.app.dock.bounce();
|
remote.app.dock.bounce();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new window.Notification(title, { body, icon });
|
return new window.Notification(title, { body, icon });
|
||||||
|
|||||||
@@ -16,22 +16,18 @@
|
|||||||
|
|
||||||
import * as electron from 'electron';
|
import * as electron from 'electron';
|
||||||
import * as settings from '../../../models/settings';
|
import * as settings from '../../../models/settings';
|
||||||
import { store } from '../../../models/store';
|
|
||||||
import { logEvent } from '../../../modules/analytics';
|
import { logEvent } from '../../../modules/analytics';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Open an external resource
|
* @summary Open an external resource
|
||||||
*/
|
*/
|
||||||
export function open(url: string) {
|
export async function open(url: string) {
|
||||||
// Don't open links if they're disabled by the env var
|
// Don't open links if they're disabled by the env var
|
||||||
if (settings.get('disableExternalLinks')) {
|
if (await settings.get('disableExternalLinks')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logEvent('Open external link', {
|
logEvent('Open external link', { url });
|
||||||
url,
|
|
||||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
electron.shell.openExternal(url);
|
electron.shell.openExternal(url);
|
||||||
|
|||||||
@@ -14,10 +14,10 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as electron from 'electron';
|
import * as remote from '@electron/remote';
|
||||||
|
|
||||||
import { percentageToFloat } from '../../../shared/utils';
|
import { percentageToFloat } from '../../../shared/utils';
|
||||||
import { FlashState, fromFlashState } from '../modules/progress-status';
|
import { FlashState, titleFromFlashState } from '../modules/progress-status';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary The title of the main window upon program launch
|
* @summary The title of the main window upon program launch
|
||||||
@@ -29,7 +29,7 @@ const INITIAL_TITLE = document.title;
|
|||||||
*/
|
*/
|
||||||
function getWindowTitle(state?: FlashState) {
|
function getWindowTitle(state?: FlashState) {
|
||||||
if (state) {
|
if (state) {
|
||||||
return `${INITIAL_TITLE} – ${fromFlashState(state)}`;
|
return `${INITIAL_TITLE} – ${titleFromFlashState(state)}`;
|
||||||
}
|
}
|
||||||
return INITIAL_TITLE;
|
return INITIAL_TITLE;
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ function getWindowTitle(state?: FlashState) {
|
|||||||
* @description
|
* @description
|
||||||
* We expose this property to `this` for testability purposes.
|
* We expose this property to `this` for testability purposes.
|
||||||
*/
|
*/
|
||||||
export const currentWindow = electron.remote.getCurrentWindow();
|
export const currentWindow = remote.getCurrentWindow();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Set operating system window progress
|
* @summary Set operating system window progress
|
||||||
@@ -50,9 +50,9 @@ export const currentWindow = electron.remote.getCurrentWindow();
|
|||||||
*/
|
*/
|
||||||
export function set(state: FlashState) {
|
export function set(state: FlashState) {
|
||||||
if (state.percentage != null) {
|
if (state.percentage != null) {
|
||||||
exports.currentWindow.setProgressBar(percentageToFloat(state.percentage));
|
currentWindow.setProgressBar(percentageToFloat(state.percentage));
|
||||||
}
|
}
|
||||||
exports.currentWindow.setTitle(getWindowTitle(state));
|
currentWindow.setTitle(getWindowTitle(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,6 +60,6 @@ export function set(state: FlashState) {
|
|||||||
*/
|
*/
|
||||||
export function clear() {
|
export function clear() {
|
||||||
// Passing 0 or null/undefined doesn't work.
|
// Passing 0 or null/undefined doesn't work.
|
||||||
exports.currentWindow.setProgressBar(-1);
|
currentWindow.setProgressBar(-1);
|
||||||
exports.currentWindow.setTitle(getWindowTitle(undefined));
|
currentWindow.setTitle(getWindowTitle(undefined));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { using } from 'bluebird';
|
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
|
import { withTmpFile } from 'etcher-sdk/build/tmp';
|
||||||
import { readFile } from 'fs';
|
import { readFile } from 'fs';
|
||||||
import { chain, trim } from 'lodash';
|
import { chain, trim } from 'lodash';
|
||||||
import { platform } from 'os';
|
import { platform } from 'os';
|
||||||
@@ -23,8 +23,6 @@ import { join } from 'path';
|
|||||||
import { env } from 'process';
|
import { env } from 'process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
|
|
||||||
import { tmpFileDisposer } from '../../../shared/utils';
|
|
||||||
|
|
||||||
const readFileAsync = promisify(readFile);
|
const readFileAsync = promisify(readFile);
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
@@ -32,8 +30,7 @@ const execAsync = promisify(exec);
|
|||||||
/**
|
/**
|
||||||
* @summary Returns wmic's output for network drives
|
* @summary Returns wmic's output for network drives
|
||||||
*/
|
*/
|
||||||
export async function getWmicNetworkDrivesOutput(): Promise<string> {
|
async function getWmicNetworkDrivesOutput(): Promise<string> {
|
||||||
// Exported for tests.
|
|
||||||
// When trying to read wmic's stdout directly from node, it is encoded with the current
|
// When trying to read wmic's stdout directly from node, it is encoded with the current
|
||||||
// console codepage (depending on the computer).
|
// console codepage (depending on the computer).
|
||||||
// Decoding this would require getting this codepage somehow and using iconv as node
|
// Decoding this would require getting this codepage somehow and using iconv as node
|
||||||
@@ -43,11 +40,11 @@ export async function getWmicNetworkDrivesOutput(): Promise<string> {
|
|||||||
// So we just redirect to a file and read it afterwards as we know it will be ucs2 encoded.
|
// So we just redirect to a file and read it afterwards as we know it will be ucs2 encoded.
|
||||||
const options = {
|
const options = {
|
||||||
// Close the file once it's created
|
// Close the file once it's created
|
||||||
discardDescriptor: true,
|
keepOpen: false,
|
||||||
// Wmic fails with "Invalid global switch" when the "/output:" switch filename contains a dash ("-")
|
// Wmic fails with "Invalid global switch" when the "/output:" switch filename contains a dash ("-")
|
||||||
prefix: 'tmp',
|
prefix: 'tmp',
|
||||||
};
|
};
|
||||||
return using(tmpFileDisposer(options), async ({ path }) => {
|
return withTmpFile(options, async ({ path }) => {
|
||||||
const command = [
|
const command = [
|
||||||
join(env.SystemRoot as string, 'System32', 'Wbem', 'wmic'),
|
join(env.SystemRoot as string, 'System32', 'Wbem', 'wmic'),
|
||||||
'path',
|
'path',
|
||||||
@@ -67,9 +64,10 @@ export async function getWmicNetworkDrivesOutput(): Promise<string> {
|
|||||||
/**
|
/**
|
||||||
* @summary returns a Map of drive letter -> network locations on Windows: 'Z:' -> '\\\\192.168.0.1\\Public'
|
* @summary returns a Map of drive letter -> network locations on Windows: 'Z:' -> '\\\\192.168.0.1\\Public'
|
||||||
*/
|
*/
|
||||||
async function getWindowsNetworkDrives(): Promise<Map<string, string>> {
|
async function getWindowsNetworkDrives(
|
||||||
// Use getWindowsNetworkDrives from "exports." so it can be mocked in tests
|
getWmicOutput: () => Promise<string>,
|
||||||
const result = await exports.getWmicNetworkDrivesOutput();
|
): Promise<Map<string, string>> {
|
||||||
|
const result = await getWmicOutput();
|
||||||
const couples: Array<[string, string]> = chain(result)
|
const couples: Array<[string, string]> = chain(result)
|
||||||
.split('\n')
|
.split('\n')
|
||||||
// Remove header line
|
// Remove header line
|
||||||
@@ -88,7 +86,7 @@ async function getWindowsNetworkDrives(): Promise<Map<string, string>> {
|
|||||||
trim(str.slice(colonPosition + 1)),
|
trim(str.slice(colonPosition + 1)),
|
||||||
];
|
];
|
||||||
})
|
})
|
||||||
.filter(couple => couple[1].length > 0)
|
.filter((couple) => couple[1].length > 0)
|
||||||
.value();
|
.value();
|
||||||
return new Map(couples);
|
return new Map(couples);
|
||||||
}
|
}
|
||||||
@@ -98,13 +96,15 @@ async function getWindowsNetworkDrives(): Promise<Map<string, string>> {
|
|||||||
*/
|
*/
|
||||||
export async function replaceWindowsNetworkDriveLetter(
|
export async function replaceWindowsNetworkDriveLetter(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
|
// getWmicOutput is a parameter so it can be replaced in tests
|
||||||
|
getWmicOutput = getWmicNetworkDrivesOutput,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
let result = filePath;
|
let result = filePath;
|
||||||
if (platform() === 'win32') {
|
if (platform() === 'win32') {
|
||||||
const matches = /^([A-Z]+:)\\(.*)$/.exec(filePath);
|
const matches = /^([A-Z]+:)\\(.*)$/.exec(filePath);
|
||||||
if (matches !== null) {
|
if (matches !== null) {
|
||||||
const [, drive, relativePath] = matches;
|
const [, drive, relativePath] = matches;
|
||||||
const drives = await getWindowsNetworkDrives();
|
const drives = await getWindowsNetworkDrives(getWmicOutput);
|
||||||
const location = drives.get(drive);
|
const location = drives.get(drive);
|
||||||
if (location !== undefined) {
|
if (location !== undefined) {
|
||||||
result = `${location}\\${relativePath}`;
|
result = `${location}\\${relativePath}`;
|
||||||
|
|||||||
@@ -1,174 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
.page-finish {
|
|
||||||
margin-top: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-xs-5.inline-flex.items-baseline > span, .col-xs-5.inline-flex.items-baseline > div {
|
|
||||||
margin-bottom: -10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-finish .button-label {
|
|
||||||
margin: 0 auto $spacing-medium;
|
|
||||||
|
|
||||||
// Keep some spacing at the sides
|
|
||||||
max-width: $btn-min-width - 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-finish .button-primary {
|
|
||||||
min-width: $btn-min-width;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-finish .title,
|
|
||||||
.page-finish .title h3 {
|
|
||||||
color: $palette-theme-dark-foreground;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-finish .huge-title {
|
|
||||||
font-size: 3.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-finish .label {
|
|
||||||
display: inline-block;
|
|
||||||
|
|
||||||
> b {
|
|
||||||
color: $palette-theme-dark-soft-foreground;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-finish .soft {
|
|
||||||
color: $palette-theme-dark-soft-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-finish .separator-xs {
|
|
||||||
flex-grow: 0;
|
|
||||||
background-color: $palette-theme-dark-soft-background;
|
|
||||||
padding: 0px;
|
|
||||||
min-width: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-finish .center {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-finish .box > div > button {
|
|
||||||
margin-right: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-finish webview {
|
|
||||||
width: 800px;
|
|
||||||
height: 300px;
|
|
||||||
position: absolute;
|
|
||||||
top: 80px;
|
|
||||||
left: 0;
|
|
||||||
z-index: 9001;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-finish .fallback-banner {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: column;
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
color: white;
|
|
||||||
height: 320px;
|
|
||||||
width: 100vw;
|
|
||||||
left: 0;
|
|
||||||
|
|
||||||
> * {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.caption {
|
|
||||||
display: flex;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.caption-big {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: bold;
|
|
||||||
position: absolute;
|
|
||||||
top: 75px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.caption-small {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fallback-footer {
|
|
||||||
font-size: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
max-height: 21px;
|
|
||||||
margin-bottom: 17px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svg-icon {
|
|
||||||
margin: 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-footer {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
|
|
||||||
.footer-right {
|
|
||||||
color: #7e8085;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-right: 30px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-flex {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.items-baseline {
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-finish .tick--success {
|
|
||||||
/* hack(Shou): for some reason the height is stretched */
|
|
||||||
height: 24px;
|
|
||||||
width: 24px;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0 15px 0 0;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-wrap {
|
|
||||||
margin-left: 5px;
|
|
||||||
|
|
||||||
> .title {
|
|
||||||
margin-bottom: 3px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,141 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as _ from 'lodash';
|
|
||||||
import * as React from 'react';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
import { DriveSelectorModal } from '../../components/drive-selector/DriveSelectorModal';
|
|
||||||
import { TargetSelector } from '../../components/drive-selector/target-selector';
|
|
||||||
import { SVGIcon } from '../../components/svg-icon/svg-icon';
|
|
||||||
import { getImage, getSelectedDrives } from '../../models/selection-state';
|
|
||||||
import * as settings from '../../models/settings';
|
|
||||||
import { observe, store } from '../../models/store';
|
|
||||||
import * as analytics from '../../modules/analytics';
|
|
||||||
|
|
||||||
const StepBorder = styled.div<{
|
|
||||||
disabled: boolean;
|
|
||||||
left?: boolean;
|
|
||||||
right?: boolean;
|
|
||||||
}>`
|
|
||||||
height: 2px;
|
|
||||||
background-color: ${props =>
|
|
||||||
props.disabled
|
|
||||||
? props.theme.customColors.dark.disabled.foreground
|
|
||||||
: props.theme.customColors.dark.foreground};
|
|
||||||
position: absolute;
|
|
||||||
width: 124px;
|
|
||||||
top: 19px;
|
|
||||||
|
|
||||||
left: ${props => (props.left ? '-67px' : undefined)};
|
|
||||||
right: ${props => (props.right ? '-67px' : undefined)};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const getDriveListLabel = () => {
|
|
||||||
return _.join(
|
|
||||||
_.map(getSelectedDrives(), (drive: any) => {
|
|
||||||
return `${drive.description} (${drive.displayName})`;
|
|
||||||
}),
|
|
||||||
'\n',
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const shouldShowDrivesButton = () => {
|
|
||||||
return !settings.get('disableExplicitDriveSelection');
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDriveSelectionStateSlice = () => ({
|
|
||||||
showDrivesButton: shouldShowDrivesButton(),
|
|
||||||
driveListLabel: getDriveListLabel(),
|
|
||||||
targets: getSelectedDrives(),
|
|
||||||
image: getImage(),
|
|
||||||
});
|
|
||||||
|
|
||||||
interface DriveSelectorProps {
|
|
||||||
webviewShowing: boolean;
|
|
||||||
disabled: boolean;
|
|
||||||
nextStepDisabled: boolean;
|
|
||||||
hasDrive: boolean;
|
|
||||||
flashing: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DriveSelector = ({
|
|
||||||
webviewShowing,
|
|
||||||
disabled,
|
|
||||||
nextStepDisabled,
|
|
||||||
hasDrive,
|
|
||||||
flashing,
|
|
||||||
}: DriveSelectorProps) => {
|
|
||||||
// TODO: inject these from redux-connector
|
|
||||||
const [
|
|
||||||
{ showDrivesButton, driveListLabel, targets, image },
|
|
||||||
setStateSlice,
|
|
||||||
] = React.useState(getDriveSelectionStateSlice());
|
|
||||||
const [showDriveSelectorModal, setShowDriveSelectorModal] = React.useState(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
return observe(() => {
|
|
||||||
setStateSlice(getDriveSelectionStateSlice());
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const showStepConnectingLines = !webviewShowing || !flashing;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="box text-center relative">
|
|
||||||
{showStepConnectingLines && (
|
|
||||||
<>
|
|
||||||
<StepBorder disabled={disabled} left />
|
|
||||||
<StepBorder disabled={nextStepDisabled} right />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="center-block">
|
|
||||||
<SVGIcon paths={['../../assets/drive.svg']} disabled={disabled} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-vertical-large">
|
|
||||||
<TargetSelector
|
|
||||||
disabled={disabled}
|
|
||||||
show={!hasDrive && showDrivesButton}
|
|
||||||
tooltip={driveListLabel}
|
|
||||||
openDriveSelector={() => {
|
|
||||||
setShowDriveSelectorModal(true);
|
|
||||||
}}
|
|
||||||
reselectDrive={() => {
|
|
||||||
analytics.logEvent('Reselect drive', {
|
|
||||||
applicationSessionUuid: store.getState().toJS()
|
|
||||||
.applicationSessionUuid,
|
|
||||||
flashingWorkflowUuid: store.getState().toJS()
|
|
||||||
.flashingWorkflowUuid,
|
|
||||||
});
|
|
||||||
setShowDriveSelectorModal(true);
|
|
||||||
}}
|
|
||||||
flashing={flashing}
|
|
||||||
targets={targets}
|
|
||||||
image={image}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showDriveSelectorModal && (
|
|
||||||
<DriveSelectorModal
|
|
||||||
close={() => setShowDriveSelectorModal(false)}
|
|
||||||
></DriveSelectorModal>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -14,45 +14,33 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import CircleSvg from '@fortawesome/fontawesome-free/svgs/solid/circle.svg';
|
||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Modal, Txt } from 'rendition';
|
import { Flex, Modal as SmallModal, Txt } from 'rendition';
|
||||||
|
|
||||||
import * as constraints from '../../../../shared/drive-constraints';
|
import * as constraints from '../../../../shared/drive-constraints';
|
||||||
import * as messages from '../../../../shared/messages';
|
import * as messages from '../../../../shared/messages';
|
||||||
import { DriveSelectorModal } from '../../components/drive-selector/DriveSelectorModal';
|
|
||||||
import { ProgressButton } from '../../components/progress-button/progress-button';
|
import { ProgressButton } from '../../components/progress-button/progress-button';
|
||||||
import { SVGIcon } from '../../components/svg-icon/svg-icon';
|
|
||||||
import * as availableDrives from '../../models/available-drives';
|
import * as availableDrives from '../../models/available-drives';
|
||||||
import * as flashState from '../../models/flash-state';
|
import * as flashState from '../../models/flash-state';
|
||||||
import * as selection from '../../models/selection-state';
|
import * as selection from '../../models/selection-state';
|
||||||
import { store } from '../../models/store';
|
|
||||||
import * as analytics from '../../modules/analytics';
|
import * as analytics from '../../modules/analytics';
|
||||||
import { scanner as driveScanner } from '../../modules/drive-scanner';
|
|
||||||
import * as imageWriter from '../../modules/image-writer';
|
import * as imageWriter from '../../modules/image-writer';
|
||||||
import * as progressStatus from '../../modules/progress-status';
|
|
||||||
import * as notification from '../../os/notification';
|
import * as notification from '../../os/notification';
|
||||||
|
import {
|
||||||
|
selectAllTargets,
|
||||||
|
TargetSelectorModal,
|
||||||
|
} from '../../components/target-selector/target-selector';
|
||||||
|
|
||||||
|
import FlashSvg from '../../../assets/flash.svg';
|
||||||
|
import DriveStatusWarningModal from '../../components/drive-status-warning-modal/drive-status-warning-modal';
|
||||||
|
import * as i18next from 'i18next';
|
||||||
|
|
||||||
const COMPLETED_PERCENTAGE = 100;
|
const COMPLETED_PERCENTAGE = 100;
|
||||||
const SPEED_PRECISION = 2;
|
const SPEED_PRECISION = 2;
|
||||||
|
|
||||||
const getWarningMessages = (drives: any, image: any) => {
|
|
||||||
const warningMessages = [];
|
|
||||||
for (const drive of drives) {
|
|
||||||
if (constraints.isDriveSizeLarge(drive)) {
|
|
||||||
warningMessages.push(messages.warning.largeDriveSize(drive));
|
|
||||||
} else if (!constraints.isDriveSizeRecommended(drive, image)) {
|
|
||||||
warningMessages.push(
|
|
||||||
messages.warning.unrecommendedDriveSize(image, drive),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO(Shou): we should consider adding the same warning dialog for system drives and remove unsafe mode
|
|
||||||
}
|
|
||||||
|
|
||||||
return warningMessages;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getErrorMessageFromCode = (errorCode: string) => {
|
const getErrorMessageFromCode = (errorCode: string) => {
|
||||||
// TODO: All these error codes to messages translations
|
// TODO: All these error codes to messages translations
|
||||||
// should go away if the writer emitted user friendly
|
// should go away if the writer emitted user friendly
|
||||||
@@ -71,87 +59,78 @@ const getErrorMessageFromCode = (errorCode: string) => {
|
|||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const flashImageToDrive = async (goToSuccess: () => void) => {
|
function notifySuccess(
|
||||||
const devices = selection.getSelectedDevices();
|
iconPath: string,
|
||||||
const image: any = selection.getImage();
|
basename: string,
|
||||||
const drives = _.filter(availableDrives.getDrives(), (drive: any) => {
|
drives: any,
|
||||||
return _.includes(devices, drive.device);
|
devices: { successful: number; failed: number },
|
||||||
});
|
) {
|
||||||
|
|
||||||
if (drives.length === 0 || flashState.isFlashing()) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop scanning drives when flashing
|
|
||||||
// otherwise Windows throws EPERM
|
|
||||||
driveScanner.stop();
|
|
||||||
|
|
||||||
const iconPath = '../../assets/icon.png';
|
|
||||||
const basename = path.basename(image.path);
|
|
||||||
try {
|
|
||||||
await imageWriter.flash(image.path, drives);
|
|
||||||
if (!flashState.wasLastFlashCancelled()) {
|
|
||||||
const flashResults: any = flashState.getFlashResults();
|
|
||||||
notification.send(
|
notification.send(
|
||||||
'Flash complete!',
|
'Flash complete!',
|
||||||
messages.info.flashComplete(
|
messages.info.flashComplete(basename, drives, devices),
|
||||||
basename,
|
|
||||||
drives as any,
|
|
||||||
flashResults.results.devices,
|
|
||||||
),
|
|
||||||
iconPath,
|
iconPath,
|
||||||
);
|
);
|
||||||
goToSuccess();
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
// When flashing is cancelled before starting above there is no error
|
function notifyFailure(iconPath: string, basename: string, drives: any) {
|
||||||
if (!error) {
|
notification.send(
|
||||||
|
'Oops! Looks like the flash failed.',
|
||||||
|
messages.error.flashFailure(basename, drives),
|
||||||
|
iconPath,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flashImageToDrive(
|
||||||
|
isFlashing: boolean,
|
||||||
|
goToSuccess: () => void,
|
||||||
|
): Promise<string> {
|
||||||
|
const devices = selection.getSelectedDevices();
|
||||||
|
const image: any = selection.getImage();
|
||||||
|
const drives = availableDrives.getDrives().filter((drive: any) => {
|
||||||
|
return devices.includes(drive.device);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (drives.length === 0 || isFlashing) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
notification.send(
|
const iconPath = path.join('media', 'icon.png');
|
||||||
'Oops! Looks like the flash failed.',
|
const basename = path.basename(image.path);
|
||||||
messages.error.flashFailure(path.basename(image.path), drives),
|
try {
|
||||||
iconPath,
|
await imageWriter.flash(image, drives);
|
||||||
);
|
if (!flashState.wasLastFlashCancelled()) {
|
||||||
|
const {
|
||||||
|
results = { devices: { successful: 0, failed: 0 } },
|
||||||
|
skip,
|
||||||
|
cancelled,
|
||||||
|
} = flashState.getFlashResults();
|
||||||
|
if (!skip && !cancelled) {
|
||||||
|
if (results?.devices?.successful > 0) {
|
||||||
|
notifySuccess(iconPath, basename, drives, results.devices);
|
||||||
|
} else {
|
||||||
|
notifyFailure(iconPath, basename, drives);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
goToSuccess();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
notifyFailure(iconPath, basename, drives);
|
||||||
let errorMessage = getErrorMessageFromCode(error.code);
|
let errorMessage = getErrorMessageFromCode(error.code);
|
||||||
if (!errorMessage) {
|
if (!errorMessage) {
|
||||||
error.image = basename;
|
error.image = basename;
|
||||||
analytics.logException(error);
|
analytics.logException(error);
|
||||||
errorMessage = messages.error.genericFlashError();
|
errorMessage = messages.error.genericFlashError(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorMessage;
|
return errorMessage;
|
||||||
} finally {
|
} finally {
|
||||||
availableDrives.setDrives([]);
|
availableDrives.setDrives([]);
|
||||||
driveScanner.start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @summary Get progress button label
|
|
||||||
* @function
|
|
||||||
* @public
|
|
||||||
*
|
|
||||||
* @returns {String} progress button label
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const label = FlashController.getProgressButtonLabel()
|
|
||||||
*/
|
|
||||||
const getProgressButtonLabel = () => {
|
|
||||||
if (!flashState.isFlashing()) {
|
|
||||||
return 'Flash!';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: no any
|
|
||||||
return progressStatus.fromFlashState(flashState.getFlashState() as any);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatSeconds = (totalSeconds: number) => {
|
const formatSeconds = (totalSeconds: number) => {
|
||||||
if (!totalSeconds && !_.isNumber(totalSeconds)) {
|
if (typeof totalSeconds !== 'number' || !Number.isFinite(totalSeconds)) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
const minutes = Math.floor(totalSeconds / 60);
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
@@ -160,154 +139,214 @@ const formatSeconds = (totalSeconds: number) => {
|
|||||||
return `${minutes}m${seconds}s`;
|
return `${minutes}m${seconds}s`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Flash = ({ shouldFlashStepBeDisabled, goToSuccess }: any) => {
|
interface FlashStepProps {
|
||||||
const state: any = flashState.getFlashState();
|
shouldFlashStepBeDisabled: boolean;
|
||||||
const isFlashing = flashState.isFlashing();
|
goToSuccess: () => void;
|
||||||
const flashErrorCode = flashState.getLastFlashErrorCode();
|
isFlashing: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
const [warningMessages, setWarningMessages] = React.useState<string[]>([]);
|
// TODO: factorize
|
||||||
const [errorMessage, setErrorMessage] = React.useState('');
|
step: 'decompressing' | 'flashing' | 'verifying';
|
||||||
const [showDriveSelectorModal, setShowDriveSelectorModal] = React.useState(
|
percentage: number;
|
||||||
false,
|
position: number;
|
||||||
);
|
failed: number;
|
||||||
|
speed?: number;
|
||||||
const handleWarningResponse = async (shouldContinue: boolean) => {
|
eta?: number;
|
||||||
setWarningMessages([]);
|
width: string;
|
||||||
|
|
||||||
if (!shouldContinue) {
|
|
||||||
setShowDriveSelectorModal(true);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrorMessage(await flashImageToDrive(goToSuccess));
|
export interface DriveWithWarnings extends constraints.DrivelistDrive {
|
||||||
};
|
statuses: constraints.DriveStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
const handleFlashErrorResponse = (shouldRetry: boolean) => {
|
interface FlashStepState {
|
||||||
setErrorMessage('');
|
warningMessage: boolean;
|
||||||
|
errorMessage: string;
|
||||||
|
showDriveSelectorModal: boolean;
|
||||||
|
systemDrives: boolean;
|
||||||
|
drivesWithWarnings: DriveWithWarnings[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FlashStep extends React.PureComponent<
|
||||||
|
FlashStepProps,
|
||||||
|
FlashStepState
|
||||||
|
> {
|
||||||
|
constructor(props: FlashStepProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
warningMessage: false,
|
||||||
|
errorMessage: '',
|
||||||
|
showDriveSelectorModal: false,
|
||||||
|
systemDrives: false,
|
||||||
|
drivesWithWarnings: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleWarningResponse(shouldContinue: boolean) {
|
||||||
|
this.setState({ warningMessage: false });
|
||||||
|
if (!shouldContinue) {
|
||||||
|
this.setState({ showDriveSelectorModal: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setState({
|
||||||
|
errorMessage: await flashImageToDrive(
|
||||||
|
this.props.isFlashing,
|
||||||
|
this.props.goToSuccess,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleFlashErrorResponse(shouldRetry: boolean) {
|
||||||
|
this.setState({ errorMessage: '' });
|
||||||
flashState.resetState();
|
flashState.resetState();
|
||||||
if (shouldRetry) {
|
if (shouldRetry) {
|
||||||
analytics.logEvent('Restart after failure', {
|
analytics.logEvent('Restart after failure');
|
||||||
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
|
|
||||||
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
selection.clear();
|
selection.clear();
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const tryFlash = async () => {
|
|
||||||
const devices = selection.getSelectedDevices();
|
|
||||||
const image = selection.getImage();
|
|
||||||
const drives = _.filter(availableDrives.getDrives(), (drive: any) => {
|
|
||||||
return _.includes(devices, drive.device);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
private hasListWarnings(drives: any[]) {
|
||||||
if (drives.length === 0 || flashState.isFlashing()) {
|
if (drives.length === 0 || flashState.isFlashing()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
return drives.filter((drive) => drive.isSystem).length > 0;
|
||||||
const hasDangerStatus = constraints.hasListDriveImageCompatibilityStatus(
|
|
||||||
drives,
|
|
||||||
image,
|
|
||||||
);
|
|
||||||
if (hasDangerStatus) {
|
|
||||||
setWarningMessages(getWarningMessages(drives, image));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrorMessage(await flashImageToDrive(goToSuccess));
|
private async tryFlash() {
|
||||||
|
const drives = selection.getSelectedDrives().map((drive) => {
|
||||||
|
return {
|
||||||
|
...drive,
|
||||||
|
statuses: constraints.getDriveImageCompatibilityStatuses(
|
||||||
|
drive,
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
if (drives.length === 0 || this.props.isFlashing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hasDangerStatus = drives.some((drive) => drive.statuses.length > 0);
|
||||||
|
if (hasDangerStatus) {
|
||||||
|
const systemDrives = drives.some((drive) =>
|
||||||
|
drive.statuses.includes(constraints.statuses.system),
|
||||||
|
);
|
||||||
|
this.setState({
|
||||||
|
systemDrives,
|
||||||
|
drivesWithWarnings: drives.filter((driveWithWarnings) => {
|
||||||
|
return (
|
||||||
|
driveWithWarnings.isSystem ||
|
||||||
|
(!systemDrives &&
|
||||||
|
driveWithWarnings.statuses.includes(constraints.statuses.large))
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
warningMessage: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setState({
|
||||||
|
errorMessage: await flashImageToDrive(
|
||||||
|
this.props.isFlashing,
|
||||||
|
this.props.goToSuccess,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="box text-center">
|
<Flex
|
||||||
<div className="center-block">
|
flexDirection="column"
|
||||||
<SVGIcon
|
alignItems="start"
|
||||||
paths={['../../assets/flash.svg']}
|
width={this.props.width}
|
||||||
disabled={shouldFlashStepBeDisabled}
|
style={this.props.style}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-vertical-large">
|
|
||||||
<ProgressButton
|
|
||||||
striped={state.type === 'verifying'}
|
|
||||||
active={isFlashing}
|
|
||||||
percentage={state.percentage}
|
|
||||||
label={getProgressButtonLabel()}
|
|
||||||
disabled={Boolean(flashErrorCode) || shouldFlashStepBeDisabled}
|
|
||||||
callback={tryFlash}
|
|
||||||
></ProgressButton>
|
|
||||||
|
|
||||||
{isFlashing && (
|
|
||||||
<button
|
|
||||||
className="button button-link button-abort-write"
|
|
||||||
onClick={imageWriter.cancel}
|
|
||||||
>
|
>
|
||||||
<span className="glyphicon glyphicon-remove-sign"></span>
|
<FlashSvg
|
||||||
</button>
|
width="40px"
|
||||||
)}
|
className={this.props.shouldFlashStepBeDisabled ? 'disabled' : ''}
|
||||||
{!_.isNil(state.speed) && state.percentage !== COMPLETED_PERCENTAGE && (
|
style={{
|
||||||
<p className="step-footer step-footer-split">
|
margin: '0 auto',
|
||||||
{Boolean(state.speed) && (
|
|
||||||
<span>{`${state.speed.toFixed(SPEED_PRECISION)} MB/s`}</span>
|
|
||||||
)}
|
|
||||||
{!_.isNil(state.eta) && (
|
|
||||||
<span>{`ETA: ${formatSeconds(state.eta)}`}</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{Boolean(state.failed) && (
|
|
||||||
<div className="target-status-wrap">
|
|
||||||
<div className="target-status-line target-status-failed">
|
|
||||||
<span className="target-status-dot"></span>
|
|
||||||
<span className="target-status-quantity">{state.failed}</span>
|
|
||||||
<span className="target-status-message">
|
|
||||||
{messages.progress.failed(state.failed)}{' '}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{warningMessages && warningMessages.length > 0 && (
|
|
||||||
<Modal
|
|
||||||
width={400}
|
|
||||||
titleElement={'Attention'}
|
|
||||||
cancel={() => handleWarningResponse(false)}
|
|
||||||
done={() => handleWarningResponse(true)}
|
|
||||||
cancelButtonProps={{
|
|
||||||
children: 'Change',
|
|
||||||
}}
|
}}
|
||||||
action={'Continue'}
|
/>
|
||||||
primaryButtonProps={{ primary: false, warning: true }}
|
|
||||||
|
<ProgressButton
|
||||||
|
type={this.props.step}
|
||||||
|
active={this.props.isFlashing}
|
||||||
|
percentage={this.props.percentage}
|
||||||
|
position={this.props.position}
|
||||||
|
disabled={this.props.shouldFlashStepBeDisabled}
|
||||||
|
cancel={imageWriter.cancel}
|
||||||
|
warning={this.hasListWarnings(selection.getSelectedDrives())}
|
||||||
|
callback={() => this.tryFlash()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!_.isNil(this.props.speed) &&
|
||||||
|
this.props.percentage !== COMPLETED_PERCENTAGE && (
|
||||||
|
<Flex
|
||||||
|
justifyContent="space-between"
|
||||||
|
fontSize="14px"
|
||||||
|
color="#7e8085"
|
||||||
|
width="100%"
|
||||||
>
|
>
|
||||||
{_.map(warningMessages, (message, key) => (
|
<Txt>
|
||||||
<Txt key={key} whitespace="pre-line" mt={2}>
|
{i18next.t('flash.speedShort', {
|
||||||
{message}
|
speed: this.props.speed.toFixed(SPEED_PRECISION),
|
||||||
|
})}
|
||||||
</Txt>
|
</Txt>
|
||||||
))}
|
{!_.isNil(this.props.eta) && (
|
||||||
</Modal>
|
<Txt>
|
||||||
|
{i18next.t('flash.eta', {
|
||||||
|
eta: formatSeconds(this.props.eta),
|
||||||
|
})}
|
||||||
|
</Txt>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{errorMessage && (
|
{Boolean(this.props.failed) && (
|
||||||
<Modal
|
<Flex color="#fff" alignItems="center" mt={35}>
|
||||||
|
<CircleSvg height="1em" fill="#ff4444" />
|
||||||
|
<Txt ml={10}>{this.props.failed}</Txt>
|
||||||
|
<Txt ml={10}>{messages.progress.failed(this.props.failed)}</Txt>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{this.state.warningMessage && (
|
||||||
|
<DriveStatusWarningModal
|
||||||
|
done={() => this.handleWarningResponse(true)}
|
||||||
|
cancel={() => this.handleWarningResponse(false)}
|
||||||
|
isSystem={this.state.systemDrives}
|
||||||
|
drivesWithWarnings={this.state.drivesWithWarnings}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{this.state.errorMessage && (
|
||||||
|
<SmallModal
|
||||||
width={400}
|
width={400}
|
||||||
titleElement={'Attention'}
|
titleElement={'Attention'}
|
||||||
cancel={() => handleFlashErrorResponse(false)}
|
cancel={() => this.handleFlashErrorResponse(false)}
|
||||||
done={() => handleFlashErrorResponse(true)}
|
done={() => this.handleFlashErrorResponse(true)}
|
||||||
action={'Retry'}
|
action={'Retry'}
|
||||||
>
|
>
|
||||||
<Txt>{errorMessage}</Txt>
|
<Txt>
|
||||||
</Modal>
|
{this.state.errorMessage.split('\n').map((message, key) => (
|
||||||
|
<p key={key}>{message}</p>
|
||||||
|
))}
|
||||||
|
</Txt>
|
||||||
|
</SmallModal>
|
||||||
)}
|
)}
|
||||||
|
{this.state.showDriveSelectorModal && (
|
||||||
{showDriveSelectorModal && (
|
<TargetSelectorModal
|
||||||
<DriveSelectorModal
|
write={true}
|
||||||
close={() => setShowDriveSelectorModal(false)}
|
cancel={() => this.setState({ showDriveSelectorModal: false })}
|
||||||
></DriveSelectorModal>
|
done={(modalTargets) => {
|
||||||
|
selectAllTargets(modalTargets);
|
||||||
|
this.setState({ showDriveSelectorModal: false });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,39 +14,47 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { faCog, faQuestionCircle } from '@fortawesome/free-solid-svg-icons';
|
import CogSvg from '@fortawesome/fontawesome-free/svgs/solid/cog.svg';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import QuestionCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/question-circle.svg';
|
||||||
import * as _ from 'lodash';
|
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as React from 'react';
|
import * as prettyBytes from 'pretty-bytes';
|
||||||
import { Button } from 'rendition';
|
import * as React from 'react';
|
||||||
|
import { Flex } from 'rendition';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
import { FeaturedProject } from '../../components/featured-project/featured-project';
|
|
||||||
import FinishPage from '../../components/finish/finish';
|
import FinishPage from '../../components/finish/finish';
|
||||||
import { ImageSelector } from '../../components/image-selector/image-selector';
|
|
||||||
import { ReducedFlashingInfos } from '../../components/reduced-flashing-infos/reduced-flashing-infos';
|
import { ReducedFlashingInfos } from '../../components/reduced-flashing-infos/reduced-flashing-infos';
|
||||||
import { SafeWebview } from '../../components/safe-webview/safe-webview';
|
|
||||||
import { SettingsModal } from '../../components/settings/settings';
|
import { SettingsModal } from '../../components/settings/settings';
|
||||||
import { SVGIcon } from '../../components/svg-icon/svg-icon';
|
import { SourceSelector } from '../../components/source-selector/source-selector';
|
||||||
|
import { SourceMetadata } from '../../../../shared/typings/source-selector';
|
||||||
import * as flashState from '../../models/flash-state';
|
import * as flashState from '../../models/flash-state';
|
||||||
import * as selectionState from '../../models/selection-state';
|
import * as selectionState from '../../models/selection-state';
|
||||||
import * as settings from '../../models/settings';
|
import * as settings from '../../models/settings';
|
||||||
import { observe } from '../../models/store';
|
import { observe } from '../../models/store';
|
||||||
import { open as openExternal } from '../../os/open-external/services/open-external';
|
import { open as openExternal } from '../../os/open-external/services/open-external';
|
||||||
import { ThemedProvider } from '../../styled-components';
|
import {
|
||||||
import { colors } from '../../theme';
|
IconButton as BaseIcon,
|
||||||
import { middleEllipsis } from '../../utils/middle-ellipsis';
|
ThemedProvider,
|
||||||
|
} from '../../styled-components';
|
||||||
|
|
||||||
import { bytesToClosestUnit } from '../../../../shared/units';
|
import {
|
||||||
|
TargetSelector,
|
||||||
|
getDriveListLabel,
|
||||||
|
} from '../../components/target-selector/target-selector';
|
||||||
|
import { FlashStep } from './Flash';
|
||||||
|
|
||||||
import { DriveSelector } from './DriveSelector';
|
import EtcherSvg from '../../../assets/etcher.svg';
|
||||||
import { Flash } from './Flash';
|
import { SafeWebview } from '../../components/safe-webview/safe-webview';
|
||||||
|
|
||||||
|
const Icon = styled(BaseIcon)`
|
||||||
|
margin-right: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
function getDrivesTitle() {
|
function getDrivesTitle() {
|
||||||
const drives = selectionState.getSelectedDrives();
|
const drives = selectionState.getSelectedDrives();
|
||||||
|
|
||||||
if (drives.length === 1) {
|
if (drives.length === 1) {
|
||||||
// @ts-ignore
|
|
||||||
return drives[0].description || 'Untitled Device';
|
return drives[0].description || 'Untitled Device';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,30 +65,54 @@ function getDrivesTitle() {
|
|||||||
return `${drives.length} Targets`;
|
return `${drives.length} Targets`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getImageBasename() {
|
function getImageBasename(image?: SourceMetadata) {
|
||||||
if (!selectionState.hasImage()) {
|
if (image === undefined) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectionImageName = selectionState.getImageName();
|
if (image.drive) {
|
||||||
const imageBasename = path.basename(selectionState.getImagePath());
|
return image.drive.description;
|
||||||
return selectionImageName || imageBasename;
|
|
||||||
}
|
}
|
||||||
|
const imageBasename = path.basename(image.path);
|
||||||
|
return image.name || imageBasename;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepBorder = styled.div<{
|
||||||
|
disabled: boolean;
|
||||||
|
left?: boolean;
|
||||||
|
right?: boolean;
|
||||||
|
}>`
|
||||||
|
position: relative;
|
||||||
|
height: 2px;
|
||||||
|
background-color: ${(props) =>
|
||||||
|
props.disabled
|
||||||
|
? props.theme.colors.dark.disabled.foreground
|
||||||
|
: props.theme.colors.dark.foreground};
|
||||||
|
width: 120px;
|
||||||
|
top: 19px;
|
||||||
|
|
||||||
|
left: ${(props) => (props.left ? '-67px' : undefined)};
|
||||||
|
margin-right: ${(props) => (props.left ? '-120px' : undefined)};
|
||||||
|
right: ${(props) => (props.right ? '-67px' : undefined)};
|
||||||
|
margin-left: ${(props) => (props.right ? '-120px' : undefined)};
|
||||||
|
`;
|
||||||
|
|
||||||
interface MainPageStateFromStore {
|
interface MainPageStateFromStore {
|
||||||
isFlashing: boolean;
|
isFlashing: boolean;
|
||||||
hasImage: boolean;
|
hasImage: boolean;
|
||||||
hasDrive: boolean;
|
hasDrive: boolean;
|
||||||
imageLogo: string;
|
imageLogo?: string;
|
||||||
imageSize: number;
|
imageSize?: number;
|
||||||
imageName: string;
|
imageName?: string;
|
||||||
driveTitle: string;
|
driveTitle: string;
|
||||||
|
driveLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MainPageState {
|
interface MainPageState {
|
||||||
current: 'main' | 'success';
|
current: 'main' | 'success';
|
||||||
isWebviewShowing: boolean;
|
isWebviewShowing: boolean;
|
||||||
hideSettings: boolean;
|
hideSettings: boolean;
|
||||||
|
featuredProjectURL?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MainPage extends React.Component<
|
export class MainPage extends React.Component<
|
||||||
@@ -98,40 +130,160 @@ export class MainPage extends React.Component<
|
|||||||
}
|
}
|
||||||
|
|
||||||
private stateHelper(): MainPageStateFromStore {
|
private stateHelper(): MainPageStateFromStore {
|
||||||
|
const image = selectionState.getImage();
|
||||||
return {
|
return {
|
||||||
isFlashing: flashState.isFlashing(),
|
isFlashing: flashState.isFlashing(),
|
||||||
hasImage: selectionState.hasImage(),
|
hasImage: selectionState.hasImage(),
|
||||||
hasDrive: selectionState.hasDrive(),
|
hasDrive: selectionState.hasDrive(),
|
||||||
imageLogo: selectionState.getImageLogo(),
|
imageLogo: image?.logo,
|
||||||
imageSize: selectionState.getImageSize(),
|
imageSize: image?.size,
|
||||||
imageName: getImageBasename(),
|
imageName: getImageBasename(selectionState.getImage()),
|
||||||
driveTitle: getDrivesTitle(),
|
driveTitle: getDrivesTitle(),
|
||||||
|
driveLabel: getDriveListLabel(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentDidMount() {
|
private async getFeaturedProjectURL() {
|
||||||
|
const url = new URL(
|
||||||
|
(await settings.get('featuredProjectEndpoint')) ||
|
||||||
|
'https://efp.balena.io/index.html',
|
||||||
|
);
|
||||||
|
url.searchParams.append('borderRight', 'false');
|
||||||
|
url.searchParams.append('darkBackground', 'true');
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async componentDidMount() {
|
||||||
observe(() => {
|
observe(() => {
|
||||||
this.setState(this.stateHelper());
|
this.setState(this.stateHelper());
|
||||||
});
|
});
|
||||||
|
this.setState({ featuredProjectURL: await this.getFeaturedProjectURL() });
|
||||||
}
|
}
|
||||||
|
|
||||||
public render() {
|
private renderMain() {
|
||||||
|
const state = flashState.getFlashState();
|
||||||
const shouldDriveStepBeDisabled = !this.state.hasImage;
|
const shouldDriveStepBeDisabled = !this.state.hasImage;
|
||||||
const shouldFlashStepBeDisabled =
|
const shouldFlashStepBeDisabled =
|
||||||
!this.state.hasImage || !this.state.hasDrive;
|
!this.state.hasImage || !this.state.hasDrive;
|
||||||
|
const notFlashingOrSplitView =
|
||||||
if (this.state.current === 'main') {
|
!this.state.isFlashing || !this.state.isWebviewShowing;
|
||||||
return (
|
return (
|
||||||
<ThemedProvider style={{ height: '100%', width: '100%' }}>
|
<Flex
|
||||||
<header
|
m={`110px ${this.state.isWebviewShowing ? 35 : 55}px`}
|
||||||
id="app-header"
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
{notFlashingOrSplitView && (
|
||||||
|
<>
|
||||||
|
<SourceSelector flashing={this.state.isFlashing} />
|
||||||
|
<Flex>
|
||||||
|
<StepBorder disabled={shouldDriveStepBeDisabled} left />
|
||||||
|
</Flex>
|
||||||
|
<TargetSelector
|
||||||
|
disabled={shouldDriveStepBeDisabled}
|
||||||
|
hasDrive={this.state.hasDrive}
|
||||||
|
flashing={this.state.isFlashing}
|
||||||
|
/>
|
||||||
|
<Flex>
|
||||||
|
<StepBorder disabled={shouldFlashStepBeDisabled} right />
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{this.state.isFlashing && this.state.isWebviewShowing && (
|
||||||
|
<Flex
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
position: 'absolute',
|
||||||
padding: '13px 14px',
|
top: 0,
|
||||||
textAlign: 'center',
|
left: 0,
|
||||||
|
width: '36.2vw',
|
||||||
|
height: '100vh',
|
||||||
|
zIndex: 1,
|
||||||
|
boxShadow: '0 2px 15px 0 rgba(0, 0, 0, 0.2)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<ReducedFlashingInfos
|
||||||
|
imageLogo={this.state.imageLogo}
|
||||||
|
imageName={this.state.imageName}
|
||||||
|
imageSize={
|
||||||
|
typeof this.state.imageSize === 'number'
|
||||||
|
? prettyBytes(this.state.imageSize)
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
driveTitle={this.state.driveTitle}
|
||||||
|
driveLabel={this.state.driveLabel}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
color: '#fff',
|
||||||
|
left: 35,
|
||||||
|
top: 72,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
{this.state.isFlashing && this.state.featuredProjectURL && (
|
||||||
|
<SafeWebview
|
||||||
|
src={this.state.featuredProjectURL}
|
||||||
|
onWebviewShow={(isWebviewShowing: boolean) => {
|
||||||
|
this.setState({ isWebviewShowing });
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: '63.8vw',
|
||||||
|
height: '100vh',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FlashStep
|
||||||
|
width={this.state.isWebviewShowing ? '220px' : '200px'}
|
||||||
|
goToSuccess={() => this.setState({ current: 'success' })}
|
||||||
|
shouldFlashStepBeDisabled={shouldFlashStepBeDisabled}
|
||||||
|
isFlashing={this.state.isFlashing}
|
||||||
|
step={state.type}
|
||||||
|
percentage={state.percentage}
|
||||||
|
position={state.position}
|
||||||
|
failed={state.failed}
|
||||||
|
speed={state.speed}
|
||||||
|
eta={state.eta}
|
||||||
|
style={{ zIndex: 1 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSuccess() {
|
||||||
|
return (
|
||||||
|
<FinishPage
|
||||||
|
goToMain={() => {
|
||||||
|
flashState.resetState();
|
||||||
|
this.setState({ current: 'main' });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
return (
|
||||||
|
<ThemedProvider style={{ height: '100%', width: '100%' }}>
|
||||||
|
<Flex
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
paddingTop="14px"
|
||||||
|
style={{
|
||||||
|
// Allow window to be dragged from header
|
||||||
|
// @ts-ignore
|
||||||
|
WebkitAppRegion: 'drag',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex width="100%" />
|
||||||
|
<Flex width="100%" alignItems="center" justifyContent="center">
|
||||||
|
<EtcherSvg
|
||||||
|
width="123px"
|
||||||
|
height="22px"
|
||||||
style={{
|
style={{
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
@@ -139,48 +291,38 @@ export class MainPage extends React.Component<
|
|||||||
openExternal('https://www.balena.io/etcher?ref=etcher_footer')
|
openExternal('https://www.balena.io/etcher?ref=etcher_footer')
|
||||||
}
|
}
|
||||||
tabIndex={100}
|
tabIndex={100}
|
||||||
>
|
|
||||||
<SVGIcon
|
|
||||||
paths={['../../assets/etcher.svg']}
|
|
||||||
width="123px"
|
|
||||||
height="22px"
|
|
||||||
/>
|
/>
|
||||||
</span>
|
</Flex>
|
||||||
|
|
||||||
<span
|
<Flex width="100%" alignItems="center" justifyContent="flex-end">
|
||||||
style={{
|
<Icon
|
||||||
float: 'right',
|
icon={<CogSvg height="1em" fill="currentColor" />}
|
||||||
position: 'absolute',
|
|
||||||
right: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
icon={<FontAwesomeIcon icon={faCog} />}
|
|
||||||
color={colors.secondary.background}
|
|
||||||
fontSize={24}
|
|
||||||
style={{ width: '30px' }}
|
|
||||||
plain
|
plain
|
||||||
onClick={() => this.setState({ hideSettings: false })}
|
|
||||||
tabIndex={5}
|
tabIndex={5}
|
||||||
|
onClick={() => this.setState({ hideSettings: false })}
|
||||||
|
style={{
|
||||||
|
// Make touch events click instead of dragging
|
||||||
|
WebkitAppRegion: 'no-drag',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{!settings.get('disableExternalLinks') && (
|
{!settings.getSync('disableExternalLinks') && (
|
||||||
<Button
|
<Icon
|
||||||
icon={<FontAwesomeIcon icon={faQuestionCircle} />}
|
icon={<QuestionCircleSvg height="1em" fill="currentColor" />}
|
||||||
color={colors.secondary.background}
|
|
||||||
fontSize={24}
|
|
||||||
style={{ width: '30px' }}
|
|
||||||
plain
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
openExternal(
|
openExternal(
|
||||||
selectionState.getImageSupportUrl() ||
|
selectionState.getImage()?.supportUrl ||
|
||||||
'https://github.com/balena-io/etcher/blob/master/SUPPORT.md',
|
'https://github.com/balena-io/etcher/blob/master/docs/SUPPORT.md',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
tabIndex={5}
|
tabIndex={6}
|
||||||
|
style={{
|
||||||
|
// Make touch events click instead of dragging
|
||||||
|
WebkitAppRegion: 'no-drag',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</span>
|
</Flex>
|
||||||
</header>
|
</Flex>
|
||||||
{this.state.hideSettings ? null : (
|
{this.state.hideSettings ? null : (
|
||||||
<SettingsModal
|
<SettingsModal
|
||||||
toggleModal={(value: boolean) => {
|
toggleModal={(value: boolean) => {
|
||||||
@@ -188,74 +330,11 @@ export class MainPage extends React.Component<
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{this.state.current === 'main'
|
||||||
<div
|
? this.renderMain()
|
||||||
className="page-main row around-xs"
|
: this.renderSuccess()}
|
||||||
style={{ margin: '110px 50px' }}
|
|
||||||
>
|
|
||||||
<div className="col-xs">
|
|
||||||
<ImageSelector flashing={this.state.isFlashing} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-xs">
|
|
||||||
<DriveSelector
|
|
||||||
webviewShowing={this.state.isWebviewShowing}
|
|
||||||
disabled={shouldDriveStepBeDisabled}
|
|
||||||
nextStepDisabled={shouldFlashStepBeDisabled}
|
|
||||||
hasDrive={this.state.hasDrive}
|
|
||||||
flashing={this.state.isFlashing}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{this.state.isFlashing && (
|
|
||||||
<div
|
|
||||||
className={`featured-project ${
|
|
||||||
this.state.isFlashing && this.state.isWebviewShowing
|
|
||||||
? 'fp-visible'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<FeaturedProject
|
|
||||||
onWebviewShow={(isWebviewShowing: boolean) => {
|
|
||||||
this.setState({ isWebviewShowing });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<ReducedFlashingInfos
|
|
||||||
imageLogo={this.state.imageLogo}
|
|
||||||
imageName={middleEllipsis(this.state.imageName, 16)}
|
|
||||||
imageSize={
|
|
||||||
_.isNumber(this.state.imageSize)
|
|
||||||
? (bytesToClosestUnit(this.state.imageSize) as string)
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
driveTitle={middleEllipsis(this.state.driveTitle, 16)}
|
|
||||||
shouldShow={
|
|
||||||
this.state.isFlashing && this.state.isWebviewShowing
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-xs">
|
|
||||||
<Flash
|
|
||||||
goToSuccess={() => this.setState({ current: 'success' })}
|
|
||||||
shouldFlashStepBeDisabled={shouldFlashStepBeDisabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ThemedProvider>
|
</ThemedProvider>
|
||||||
);
|
);
|
||||||
} else if (this.state.current === 'success') {
|
|
||||||
return (
|
|
||||||
<div className="section-loader isFinish">
|
|
||||||
<FinishPage goToMain={() => this.setState({ current: 'main' })} />
|
|
||||||
<SafeWebview src="https://www.balena.io/etcher/success-banner/" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,201 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
img[disabled] {
|
|
||||||
opacity: $disabled-opacity;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-main {
|
|
||||||
flex: 1;
|
|
||||||
align-self: center;
|
|
||||||
margin: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-main > .col-xs {
|
|
||||||
height: 165px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-main .step-selection-text {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
color: $palette-theme-dark-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-main .text-disabled > span {
|
|
||||||
color: $palette-theme-dark-disabled-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-main .step-drive.text-warning {
|
|
||||||
color: $palette-theme-warning-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-main .relative {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-main .button-abort-write {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
position: absolute;
|
|
||||||
right: -17px;
|
|
||||||
top: 30%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-brick {
|
|
||||||
width: 200px;
|
|
||||||
height: 48px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-main .step-tooltip {
|
|
||||||
display: block;
|
|
||||||
margin: -5px auto -20px;
|
|
||||||
color: $palette-theme-dark-disabled-foreground;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-main .step-footer {
|
|
||||||
width: 100%;
|
|
||||||
color: $palette-theme-dark-disabled-foreground;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-main p.step-footer {
|
|
||||||
margin-top: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-main .step-footer-split {
|
|
||||||
position: absolute;
|
|
||||||
top: 39px;
|
|
||||||
left: 28px;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: $btn-min-width;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-main .button.step-footer {
|
|
||||||
font-size: 16px;
|
|
||||||
color: $palette-theme-primary-background;
|
|
||||||
border-radius: 0;
|
|
||||||
padding: 0;
|
|
||||||
width: 100%;
|
|
||||||
font-weight: 300;
|
|
||||||
height: 21px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-main .step-drive.glyphicon {
|
|
||||||
margin-top: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-main div.step-fill,
|
|
||||||
.page-main span.step-fill {
|
|
||||||
margin-top: 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-main .step-drive.step-list {
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background-color: $palette-theme-dark-disabled-foreground;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-main .glyphicon {
|
|
||||||
vertical-align: text-top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-main .step-name {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
height: 39px;
|
|
||||||
width: 100%;
|
|
||||||
font-weight: bold;
|
|
||||||
color: $palette-theme-primary-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-main .step-size {
|
|
||||||
color: $palette-theme-dark-disabled-foreground;
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.5;
|
|
||||||
height: 21px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-main .step-list {
|
|
||||||
height: 80px;
|
|
||||||
margin: 15px;
|
|
||||||
overflow-y: auto;
|
|
||||||
color: $palette-theme-dark-disabled-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
.target-status-wrap {
|
|
||||||
display: flex;
|
|
||||||
position: absolute;
|
|
||||||
top: 62px;
|
|
||||||
flex-direction: column;
|
|
||||||
margin: 8px 28px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.target-status-line {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
margin-bottom: 9px;
|
|
||||||
|
|
||||||
> .target-status-dot {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.target-status-successful > .target-status-dot {
|
|
||||||
background-color: $palette-theme-success-background;
|
|
||||||
}
|
|
||||||
&.target-status-failed > .target-status-dot {
|
|
||||||
background-color: $palette-theme-danger-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .target-status-quantity {
|
|
||||||
color: white;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .target-status-message {
|
|
||||||
color: gray;
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-inner {
|
|
||||||
white-space: pre-line;
|
|
||||||
}
|
|
||||||
|
|
||||||
.space-vertical-large {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
15
lib/gui/app/renderer.ts
Normal file
15
lib/gui/app/renderer.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { main } from './app';
|
||||||
|
import './i18n';
|
||||||
|
import { langParser } from './i18n';
|
||||||
|
import { ipcRenderer } from 'electron';
|
||||||
|
|
||||||
|
ipcRenderer.send('change-lng', langParser());
|
||||||
|
|
||||||
|
if (module.hot) {
|
||||||
|
module.hot.accept('./app', () => {
|
||||||
|
main();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -1,24 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
border: 2px solid;
|
|
||||||
border-radius: 50%;
|
|
||||||
padding: 7px 10px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
@@ -1,99 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
.button {
|
|
||||||
@extend .btn;
|
|
||||||
|
|
||||||
padding: 10px;
|
|
||||||
padding-top: 11px;
|
|
||||||
|
|
||||||
border-radius: 24px;
|
|
||||||
border: 0;
|
|
||||||
|
|
||||||
letter-spacing: .5px;
|
|
||||||
outline: none;
|
|
||||||
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
> .glyphicon {
|
|
||||||
top: 0;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.button-primary{
|
|
||||||
width: 200px;
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[disabled] {
|
|
||||||
@extend .button-no-hover;
|
|
||||||
background-color: $palette-theme-dark-disabled-background;
|
|
||||||
color: $palette-theme-dark-disabled-foreground;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-link {
|
|
||||||
@extend .btn-link;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-block {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-no-hover {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create map from Bootstrap `.btn` type styles
|
|
||||||
// since its not possible to perform variable
|
|
||||||
// interpolation (e.g: `$btn-${type}-bg`).
|
|
||||||
// See https://github.com/sass/sass/issues/132
|
|
||||||
$button-types-styles: (
|
|
||||||
default: (
|
|
||||||
bg: $palette-theme-default-background,
|
|
||||||
color: $palette-theme-default-foreground
|
|
||||||
),
|
|
||||||
primary: (
|
|
||||||
bg: $palette-theme-primary-background,
|
|
||||||
color: $palette-theme-primary-foreground
|
|
||||||
),
|
|
||||||
danger: (
|
|
||||||
bg: $palette-theme-danger-background,
|
|
||||||
color: $palette-theme-danger-foreground
|
|
||||||
),
|
|
||||||
warning: (
|
|
||||||
bg: $palette-theme-warning-background,
|
|
||||||
color: $palette-theme-danger-foreground
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
@each $style in map-keys($button-types-styles) {
|
|
||||||
$button-styles: map-get($button-types-styles, $style);
|
|
||||||
|
|
||||||
.button-#{$style} {
|
|
||||||
background-color: map-get($button-styles, "bg");
|
|
||||||
color: map-get($button-styles, "color");
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-#{$style}:focus,
|
|
||||||
.button-#{$style}:hover {
|
|
||||||
background-color: darken(map-get($button-styles, "bg"), 10%);
|
|
||||||
color: map-get($button-styles, "color");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
.caption {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 11px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
@@ -1,35 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 9px;
|
|
||||||
margin-right: 4.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-big {
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 8px 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
@@ -1,47 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
.tick {
|
|
||||||
@extend .glyphicon;
|
|
||||||
|
|
||||||
display: inline-block;
|
|
||||||
border-radius: 50%;
|
|
||||||
padding: 3px;
|
|
||||||
font-size: 18px;
|
|
||||||
border: 2px solid;
|
|
||||||
|
|
||||||
&[disabled] {
|
|
||||||
color: $palette-theme-dark-soft-foreground;
|
|
||||||
border-color: $palette-theme-dark-soft-foreground;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tick--success {
|
|
||||||
@extend .glyphicon-ok;
|
|
||||||
|
|
||||||
color: $palette-theme-success-foreground;
|
|
||||||
background-color: $palette-theme-success-background;
|
|
||||||
border-color: $palette-theme-success-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tick--error {
|
|
||||||
@extend .glyphicon-remove;
|
|
||||||
|
|
||||||
color: $palette-theme-danger-foreground;
|
|
||||||
background-color: $palette-theme-danger-background;
|
|
||||||
border-color: $palette-theme-danger-background;
|
|
||||||
}
|
|
||||||
@@ -1,182 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
$icon-font-path: "../../../node_modules/bootstrap-sass/assets/fonts/bootstrap/";
|
|
||||||
$font-size-base: 16px;
|
|
||||||
$cursor-disabled: initial;
|
|
||||||
$link-hover-decoration: none;
|
|
||||||
$btn-min-width: 170px;
|
|
||||||
$link-color: #ddd;
|
|
||||||
$disabled-opacity: 0.2;
|
|
||||||
|
|
||||||
@import "../../../../node_modules/bootstrap-sass/assets/stylesheets/bootstrap";
|
|
||||||
@import "./modules/theme";
|
|
||||||
@import "./modules/bootstrap";
|
|
||||||
@import "./modules/space";
|
|
||||||
@import "./components/label";
|
|
||||||
@import "./components/badge";
|
|
||||||
@import "./components/caption";
|
|
||||||
@import "./components/button";
|
|
||||||
@import "./components/tick";
|
|
||||||
@import "../components/drive-selector/styles/drive-selector";
|
|
||||||
@import "../pages/main/styles/main";
|
|
||||||
@import "../pages/finish/styles/finish";
|
|
||||||
|
|
||||||
$fa-font-path: "../../../node_modules/@fortawesome/fontawesome-free-webfonts/webfonts";
|
|
||||||
|
|
||||||
@import "../../../../node_modules/@fortawesome/fontawesome-free-webfonts/scss/fontawesome";
|
|
||||||
@import "../../../../node_modules/@fortawesome/fontawesome-free-webfonts/scss/fa-solid";
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Nunito';
|
|
||||||
src: url('Nunito-Regular.eot');
|
|
||||||
src: url('./fonts/Nunito-Regular.eot?#iefix') format('embedded-opentype'),
|
|
||||||
url('./fonts/Nunito-Regular.woff2') format('woff2'),
|
|
||||||
url('./fonts/Nunito-Regular.woff') format('woff'),
|
|
||||||
url('./fonts/Nunito-Regular.ttf') format('truetype');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Nunito';
|
|
||||||
src: url('Nunito-Bold.eot');
|
|
||||||
src: url('./fonts/Nunito-Bold.eot?#iefix') format('embedded-opentype'),
|
|
||||||
url('./fonts/Nunito-Bold.woff2') format('woff2'),
|
|
||||||
url('./fonts/Nunito-Bold.woff') format('woff'),
|
|
||||||
url('./fonts/Nunito-Bold.ttf') format('truetype');
|
|
||||||
font-weight: bold;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Nunito';
|
|
||||||
src: url('Nunito-Light.eot');
|
|
||||||
src: url('./fonts/Nunito-Light.eot?#iefix') format('embedded-opentype'),
|
|
||||||
url('./fonts/Nunito-Light.woff2') format('woff2'),
|
|
||||||
url('./fonts/Nunito-Light.woff') format('woff'),
|
|
||||||
url('./fonts/Nunito-Light.ttf') format('truetype');
|
|
||||||
font-weight: 300;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'CircularStd';
|
|
||||||
src: url('./fonts/CircularStd-Bold.eot');
|
|
||||||
src: url('./fonts/CircularStd-Bold.eot?#iefix') format('embedded-opentype'),
|
|
||||||
url('./fonts/CircularStd-Bold.woff2') format('woff2'),
|
|
||||||
url('./fonts/CircularStd-Bold.woff') format('woff'),
|
|
||||||
url('./fonts/CircularStd-Bold.ttf') format('truetype');
|
|
||||||
font-weight: bold;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'CircularStd';
|
|
||||||
src: url('./fonts/CircularStd-Book.eot');
|
|
||||||
src: url('./fonts/CircularStd-Book.eot?#iefix') format('embedded-opentype'),
|
|
||||||
url('./fonts/CircularStd-Book.woff2') format('woff2'),
|
|
||||||
url('./fonts/CircularStd-Book.woff') format('woff'),
|
|
||||||
url('./fonts/CircularStd-Book.ttf') format('truetype');
|
|
||||||
font-weight: 500;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'CircularStd';
|
|
||||||
src: url('./fonts/CircularStd-Medium.eot');
|
|
||||||
src: url('./fonts/CircularStd-Medium.eot?#iefix') format('embedded-opentype'),
|
|
||||||
url('./fonts/CircularStd-Medium.woff2') format('woff2'),
|
|
||||||
url('./fonts/CircularStd-Medium.woff') format('woff'),
|
|
||||||
url('./fonts/CircularStd-Medium.ttf') format('truetype');
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.circular {
|
|
||||||
font-family: 'CircularStd';
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.nunito {
|
|
||||||
font-family: 'Nunito';
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
font-family: 'CircularStd';
|
|
||||||
|
|
||||||
> header {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
> main {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
> footer {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-loader {
|
|
||||||
webview {
|
|
||||||
flex: 0 1;
|
|
||||||
height: 0;
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.isFinish webview {
|
|
||||||
flex: initial;
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 320px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrapper {
|
|
||||||
height: 100%;
|
|
||||||
margin: 20px 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-project {
|
|
||||||
webview {
|
|
||||||
flex: 0 1;
|
|
||||||
height: 0;
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.fp-visible webview {
|
|
||||||
width: 480px;
|
|
||||||
height: 360px;
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1;
|
|
||||||
left: 30px;
|
|
||||||
top: 45px;
|
|
||||||
border-radius: 7px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// This file is meant to hold Bootstrap modifications
|
|
||||||
// that don't qualify as separate UI components.
|
|
||||||
|
|
||||||
// Prevent white flash when running application
|
|
||||||
html {
|
|
||||||
background-color: $palette-theme-dark-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: $palette-theme-dark-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix slight checkbox vertical alignment issue
|
|
||||||
.checkbox input[type="checkbox"] {
|
|
||||||
position: initial;
|
|
||||||
margin-right: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[uib-tooltip] {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip {
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,55 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
$spacing-large: 30px;
|
|
||||||
$spacing-medium: 15px;
|
|
||||||
$spacing-small: 10px;
|
|
||||||
$spacing-tiny: 5px;
|
|
||||||
|
|
||||||
.space-medium {
|
|
||||||
margin: $spacing-medium;
|
|
||||||
}
|
|
||||||
|
|
||||||
.space-vertical-medium {
|
|
||||||
margin-top: $spacing-medium;
|
|
||||||
margin-bottom: $spacing-medium;
|
|
||||||
}
|
|
||||||
|
|
||||||
.space-vertical-small {
|
|
||||||
margin-top: $spacing-small;
|
|
||||||
margin-bottom: $spacing-small;
|
|
||||||
}
|
|
||||||
|
|
||||||
.space-top-large {
|
|
||||||
margin-top: $spacing-large;
|
|
||||||
}
|
|
||||||
|
|
||||||
.space-vertical-large {
|
|
||||||
margin-top: $spacing-large;
|
|
||||||
margin-bottom: $spacing-large;
|
|
||||||
}
|
|
||||||
|
|
||||||
.space-bottom-medium {
|
|
||||||
margin-bottom: $spacing-medium;
|
|
||||||
}
|
|
||||||
|
|
||||||
.space-bottom-large {
|
|
||||||
margin-bottom: $spacing-large;
|
|
||||||
}
|
|
||||||
|
|
||||||
.space-right-tiny {
|
|
||||||
margin-right: $spacing-tiny;
|
|
||||||
}
|
|
||||||
@@ -1,37 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
$palette-theme-dark-foreground: #fff;
|
|
||||||
$palette-theme-dark-background: #4d5057;
|
|
||||||
$palette-theme-light-foreground: #666;
|
|
||||||
$palette-theme-light-background: #fff;
|
|
||||||
$palette-theme-dark-soft-foreground: #ddd;
|
|
||||||
$palette-theme-dark-soft-background: #64686a;
|
|
||||||
$palette-theme-light-soft-foreground: #b3b3b3;
|
|
||||||
$palette-theme-dark-disabled-background: #3a3c41;
|
|
||||||
$palette-theme-dark-disabled-foreground: #787c7f;
|
|
||||||
$palette-theme-light-disabled-background: #d5d5d5;
|
|
||||||
$palette-theme-light-disabled-foreground: #787c7f;
|
|
||||||
$palette-theme-default-background: #ececec;
|
|
||||||
$palette-theme-default-foreground: #b3b3b3;
|
|
||||||
$palette-theme-primary-background: #2297de;
|
|
||||||
$palette-theme-primary-foreground: #fff;
|
|
||||||
$palette-theme-warning-background: #ff912f;
|
|
||||||
$palette-theme-warning-foreground: #fff;
|
|
||||||
$palette-theme-danger-background: #d9534f;
|
|
||||||
$palette-theme-danger-foreground: #fff;
|
|
||||||
$palette-theme-success-background: #5fb835;
|
|
||||||
$palette-theme-success-foreground: #fff;
|
|
||||||
@@ -14,60 +14,61 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as _ from 'lodash';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Button, Flex, Provider, Txt } from 'rendition';
|
import {
|
||||||
import styled from 'styled-components';
|
Alert as AlertBase,
|
||||||
import { space } from 'styled-system';
|
Flex,
|
||||||
|
FlexProps,
|
||||||
|
Button,
|
||||||
|
ButtonProps,
|
||||||
|
Modal as ModalBase,
|
||||||
|
Provider,
|
||||||
|
Table as BaseTable,
|
||||||
|
TableProps as BaseTableProps,
|
||||||
|
Txt,
|
||||||
|
} from 'rendition';
|
||||||
|
import styled, { css } from 'styled-components';
|
||||||
|
|
||||||
import { colors } from './theme';
|
import { colors, theme } from './theme';
|
||||||
|
|
||||||
const theme = {
|
|
||||||
// TODO: Standardize how the colors are specified to match with rendition's format.
|
|
||||||
customColors: colors,
|
|
||||||
button: {
|
|
||||||
border: {
|
|
||||||
width: '0',
|
|
||||||
radius: '24px',
|
|
||||||
},
|
|
||||||
disabled: {
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
extend: () => `
|
|
||||||
width: 200px;
|
|
||||||
height: 48px;
|
|
||||||
font-size: 16px;
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
background-color: ${colors.dark.disabled.background};
|
|
||||||
color: ${colors.dark.disabled.foreground};
|
|
||||||
opacity: 1;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: ${colors.dark.disabled.background};
|
|
||||||
color: ${colors.dark.disabled.foreground};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ThemedProvider = (props: any) => (
|
export const ThemedProvider = (props: any) => (
|
||||||
<Provider theme={theme} {...props}></Provider>
|
<Provider theme={theme} {...props}></Provider>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const BaseButton = styled(Button)`
|
export const BaseButton = styled(Button)`
|
||||||
|
width: 200px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
|
font-size: 16px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const StepButton = (props: any) => (
|
export const IconButton = styled((props) => <Button plain {...props} />)`
|
||||||
<BaseButton primary {...props}></BaseButton>
|
&&& {
|
||||||
);
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
export const ChangeButton = styled(BaseButton)`
|
> svg {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const StepButton = styled((props: ButtonProps) => (
|
||||||
|
<BaseButton {...props}></BaseButton>
|
||||||
|
))`
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ChangeButton = styled(Button)`
|
||||||
|
&& {
|
||||||
|
border-radius: 24px;
|
||||||
color: ${colors.primary.background};
|
color: ${colors.primary.background};
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 100%;
|
height: 18px;
|
||||||
height: auto;
|
font-size: 14px;
|
||||||
|
|
||||||
&:enabled {
|
&:enabled {
|
||||||
&:hover,
|
&:hover,
|
||||||
@@ -76,14 +77,15 @@ export const ChangeButton = styled(BaseButton)`
|
|||||||
color: #8f9297;
|
color: #8f9297;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
${space}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const StepNameButton = styled(BaseButton)`
|
export const StepNameButton = styled(BaseButton)`
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-weight: bold;
|
font-weight: normal;
|
||||||
color: ${colors.dark.foreground};
|
color: ${colors.dark.foreground};
|
||||||
|
|
||||||
&:enabled {
|
&:enabled {
|
||||||
@@ -94,20 +96,230 @@ export const StepNameButton = styled(BaseButton)`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
export const StepSelection = styled(Flex)`
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
`;
|
|
||||||
export const Footer = styled(Txt)`
|
export const Footer = styled(Txt)`
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
color: ${colors.dark.disabled.foreground};
|
color: ${colors.dark.disabled.foreground};
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
`;
|
`;
|
||||||
export const Underline = styled(Txt.span)`
|
|
||||||
border-bottom: 1px dotted;
|
export const DetailsText = (props: FlexProps) => (
|
||||||
padding-bottom: 2px;
|
<Flex
|
||||||
|
alignItems="center"
|
||||||
|
color={colors.dark.disabled.foreground}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const modalFooterShadowCss = css`
|
||||||
|
overflow: auto;
|
||||||
|
background: 0, linear-gradient(rgba(255, 255, 255, 0), white 70%) 0 100%, 0,
|
||||||
|
linear-gradient(rgba(255, 255, 255, 0), rgba(221, 225, 240, 0.5) 70%) 0 100%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 100% 40px, 100% 40px, 100% 8px, 100% 8px;
|
||||||
|
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-color: white;
|
||||||
|
background-size: 100% 40px, 100% 40px, 100% 8px, 100% 8px;
|
||||||
|
background-attachment: local, local, scroll, scroll;
|
||||||
`;
|
`;
|
||||||
export const DetailsText = styled(Txt.p)`
|
|
||||||
color: ${colors.dark.disabled.foreground};
|
export const Modal = styled(({ style, children, ...props }) => {
|
||||||
margin-bottom: 0;
|
return (
|
||||||
|
<ModalBase
|
||||||
|
position="top"
|
||||||
|
width="97vw"
|
||||||
|
cancelButtonProps={{
|
||||||
|
style: {
|
||||||
|
marginRight: '20px',
|
||||||
|
border: 'solid 1px #2a506f',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
height: '87.5vh',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollableFlex flexDirection="column" width="100%" height="90%">
|
||||||
|
{children.length ? children.map((c: any) => <>{c}</>) : children}
|
||||||
|
</ScrollableFlex>
|
||||||
|
</ModalBase>
|
||||||
|
);
|
||||||
|
})`
|
||||||
|
> div {
|
||||||
|
padding: 0;
|
||||||
|
height: 99%;
|
||||||
|
|
||||||
|
> div:first-child {
|
||||||
|
height: 81%;
|
||||||
|
padding: 24px 30px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> h3 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px 30px 0;
|
||||||
|
height: 14.3%;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div:first-child {
|
||||||
|
height: 81%;
|
||||||
|
padding: 24px 30px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div:nth-child(2) {
|
||||||
|
height: 61%;
|
||||||
|
padding: 0 30px;
|
||||||
|
${modalFooterShadowCss}
|
||||||
|
}
|
||||||
|
|
||||||
|
> div:last-child {
|
||||||
|
margin: 0;
|
||||||
|
flex-direction: ${(props) =>
|
||||||
|
props.reverseFooterButtons ? 'row-reverse' : 'row'};
|
||||||
|
border-radius: 0 0 7px 7px;
|
||||||
|
height: 80px;
|
||||||
|
background-color: #fff;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const ScrollableFlex = styled(Flex)`
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div > div {
|
||||||
|
/* This is required for the sticky table header in TargetsTable */
|
||||||
|
overflow-x: visible;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Alert = styled((props) => (
|
||||||
|
<AlertBase warning emphasized {...props}></AlertBase>
|
||||||
|
))`
|
||||||
|
position: fixed;
|
||||||
|
top: -40px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 0px);
|
||||||
|
height: 30px;
|
||||||
|
min-width: 50%;
|
||||||
|
padding: 0px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: #fca321;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
* {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div:first-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export interface GenericTableProps<T> extends BaseTableProps<T> {
|
||||||
|
refFn: (t: BaseTable<T>) => void;
|
||||||
|
data: T[];
|
||||||
|
checkedRowsNumber?: number;
|
||||||
|
multipleSelection: boolean;
|
||||||
|
showWarnings?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GenericTable: <T>(
|
||||||
|
props: GenericTableProps<T>,
|
||||||
|
) => React.ReactElement<GenericTableProps<T>> = <T extends {}>({
|
||||||
|
refFn,
|
||||||
|
...props
|
||||||
|
}: GenericTableProps<T>) => (
|
||||||
|
<div>
|
||||||
|
<BaseTable<T> ref={refFn} {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function StyledTable<T>() {
|
||||||
|
return styled((props: GenericTableProps<T>) => (
|
||||||
|
<GenericTable<T> {...props} />
|
||||||
|
))`
|
||||||
|
[data-display='table-head']
|
||||||
|
> [data-display='table-row']
|
||||||
|
> [data-display='table-cell'] {
|
||||||
|
position: sticky;
|
||||||
|
background-color: #f8f9fd;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
input[type='checkbox'] + div {
|
||||||
|
display: ${(props) => (props.multipleSelection ? 'flex' : 'none')};
|
||||||
|
|
||||||
|
${(props) =>
|
||||||
|
props.multipleSelection &&
|
||||||
|
props.checkedRowsNumber !== 0 &&
|
||||||
|
props.checkedRowsNumber !== props.data.length
|
||||||
|
? `
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${colors.primary.foreground};
|
||||||
|
background: ${colors.primary.background};
|
||||||
|
|
||||||
|
::after {
|
||||||
|
content: '–';
|
||||||
|
}
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-display='table-head'] > [data-display='table-row'],
|
||||||
|
[data-display='table-body'] > [data-display='table-row'] {
|
||||||
|
> [data-display='table-cell']:first-child {
|
||||||
|
padding-left: 15px;
|
||||||
|
width: 6%;
|
||||||
|
}
|
||||||
|
|
||||||
|
> [data-display='table-cell']:last-child {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-display='table-body'] > [data-display='table-row'] {
|
||||||
|
&:nth-of-type(2n) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-highlight='true'] {
|
||||||
|
&.system {
|
||||||
|
background-color: ${(props) => (props.showWarnings ? '#fff5e6' : '#e8f5fc')};
|
||||||
|
}
|
||||||
|
|
||||||
|
> [data-display='table-cell']:first-child {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&& [data-display='table-row'] > [data-display='table-cell'] {
|
||||||
|
padding: 6px 8px;
|
||||||
|
color: #2a506f;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox'] + div {
|
||||||
|
border-radius: ${(props) => (props.multipleSelection ? '4px' : '50%')};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Table = <T extends {}>(props: GenericTableProps<T>) => {
|
||||||
|
const TypedStyledFunctional = StyledTable<T>();
|
||||||
|
return <TypedStyledFunctional {...props} />;
|
||||||
|
};
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as _ from 'lodash';
|
||||||
|
import { Theme } from 'rendition';
|
||||||
|
|
||||||
export const colors = {
|
export const colors = {
|
||||||
dark: {
|
dark: {
|
||||||
foreground: '#fff',
|
foreground: '#fff',
|
||||||
@@ -44,11 +47,12 @@ export const colors = {
|
|||||||
},
|
},
|
||||||
primary: {
|
primary: {
|
||||||
foreground: '#fff',
|
foreground: '#fff',
|
||||||
background: '#2297de',
|
background: '#00aeef',
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
foreground: '#000',
|
foreground: '#000',
|
||||||
background: '#ddd',
|
background: '#ddd',
|
||||||
|
main: '#fff',
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
foreground: '#fff',
|
foreground: '#fff',
|
||||||
@@ -63,3 +67,60 @@ export const colors = {
|
|||||||
background: '#5fb835',
|
background: '#5fb835',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const font = 'SourceSansPro';
|
||||||
|
|
||||||
|
export const theme = _.merge({}, Theme, {
|
||||||
|
colors,
|
||||||
|
font,
|
||||||
|
header: {
|
||||||
|
height: '40px',
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
font: {
|
||||||
|
family: font,
|
||||||
|
size: 16,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
medium: {
|
||||||
|
size: 16,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
border: {
|
||||||
|
width: '0',
|
||||||
|
radius: '24px',
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
extend: () => `
|
||||||
|
width: 200px;
|
||||||
|
font-size: 16px;
|
||||||
|
|
||||||
|
&& {
|
||||||
|
width: 200px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:disabled {
|
||||||
|
background-color: ${colors.dark.disabled.background};
|
||||||
|
color: ${colors.dark.disabled.foreground};
|
||||||
|
opacity: 1;
|
||||||
|
|
||||||
|
:hover {
|
||||||
|
background-color: ${colors.dark.disabled.background};
|
||||||
|
color: ${colors.dark.disabled.foreground};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
layer: {
|
||||||
|
extend: () => `
|
||||||
|
> div:first-child {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
73
lib/gui/app/utils/etcher-pro-specific.ts
Normal file
73
lib/gui/app/utils/etcher-pro-specific.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2022 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 { Dictionary } from 'lodash';
|
||||||
|
|
||||||
|
type BalenaTag = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class EtcherPro {
|
||||||
|
private supervisorAddr: string;
|
||||||
|
private supervisorKey: string;
|
||||||
|
private tags: Dictionary<string> | undefined;
|
||||||
|
public uuid: string;
|
||||||
|
|
||||||
|
constructor(supervisorAddr: string, supervisorKey: string) {
|
||||||
|
this.supervisorAddr = supervisorAddr;
|
||||||
|
this.supervisorKey = supervisorKey;
|
||||||
|
this.uuid = (process.env.BALENA_DEVICE_UUID ?? 'NO-UUID').substring(0, 7);
|
||||||
|
this.tags = undefined;
|
||||||
|
this.get_tags().then((tags) => (this.tags = tags));
|
||||||
|
}
|
||||||
|
|
||||||
|
async get_tags(): Promise<Dictionary<string>> {
|
||||||
|
const result = await fetch(
|
||||||
|
this.supervisorAddr + '/v2/device/tags?apikey=' + this.supervisorKey,
|
||||||
|
);
|
||||||
|
const parsed = await result.json();
|
||||||
|
if (parsed['status'] === 'success') {
|
||||||
|
return Object.assign(
|
||||||
|
{},
|
||||||
|
...parsed['tags'].map((tag: BalenaTag) => {
|
||||||
|
return { [tag.name]: tag.value };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public get_serial(): string | undefined {
|
||||||
|
if (this.tags) {
|
||||||
|
return this.tags['Serial'];
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function etcherProInfo(): EtcherPro | undefined {
|
||||||
|
const BALENA_SUPERVISOR_ADDRESS = process.env.BALENA_SUPERVISOR_ADDRESS;
|
||||||
|
const BALENA_SUPERVISOR_API_KEY = process.env.BALENA_SUPERVISOR_API_KEY;
|
||||||
|
|
||||||
|
if (BALENA_SUPERVISOR_ADDRESS && BALENA_SUPERVISOR_API_KEY) {
|
||||||
|
return new EtcherPro(BALENA_SUPERVISOR_ADDRESS, BALENA_SUPERVISOR_API_KEY);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user