mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-10 22:41:05 +03:00
Compare commits
593 Commits
1.4.2
...
fix-textur
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24a16d9a30 | ||
|
|
8fc82d04ac | ||
|
|
c6c001a15e | ||
|
|
7da2bbe6ac | ||
|
|
c5adac828b | ||
|
|
7c23e1f4b9 | ||
|
|
c4fd7d692d | ||
|
|
dfca2c1b8f | ||
|
|
10bcf976f7 | ||
|
|
63822048df | ||
|
|
1d09760ef7 | ||
|
|
23256e6ac1 | ||
|
|
ff07ff7f13 | ||
|
|
947cb3f17b | ||
|
|
d407db9fae | ||
|
|
594e63805c | ||
|
|
7c23fd3073 | ||
|
|
2915076642 | ||
|
|
11190fa54e | ||
|
|
d057fe14b2 | ||
|
|
4234b99029 | ||
|
|
6fd96dda6e | ||
|
|
429c8c6711 | ||
|
|
9a81c8a138 | ||
|
|
ddad47925c | ||
|
|
f5ab01f8bd | ||
|
|
cc85685b96 | ||
|
|
7eb9150116 | ||
|
|
ef3a57580f | ||
|
|
402ed07b0c | ||
|
|
3cf32e7066 | ||
|
|
6f1eb164d6 | ||
|
|
4389687d9d | ||
|
|
a84bad4639 | ||
|
|
e6dd925ab0 | ||
|
|
d752823b8c | ||
|
|
2f8822ec7a | ||
|
|
ffe20bb297 | ||
|
|
a5018a022b | ||
|
|
6c69faaa1c | ||
|
|
b19f1ef76f | ||
|
|
807e05ea9a | ||
|
|
e0254d997e | ||
|
|
006b9737e4 | ||
|
|
5aeb4cf945 | ||
|
|
c6c53f094a | ||
|
|
e63df74715 | ||
|
|
9aeb54cf33 | ||
|
|
3442648afe | ||
|
|
8545b5ed98 | ||
|
|
72c052cb9a | ||
|
|
12f2de5959 | ||
|
|
a6708f40e7 | ||
|
|
85a5fefab8 | ||
|
|
d412d19872 | ||
|
|
4dd8e20392 | ||
|
|
dabdbf73bb | ||
|
|
d6ea170061 | ||
|
|
eefd22b205 | ||
|
|
5882346caa | ||
|
|
b1fad7bbed | ||
|
|
57456f0b52 | ||
|
|
cefff781d4 | ||
|
|
ad9dac1001 | ||
|
|
b4af82157b | ||
|
|
beaa754299 | ||
|
|
929e989f17 | ||
|
|
1c2dd71891 | ||
|
|
5b4d6baf47 | ||
|
|
7696b0ee51 | ||
|
|
20ab5ab0ad | ||
|
|
c01300be20 | ||
|
|
5f015c9da1 | ||
|
|
082a5a2a4e | ||
|
|
61f0944990 | ||
|
|
96e2a330b8 | ||
|
|
5abf4e9724 | ||
|
|
cf2b28faf9 | ||
|
|
bdb38c4730 | ||
|
|
fa418cace6 | ||
|
|
94a2a2bb4a | ||
|
|
865fe71c46 | ||
|
|
137298e05a | ||
|
|
685a89a171 | ||
|
|
12b5cc7f72 | ||
|
|
480e9e8234 | ||
|
|
29e1852a68 | ||
|
|
8314335b31 | ||
|
|
acb9f63e1d | ||
|
|
005a8b4a04 | ||
|
|
e2149974cc | ||
|
|
6c578292e8 | ||
|
|
28930c0463 | ||
|
|
37141afece | ||
|
|
493b14ba78 | ||
|
|
cf1de4de62 | ||
|
|
9fdb8410d3 | ||
|
|
a2b79462ab | ||
|
|
dce221be5a | ||
|
|
9d1ab3fba3 | ||
|
|
b3bd18845d | ||
|
|
435f6ec61d | ||
|
|
0497814004 | ||
|
|
4b1ef9e20d | ||
|
|
10d5250d23 | ||
|
|
2ee580d49d | ||
|
|
4a54029cac | ||
|
|
001848bf2f | ||
|
|
989bf80fe8 | ||
|
|
78b5f47668 | ||
|
|
97e9e44faa | ||
|
|
ff226f6d80 | ||
|
|
0cbdb6ffb3 | ||
|
|
b8117c5c34 | ||
|
|
a69614d464 | ||
|
|
58ee593e26 | ||
|
|
09bc9056c9 | ||
|
|
0c6df924d1 | ||
|
|
456817b4f4 | ||
|
|
16570ee34f | ||
|
|
2b40c61d8e | ||
|
|
dcc64cdeae | ||
|
|
2747d3d8b4 | ||
|
|
3c574a4182 | ||
|
|
311d4708e5 | ||
|
|
5cf4323d07 | ||
|
|
3976701ac6 | ||
|
|
9ded8d6ab2 | ||
|
|
cd2fff0655 | ||
|
|
10f61ffdc2 | ||
|
|
d72952bf93 | ||
|
|
a7c55db9ac | ||
|
|
bff47e2b81 | ||
|
|
3d478c4935 | ||
|
|
a658e987b7 | ||
|
|
7c8b0adc1e | ||
|
|
a732ebc3e1 | ||
|
|
30c0867e40 | ||
|
|
8f50ea64dc | ||
|
|
0797ebb695 | ||
|
|
c9391fb894 | ||
|
|
8a955888bf | ||
|
|
36e812e550 | ||
|
|
8baa995c7a | ||
|
|
f4a0535289 | ||
|
|
6665242edf | ||
|
|
3cdf1cce54 | ||
|
|
88ae00ba73 | ||
|
|
7c26575dbd | ||
|
|
93d064a9b0 | ||
|
|
bf206dc309 | ||
|
|
6d116cf1c9 | ||
|
|
1fc33218dc | ||
|
|
b73e5bbfa0 | ||
|
|
78533e428e | ||
|
|
cc7fe4efdc | ||
|
|
84af60c07e | ||
|
|
6426269d41 | ||
|
|
7c41f993fe | ||
|
|
0c86d46162 | ||
|
|
e87797418f | ||
|
|
78a3a2aeb9 | ||
|
|
e18cf7a245 | ||
|
|
50d5823ef5 | ||
|
|
518296f257 | ||
|
|
3217125dd3 | ||
|
|
00032854eb | ||
|
|
d99ddf6816 | ||
|
|
32c6e32e04 | ||
|
|
c55b1f3359 | ||
|
|
fabeae4180 | ||
|
|
5eed50961d | ||
|
|
bed0976eb9 | ||
|
|
70d92d9b07 | ||
|
|
fb4ba31504 | ||
|
|
152c5c71b1 | ||
|
|
fa369365a5 | ||
|
|
7345366ba7 | ||
|
|
6151ea7128 | ||
|
|
440ab26b69 | ||
|
|
caadd72ab2 | ||
|
|
d59d543ec1 | ||
|
|
58d1109510 | ||
|
|
9c52e25a6a | ||
|
|
62a44c5a09 | ||
|
|
e5fa40e903 | ||
|
|
8177083992 | ||
|
|
4bfd8e9f61 | ||
|
|
81e7d27ec8 | ||
|
|
f3fc0b5ac2 | ||
|
|
fb7bca436b | ||
|
|
c19a0ceba2 | ||
|
|
1f26e452fc | ||
|
|
0af6b7ede9 | ||
|
|
6ad56075d6 | ||
|
|
b81ae6c894 | ||
|
|
546e9f1702 | ||
|
|
bb51c6aa42 | ||
|
|
78e8134ad5 | ||
|
|
bc2c36215d | ||
|
|
377547fa11 | ||
|
|
472c4fc03a | ||
|
|
9f8f726f12 | ||
|
|
701a9c6cdc | ||
|
|
0d40cf2101 | ||
|
|
dd265dadd7 | ||
|
|
fe5a8cb2ad | ||
|
|
b6caa1a7b2 | ||
|
|
55c9707639 | ||
|
|
d8808baa83 | ||
|
|
1978020d27 | ||
|
|
0e4b91b8d7 | ||
|
|
9c831dc59b | ||
|
|
b757e97c11 | ||
|
|
9df486a689 | ||
|
|
72d27c3c47 | ||
|
|
6c20fc936d | ||
|
|
5439ec38b6 | ||
|
|
8b8a64f870 | ||
|
|
92509f8e8a | ||
|
|
0221634a4d | ||
|
|
9d1f86fbc6 | ||
|
|
f29dec7b13 | ||
|
|
d5d0b01266 | ||
|
|
5abae617dc | ||
|
|
52d62da002 | ||
|
|
253d632709 | ||
|
|
383a5c3478 | ||
|
|
d4a1430c27 | ||
|
|
bfd31d21e4 | ||
|
|
590296b297 | ||
|
|
ee8cc0c06b | ||
|
|
99b565ef40 | ||
|
|
1e6a3dc644 | ||
|
|
5b7ad339b8 | ||
|
|
7308c448f1 | ||
|
|
c8ba99d1a1 | ||
|
|
5ea6714db8 | ||
|
|
3a1622e8b5 | ||
|
|
38f1300717 | ||
|
|
03e351ac61 | ||
|
|
6cb323725b | ||
|
|
5d0533f0d4 | ||
|
|
e0c5e1483e | ||
|
|
47e4c65d8e | ||
|
|
9bc1ce52af | ||
|
|
348d1b46e1 | ||
|
|
1a41b3ac11 | ||
|
|
b239535009 | ||
|
|
5fd20f808c | ||
|
|
803ac8cc4e | ||
|
|
4a50bc6fc2 | ||
|
|
e8a1b7fe21 | ||
|
|
ac124c0680 | ||
|
|
91aff3ffd1 | ||
|
|
642c281ad0 | ||
|
|
1e9c4d04f1 | ||
|
|
9f817714fe | ||
|
|
091f2c6135 | ||
|
|
91de51290d | ||
|
|
68fa0466c8 | ||
|
|
28e303576c | ||
|
|
2d41b3e80d | ||
|
|
ffd2d26c1a | ||
|
|
a8dc6fc632 | ||
|
|
771cb4ebd7 | ||
|
|
2f694c0eb2 | ||
|
|
8dea347a21 | ||
|
|
0cf3e8ed40 | ||
|
|
9d3bc7d9e6 | ||
|
|
e0427bdc77 | ||
|
|
9cf1338dc4 | ||
|
|
4e30ee8d1c | ||
|
|
cca6a5fe12 | ||
|
|
9e4b7fca4d | ||
|
|
d135c58ead | ||
|
|
de194417d4 | ||
|
|
d01ce3173f | ||
|
|
010a54d1c9 | ||
|
|
f557fc94fa | ||
|
|
f02cd9c0f6 | ||
|
|
170516572e | ||
|
|
285e29d2dc | ||
|
|
aab34b2338 | ||
|
|
ad1e5330e9 | ||
|
|
ca4647ddd6 | ||
|
|
7004acae46 | ||
|
|
899dd46f5b | ||
|
|
dba5fea66f | ||
|
|
c457b0e7d3 | ||
|
|
c0da4a6645 | ||
|
|
9d8df6a226 | ||
|
|
02da7132e7 | ||
|
|
e3b6e4eaf0 | ||
|
|
0388d00ad3 | ||
|
|
1e2d2c5146 | ||
|
|
96797742f2 | ||
|
|
682e347be0 | ||
|
|
b3f43f55c1 | ||
|
|
016a0b1141 | ||
|
|
fd7bcf54bd | ||
|
|
db3f5fe816 | ||
|
|
0d3016fcd8 | ||
|
|
1abc897c45 | ||
|
|
ab64a32f30 | ||
|
|
52b66e71d1 | ||
|
|
41ab5bbdd8 | ||
|
|
732b250815 | ||
|
|
157dbdc543 | ||
|
|
6ba23683d5 | ||
|
|
80a5865db3 | ||
|
|
9cb6f38aea | ||
|
|
cd7e3e4505 | ||
|
|
1833cb0655 | ||
|
|
e4208aa9cf | ||
|
|
bb3501a4f9 | ||
|
|
4abdb2e08b | ||
|
|
d49ae493b2 | ||
|
|
394079833e | ||
|
|
12d6789c2e | ||
|
|
34803f8e9b | ||
|
|
fd43184406 | ||
|
|
3cc3315081 | ||
|
|
6aee70fa18 | ||
|
|
82a9fd1540 | ||
|
|
dc760d6ca8 | ||
|
|
eb239501bc | ||
|
|
0016033937 | ||
|
|
50c62d5eac | ||
|
|
91ac48912e | ||
|
|
272a6604cd | ||
|
|
8a889d3ebb | ||
|
|
17a3f2ae52 | ||
|
|
6c3515588f | ||
|
|
4d2d2118a2 | ||
|
|
483fe80308 | ||
|
|
34ceeac36e | ||
|
|
20f11018ce | ||
|
|
9345fb754a | ||
|
|
779b7aaf02 | ||
|
|
b268aa1061 | ||
|
|
40f86fa639 | ||
|
|
980bc11e68 | ||
|
|
85db677982 | ||
|
|
2842315b1d | ||
|
|
6c541f7bfd | ||
|
|
067fab2b73 | ||
|
|
de6bf9dc7e | ||
|
|
54eae37038 | ||
|
|
0118e16132 | ||
|
|
626a091f55 | ||
|
|
4fa5e99e65 | ||
|
|
5ee9dcf42d | ||
|
|
6306f83316 | ||
|
|
96075fdf49 | ||
|
|
8c6dcf53a6 | ||
|
|
e1b1a927b8 | ||
|
|
1e6bfa7bb1 | ||
|
|
79ef4c4501 | ||
|
|
5f3ceef592 | ||
|
|
1a90e6b6c7 | ||
|
|
f112d097dc | ||
|
|
45cab7f808 | ||
|
|
216ec9d52b | ||
|
|
56a8f6b97b | ||
|
|
c76d10a438 | ||
|
|
f05f2178e5 | ||
|
|
226d7417b2 | ||
|
|
b0c8e65c6e | ||
|
|
4ae577c3c4 | ||
|
|
204e81a700 | ||
|
|
1f35830570 | ||
|
|
6b334f2977 | ||
|
|
0dc3c12aa5 | ||
|
|
ceffcce20e | ||
|
|
e4b06dadf5 | ||
|
|
087eb55299 | ||
|
|
341eb0c671 | ||
|
|
43b39102a4 | ||
|
|
be4bbd018d | ||
|
|
21a7cef98a | ||
|
|
a6724b1c07 | ||
|
|
7437593ee7 | ||
|
|
f21829b075 | ||
|
|
b4f60e6057 | ||
|
|
b9ebddff0c | ||
|
|
a2243484a3 | ||
|
|
c4a9835ae5 | ||
|
|
92ad279324 | ||
|
|
7276025cf9 | ||
|
|
9808d585cf | ||
|
|
dab9ed711c | ||
|
|
b27a93fc77 | ||
|
|
e3f66973b7 | ||
|
|
21529d6ca2 | ||
|
|
775b0a3c93 | ||
|
|
070d4d029f | ||
|
|
5355702e9c | ||
|
|
a97997952d | ||
|
|
b0c12bd86b | ||
|
|
82fcab26b1 | ||
|
|
f3bbcc4f55 | ||
|
|
98362eaca0 | ||
|
|
998b75856d | ||
|
|
3a9084006f | ||
|
|
4d3ccc62e8 | ||
|
|
8fe10d61ea | ||
|
|
5a183490dc | ||
|
|
9dd4fa8646 | ||
|
|
a05b619563 | ||
|
|
7f9506b476 | ||
|
|
f65952cf1c | ||
|
|
7ac03ffefc | ||
|
|
f6d6c3afb5 | ||
|
|
419703d2ea | ||
|
|
9301edef06 | ||
|
|
7e3f0a607b | ||
|
|
dec0e7c56d | ||
|
|
0758e10ae2 | ||
|
|
19ae785fa2 | ||
|
|
918ce865ca | ||
|
|
d27a21feee | ||
|
|
d8932b69a3 | ||
|
|
5af580f44d | ||
|
|
3384eda8b7 | ||
|
|
969ea28d06 | ||
|
|
5b2101e17d | ||
|
|
ec2d7f0519 | ||
|
|
656ce93d6e | ||
|
|
b69e871f9a | ||
|
|
bba57069a8 | ||
|
|
6a701f1420 | ||
|
|
eba847e62e | ||
|
|
b80eb2dc6c | ||
|
|
1f9689dc00 | ||
|
|
84eb75d5b6 | ||
|
|
4f2aea65ab | ||
|
|
d6463f95b9 | ||
|
|
3e0688ab63 | ||
|
|
692e90f779 | ||
|
|
e4faedcb62 | ||
|
|
a32d36a97b | ||
|
|
da2c678fb3 | ||
|
|
7bdfa121f3 | ||
|
|
b9a1369c6f | ||
|
|
0112b3387e | ||
|
|
de9d86621d | ||
|
|
735862d1fd | ||
|
|
a0537759b1 | ||
|
|
a79776c1c4 | ||
|
|
822b6d1baf | ||
|
|
0065085ba2 | ||
|
|
4f4da20fc0 | ||
|
|
eb0174ea53 | ||
|
|
20ce626654 | ||
|
|
a342941ec1 | ||
|
|
a78a803a22 | ||
|
|
23754630e8 | ||
|
|
8e6e91eb4a | ||
|
|
9cfa551163 | ||
|
|
5b21441898 | ||
|
|
4ed8696d1d | ||
|
|
ae06f27372 | ||
|
|
33e1493932 | ||
|
|
22b1dcaf7b | ||
|
|
426a68775f | ||
|
|
3c0be4e40e | ||
|
|
3787b45b49 | ||
|
|
7d06de00fb | ||
|
|
6f8af9d114 | ||
|
|
0a672f092a | ||
|
|
ef62f1db29 | ||
|
|
7f804a0e45 | ||
|
|
b2dff336ce | ||
|
|
a6571e71e4 | ||
|
|
81f711eb00 | ||
|
|
c8a8e06558 | ||
|
|
2c079f53a9 | ||
|
|
322ffe288e | ||
|
|
c340eb0e57 | ||
|
|
4e953291ed | ||
|
|
1dea5fee0e | ||
|
|
9f24b46fee | ||
|
|
0808c41a1c | ||
|
|
296c6df462 | ||
|
|
13ee3e907d | ||
|
|
ce7d794b4c | ||
|
|
fb10069632 | ||
|
|
43a7677644 | ||
|
|
58fa32d7ea | ||
|
|
934d6c3987 | ||
|
|
2d7c6ef21f | ||
|
|
99a97e6a6c | ||
|
|
017a10e8c8 | ||
|
|
41ffa8ba08 | ||
|
|
e029d00cfa | ||
|
|
268534d5e7 | ||
|
|
a7d2bc63f9 | ||
|
|
559115c43c | ||
|
|
1277c7d60c | ||
|
|
9b69c7e972 | ||
|
|
a903f710ea | ||
|
|
b75f4daa47 | ||
|
|
fef44ffa57 | ||
|
|
5a812e3b2f | ||
|
|
910dcf2036 | ||
|
|
44a28aa5bd | ||
|
|
f7f947beb9 | ||
|
|
d03a9e2baf | ||
|
|
ca22316e95 | ||
|
|
ef99c479aa | ||
|
|
fa9260c763 | ||
|
|
fab11c8ffa | ||
|
|
9bd9658a92 | ||
|
|
213880c14d | ||
|
|
0550397046 | ||
|
|
f7a5a506f6 | ||
|
|
0f34c50bd2 | ||
|
|
055826e26f | ||
|
|
a30582c840 | ||
|
|
d106d97b99 | ||
|
|
d4410e78e2 | ||
|
|
e3fcc6cce3 | ||
|
|
265d08fc3b | ||
|
|
7c8329c5c6 | ||
|
|
d3947c9a19 | ||
|
|
cd99331668 | ||
|
|
472e18b10a | ||
|
|
d443f5de28 | ||
|
|
3242d132f6 | ||
|
|
e66d2facd4 | ||
|
|
f15b8dc0da | ||
|
|
3275824aec | ||
|
|
965cb704ec | ||
|
|
938e165470 | ||
|
|
9058ef3344 | ||
|
|
ed39cc3038 | ||
|
|
a77752c4cb | ||
|
|
c9940957f0 | ||
|
|
c90d72d720 | ||
|
|
2c30bd9d24 | ||
|
|
f2dc8e21a8 | ||
|
|
6a0da9cf09 | ||
|
|
d55974c352 | ||
|
|
a898c22f4b | ||
|
|
b82e8bedfc | ||
|
|
7453cefd94 | ||
|
|
1ed6b958cb | ||
|
|
57896ab176 | ||
|
|
5c370b3914 | ||
|
|
182e35adc7 | ||
|
|
d0a360fd80 | ||
|
|
2fbc0625de | ||
|
|
d3d20a4e20 | ||
|
|
2c088d3504 | ||
|
|
6f9728f2d4 | ||
|
|
30552fd202 | ||
|
|
9826c4e943 | ||
|
|
bb9445bd0f | ||
|
|
1f7e66f4cb | ||
|
|
2a34e918a0 | ||
|
|
21c0d924ab | ||
|
|
c8d5ee6565 | ||
|
|
3d8fc7ca7b | ||
|
|
246b5b93f8 | ||
|
|
2183c0980b | ||
|
|
4ae301710d | ||
|
|
5f9390c210 | ||
|
|
0f3a03aab7 | ||
|
|
02f455b0cc | ||
|
|
ffddf60184 | ||
|
|
482840b8bb | ||
|
|
a3637cf2b6 | ||
|
|
48669cdb34 | ||
|
|
a953845ba7 | ||
|
|
8d71534839 | ||
|
|
d110118961 | ||
|
|
fa1ed2bc0c | ||
|
|
3f28978dad | ||
|
|
02cd121465 | ||
|
|
5481c300b2 | ||
|
|
7b75257a4a | ||
|
|
c02e5cad73 | ||
|
|
dee03c0f9f | ||
|
|
d1159764f6 | ||
|
|
eacb07988d | ||
|
|
a375766ac2 | ||
|
|
9b9276e752 | ||
|
|
753a2ab2b7 | ||
|
|
0cef5f79ee | ||
|
|
b11a8dfe54 | ||
|
|
2d1c94f1ef | ||
|
|
e14e850e10 |
@@ -2,6 +2,8 @@
|
|||||||
rustflags = ["-Ctarget-feature=+crt-static"]
|
rustflags = ["-Ctarget-feature=+crt-static"]
|
||||||
[target.i686-pc-windows-msvc]
|
[target.i686-pc-windows-msvc]
|
||||||
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-args=/NODEFAULTLIB:MSVCRT"]
|
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-args=/NODEFAULTLIB:MSVCRT"]
|
||||||
|
[target.aarch64-pc-windows-msvc]
|
||||||
|
rustflags = ["-Ctarget-feature=+crt-static"]
|
||||||
[target.'cfg(target_os="macos")']
|
[target.'cfg(target_os="macos")']
|
||||||
rustflags = [
|
rustflags = [
|
||||||
"-C", "link-args=-sectcreate __CGPreLoginApp __cgpreloginapp /dev/null",
|
"-C", "link-args=-sectcreate __CGPreLoginApp __cgpreloginapp /dev/null",
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
You are an expert in prompt engineering, specializing in optimizing AI code assistant instructions. Your task is to analyze and improve the instructions for Claude Code.
|
|
||||||
Follow these steps carefully:
|
|
||||||
|
|
||||||
1. Analysis Phase:
|
|
||||||
Review the chat history in your context window.
|
|
||||||
|
|
||||||
Then, examine the current Claude instructions, commands and config
|
|
||||||
<claude_instructions>
|
|
||||||
/CLAUDE.md
|
|
||||||
/.claude/commands/*
|
|
||||||
**/CLAUDE.md
|
|
||||||
.claude/settings.json
|
|
||||||
.claude/settings.local.json
|
|
||||||
</claude_instructions>
|
|
||||||
|
|
||||||
Analyze the chat history, instructions, commands and config to identify areas that could be improved. Look for:
|
|
||||||
- Inconsistencies in Claude's responses
|
|
||||||
- Misunderstandings of user requests
|
|
||||||
- Areas where Claude could provide more detailed or accurate information
|
|
||||||
- Opportunities to enhance Claude's ability to handle specific types of queries or tasks
|
|
||||||
- New commands or improvements to a commands name, function or response
|
|
||||||
- Permissions and MCPs we've approved locally that we should add to the config, especially if we've added new tools or require them for the command to work
|
|
||||||
|
|
||||||
2. Interaction Phase:
|
|
||||||
Present your findings and improvement ideas to the human. For each suggestion:
|
|
||||||
a) Explain the current issue you've identified
|
|
||||||
b) Propose a specific change or addition to the instructions
|
|
||||||
c) Describe how this change would improve Claude's performance
|
|
||||||
|
|
||||||
Wait for feedback from the human on each suggestion before proceeding. If the human approves a change, move it to the implementation phase. If not, refine your suggestion or move on to the next idea.
|
|
||||||
|
|
||||||
3. Implementation Phase:
|
|
||||||
For each approved change:
|
|
||||||
a) Clearly state the section of the instructions you're modifying
|
|
||||||
b) Present the new or modified text for that section
|
|
||||||
c) Explain how this change addresses the issue identified in the analysis phase
|
|
||||||
|
|
||||||
4. Output Format:
|
|
||||||
Present your final output in the following structure:
|
|
||||||
|
|
||||||
<analysis>
|
|
||||||
[List the issues identified and potential improvements]
|
|
||||||
</analysis>
|
|
||||||
|
|
||||||
<improvements>
|
|
||||||
[For each approved improvement:
|
|
||||||
1. Section being modified
|
|
||||||
2. New or modified instruction text
|
|
||||||
3. Explanation of how this addresses the identified issue]
|
|
||||||
</improvements>
|
|
||||||
|
|
||||||
<final_instructions>
|
|
||||||
[Present the complete, updated set of instructions for Claude, incorporating all approved changes]
|
|
||||||
</final_instructions>
|
|
||||||
|
|
||||||
Remember, your goal is to enhance Claude's performance and consistency while maintaining the core functionality and purpose of the AI assistant. Be thorough in your analysis, clear in your explanations, and precise in your implementations.
|
|
||||||
140
.github/patches/apply_flutter_3.44_source_patches.sh
vendored
Normal file
140
.github/patches/apply_flutter_3.44_source_patches.sh
vendored
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Applies the Flutter 3.44-only source/pubspec changes on the fly, in CI only.
|
||||||
|
#
|
||||||
|
# Windows arm64 needs Flutter >= 3.44 (the first stable release shipping an arm64 Dart SDK +
|
||||||
|
# engine), which renamed DialogTheme/TabBarTheme -> *Data and needs newer extended_text/
|
||||||
|
# google_fonts. Every other platform is still on Flutter 3.24.5, where the old names/versions
|
||||||
|
# are required, so these changes are kept OUT of the committed sources and applied here instead.
|
||||||
|
#
|
||||||
|
# Used by BOTH the Windows arm64 build (flutter-build.yml) and its dedicated bridge artifact
|
||||||
|
# (bridge.yml) so they share an identical 3.44 source state -- the generated *.freezed.dart must
|
||||||
|
# compile against the same Flutter/freezed version the arm64 build resolves.
|
||||||
|
#
|
||||||
|
# Remove this script (and commit the changes) once upstream bumps Flutter across the board.
|
||||||
|
#
|
||||||
|
# Run from the repository root. sed is used (not a git-apply patch) because the checked-out
|
||||||
|
# sources are CRLF on the windows-11-arm runner; the substitutions below are anchor-free and
|
||||||
|
# therefore CRLF-safe.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly NO_MATCHES=0
|
||||||
|
readonly SINGLE_MATCH=1
|
||||||
|
readonly THEME_MATCHES=2
|
||||||
|
|
||||||
|
has_exact_count() {
|
||||||
|
local -r expected_count="$1"
|
||||||
|
local -r pattern="$2"
|
||||||
|
local -r file="$3"
|
||||||
|
local actual_count
|
||||||
|
[[ -r "$file" ]] || return 1
|
||||||
|
actual_count="$(grep -cF "$pattern" "$file" || true)"
|
||||||
|
[[ "$actual_count" -eq "$expected_count" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# The target background-color line must directly follow DialogThemeData in the selected range.
|
||||||
|
has_dialog_background_in_theme_range() {
|
||||||
|
local -r start_pattern="$1"
|
||||||
|
local -r end_pattern="$2"
|
||||||
|
local -r target_pattern="$3"
|
||||||
|
local -r file="$4"
|
||||||
|
awk -v start_pattern="$start_pattern" \
|
||||||
|
-v end_pattern="$end_pattern" \
|
||||||
|
-v target_pattern="$target_pattern" '
|
||||||
|
index($0, start_pattern) {
|
||||||
|
in_theme = 1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
in_theme && index($0, end_pattern) {
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
in_theme && index($0, "dialogTheme: DialogThemeData(") {
|
||||||
|
if (getline > 0) {
|
||||||
|
line = $0
|
||||||
|
sub(/\r$/, "", line)
|
||||||
|
sub(/^[[:space:]]+/, "", line)
|
||||||
|
matched = line == target_pattern
|
||||||
|
}
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
END {
|
||||||
|
exit matched ? 0 : 1
|
||||||
|
}
|
||||||
|
' "$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_patch_inputs() {
|
||||||
|
if [[ ! -f flutter/lib/common.dart || ! -r flutter/lib/common.dart ]]; then
|
||||||
|
echo "Flutter 3.44 source patch input is missing or unreadable: flutter/lib/common.dart" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ ! -f flutter/pubspec.yaml || ! -r flutter/pubspec.yaml ]]; then
|
||||||
|
echo "Flutter 3.44 source patch input is missing or unreadable: flutter/pubspec.yaml" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
is_complete_patch_state() {
|
||||||
|
has_exact_count "$THEME_MATCHES" 'dialogTheme: DialogThemeData(' flutter/lib/common.dart &&
|
||||||
|
has_exact_count "$THEME_MATCHES" 'tabBarTheme: const TabBarThemeData(' flutter/lib/common.dart &&
|
||||||
|
has_exact_count "$SINGLE_MATCH" 'backgroundColor: Colors.white,' flutter/lib/common.dart &&
|
||||||
|
has_exact_count "$SINGLE_MATCH" 'backgroundColor: Color(0xFF18191E),' flutter/lib/common.dart &&
|
||||||
|
has_exact_count "$SINGLE_MATCH" 'extended_text: 15.0.2' flutter/pubspec.yaml &&
|
||||||
|
has_exact_count "$SINGLE_MATCH" 'google_fonts: ^8.1.0' flutter/pubspec.yaml &&
|
||||||
|
has_exact_count "$NO_MATCHES" 'dialogTheme: DialogTheme(' flutter/lib/common.dart &&
|
||||||
|
has_exact_count "$NO_MATCHES" 'tabBarTheme: const TabBarTheme(' flutter/lib/common.dart &&
|
||||||
|
has_exact_count "$NO_MATCHES" 'extended_text: 14.0.0' flutter/pubspec.yaml &&
|
||||||
|
has_exact_count "$NO_MATCHES" 'google_fonts: ^6.2.1' flutter/pubspec.yaml &&
|
||||||
|
has_dialog_background_in_theme_range 'static ThemeData lightTheme = ThemeData(' \
|
||||||
|
'static ThemeData darkTheme = ThemeData(' 'backgroundColor: Colors.white,' \
|
||||||
|
flutter/lib/common.dart &&
|
||||||
|
has_dialog_background_in_theme_range 'static ThemeData darkTheme = ThemeData(' \
|
||||||
|
'scrollbarTheme: scrollbarThemeDark,' 'backgroundColor: Color(0xFF18191E),' \
|
||||||
|
flutter/lib/common.dart
|
||||||
|
}
|
||||||
|
|
||||||
|
is_unpatched_state() {
|
||||||
|
has_exact_count "$THEME_MATCHES" 'dialogTheme: DialogTheme(' flutter/lib/common.dart &&
|
||||||
|
has_exact_count "$THEME_MATCHES" 'tabBarTheme: const TabBarTheme(' flutter/lib/common.dart &&
|
||||||
|
has_exact_count "$SINGLE_MATCH" 'extended_text: 14.0.0' flutter/pubspec.yaml &&
|
||||||
|
has_exact_count "$SINGLE_MATCH" 'google_fonts: ^6.2.1' flutter/pubspec.yaml &&
|
||||||
|
has_exact_count "$NO_MATCHES" 'dialogTheme: DialogThemeData(' flutter/lib/common.dart &&
|
||||||
|
has_exact_count "$NO_MATCHES" 'tabBarTheme: const TabBarThemeData(' flutter/lib/common.dart &&
|
||||||
|
has_exact_count "$NO_MATCHES" 'backgroundColor: Colors.white,' flutter/lib/common.dart &&
|
||||||
|
has_exact_count "$NO_MATCHES" 'backgroundColor: Color(0xFF18191E),' flutter/lib/common.dart &&
|
||||||
|
has_exact_count "$NO_MATCHES" 'extended_text: 15.0.2' flutter/pubspec.yaml &&
|
||||||
|
has_exact_count "$NO_MATCHES" 'google_fonts: ^8.1.0' flutter/pubspec.yaml
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! validate_patch_inputs; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if is_complete_patch_state; then
|
||||||
|
echo "Flutter 3.44 source patches already applied."
|
||||||
|
git --no-pager diff -- flutter/lib/common.dart flutter/pubspec.yaml
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! is_unpatched_state; then
|
||||||
|
echo "Flutter 3.44 source patches are partially applied or their anchors have drifted." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ThemeData API renames (Flutter 3.27+):
|
||||||
|
sed -i 's/dialogTheme: DialogTheme(/dialogTheme: DialogThemeData(/g' flutter/lib/common.dart
|
||||||
|
sed -i 's/tabBarTheme: const TabBarTheme(/tabBarTheme: const TabBarThemeData(/g' flutter/lib/common.dart
|
||||||
|
sed -i '/static ThemeData lightTheme = ThemeData(/,/static ThemeData darkTheme = ThemeData(/s/dialogTheme: DialogThemeData(/dialogTheme: DialogThemeData(\
|
||||||
|
backgroundColor: Colors.white,/' flutter/lib/common.dart
|
||||||
|
sed -i '/static ThemeData darkTheme = ThemeData(/,/scrollbarTheme: scrollbarThemeDark,/s/dialogTheme: DialogThemeData(/dialogTheme: DialogThemeData(\
|
||||||
|
backgroundColor: Color(0xFF18191E),/' flutter/lib/common.dart
|
||||||
|
# Dependency bumps required by the newer Dart/Flutter:
|
||||||
|
sed -i 's/extended_text: 14.0.0/extended_text: 15.0.2/' flutter/pubspec.yaml
|
||||||
|
sed -i 's/google_fonts: \^6.2.1/google_fonts: ^8.1.0/' flutter/pubspec.yaml
|
||||||
|
|
||||||
|
# Fail loudly if any expected substitution did not produce the complete state.
|
||||||
|
if ! is_complete_patch_state; then
|
||||||
|
echo "Flutter 3.44 source patches did not produce the expected state." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git --no-pager diff -- flutter/lib/common.dart flutter/pubspec.yaml
|
||||||
51
.github/patches/apply_flutter_3.44_web_patches.sh
vendored
Executable file
51
.github/patches/apply_flutter_3.44_web_patches.sh
vendored
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Prepares a web build on Flutter 3.44.x. Companion to
|
||||||
|
# apply_flutter_3.44_source_patches.sh (which it runs first): the web target
|
||||||
|
# additionally needs qr_code_scanner's web implementation patched for the
|
||||||
|
# dart:ui platformViewRegistry removal, and flutter/web/fonts refreshed with
|
||||||
|
# the font paths the 3.44 engine requests for offline/air-gapped support
|
||||||
|
# (rustdesk-server-pro#996; see flutter/web/fonts/sync_fonts.py).
|
||||||
|
#
|
||||||
|
# Run from the repository root with Flutter 3.44.x on PATH, then build:
|
||||||
|
# bash .github/patches/apply_flutter_3.44_web_patches.sh
|
||||||
|
# (cd flutter && flutter build web --release) # or ./web/js/flutter_build.py
|
||||||
|
#
|
||||||
|
# Idempotent. To undo the source changes locally:
|
||||||
|
# git checkout -- flutter/lib/common.dart flutter/pubspec.yaml flutter/pubspec.lock
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
flutter --version | grep -q "Flutter 3\.44\." || {
|
||||||
|
echo "Flutter 3.44.x must be on PATH; found:" >&2
|
||||||
|
flutter --version | grep "^Flutter" >&2 || true
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Shared 3.44 source/pubspec patches own their complete-state validation.
|
||||||
|
bash .github/patches/apply_flutter_3.44_source_patches.sh
|
||||||
|
|
||||||
|
# Populate the pub cache with the 3.44 dependency resolution.
|
||||||
|
(cd flutter && flutter pub get)
|
||||||
|
|
||||||
|
# qr_code_scanner 1.0.1 (unmaintained) reads platformViewRegistry from
|
||||||
|
# dart:ui, which Flutter 3.44 removed; point it at dart:ui_web instead. The
|
||||||
|
# patched file also compiles on Flutter 3.24 (dart:ui_web exists there), so
|
||||||
|
# mutating the shared pub cache is safe for other local builds.
|
||||||
|
QR_WEB="${PUB_CACHE:-$HOME/.pub-cache}/hosted/pub.dev/qr_code_scanner-1.0.1/lib/src/web/flutter_qr_web.dart"
|
||||||
|
if ! grep -qF "dart:ui_web" "$QR_WEB"; then
|
||||||
|
sed -i.bak "s|import 'dart:ui' as ui;|import 'dart:ui' as ui; import 'dart:ui_web' as ui_web;|" "$QR_WEB"
|
||||||
|
rm -f "$QR_WEB.bak"
|
||||||
|
fi
|
||||||
|
if grep -qF "ui.platformViewRegistry" "$QR_WEB"; then
|
||||||
|
sed -i.bak "s|ui\.platformViewRegistry|ui_web.platformViewRegistry|g" "$QR_WEB"
|
||||||
|
rm -f "$QR_WEB.bak"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Mirror the fonts this engine version requests into flutter/web/fonts.
|
||||||
|
python3 flutter/web/fonts/sync_fonts.py
|
||||||
|
|
||||||
|
# Fail loudly if any expected state is missing:
|
||||||
|
grep -qF "import 'dart:ui' as ui; import 'dart:ui_web' as ui_web;" "$QR_WEB"
|
||||||
|
grep -qF "ui_web.platformViewRegistry" "$QR_WEB"
|
||||||
|
grep -qF 'google_fonts: ^8.1.0' flutter/pubspec.yaml
|
||||||
|
|
||||||
|
echo "Flutter 3.44 web patches applied."
|
||||||
40
.github/workflows/bridge.yml
vendored
40
.github/workflows/bridge.yml
vendored
@@ -7,7 +7,6 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_EXPAND_VERSION: "1.0.95"
|
CARGO_EXPAND_VERSION: "1.0.95"
|
||||||
FLUTTER_VERSION: "3.22.3"
|
|
||||||
FLUTTER_RUST_BRIDGE_VERSION: "1.80.1"
|
FLUTTER_RUST_BRIDGE_VERSION: "1.80.1"
|
||||||
RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503
|
RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503
|
||||||
|
|
||||||
@@ -18,14 +17,25 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
job:
|
job:
|
||||||
|
# Default bridge for every platform still on Flutter 3.24.5 (generated with 3.22.3).
|
||||||
- {
|
- {
|
||||||
target: x86_64-unknown-linux-gnu,
|
target: x86_64-unknown-linux-gnu,
|
||||||
os: ubuntu-22.04,
|
os: ubuntu-22.04,
|
||||||
extra-build-args: "",
|
extra-build-args: "",
|
||||||
|
flutter-version: "3.22.3",
|
||||||
|
artifact-name: "bridge-artifact",
|
||||||
|
}
|
||||||
|
# Dedicated bridge for the Windows arm64 build (Flutter 3.44); runs in parallel.
|
||||||
|
- {
|
||||||
|
target: x86_64-unknown-linux-gnu,
|
||||||
|
os: ubuntu-22.04,
|
||||||
|
extra-build-args: "",
|
||||||
|
flutter-version: "3.44.8",
|
||||||
|
artifact-name: "bridge-artifact-flutter-3.44",
|
||||||
}
|
}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
@@ -49,28 +59,28 @@ jobs:
|
|||||||
wget
|
wget
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@v1
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||||
with:
|
with:
|
||||||
toolchain: ${{ env.RUST_VERSION }}
|
toolchain: ${{ env.RUST_VERSION }}
|
||||||
targets: ${{ matrix.job.target }}
|
targets: ${{ matrix.job.target }}
|
||||||
components: "rustfmt"
|
components: "rustfmt"
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
with:
|
with:
|
||||||
prefix-key: bridge-${{ matrix.job.os }}
|
prefix-key: bridge-${{ matrix.job.os }}
|
||||||
|
|
||||||
- name: Cache Bridge
|
- name: Cache Bridge
|
||||||
id: cache-bridge
|
id: cache-bridge
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@6f8efc29b200d32929f49075959781ed54ec270c # v3
|
||||||
with:
|
with:
|
||||||
path: /tmp/flutter_rust_bridge
|
path: /tmp/flutter_rust_bridge
|
||||||
key: vcpkg-${{ matrix.job.arch }}
|
key: bridge-${{ matrix.job.flutter-version }}
|
||||||
|
|
||||||
- name: Install flutter
|
- name: Install flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ matrix.job.flutter-version }}
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Install flutter rust bridge deps
|
- name: Install flutter rust bridge deps
|
||||||
@@ -78,7 +88,15 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cargo install cargo-expand --version ${{ env.CARGO_EXPAND_VERSION }} --locked
|
cargo install cargo-expand --version ${{ env.CARGO_EXPAND_VERSION }} --locked
|
||||||
cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" --locked
|
cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" --locked
|
||||||
pushd flutter && sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' pubspec.yaml && flutter pub get && popd
|
if [[ "${{ matrix.job.flutter-version }}" == "3.22.3" ]]; then
|
||||||
|
# Default Flutter 3.22.3: extended_text 14 needs a newer Dart, so downgrade for resolution.
|
||||||
|
sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' flutter/pubspec.yaml
|
||||||
|
else
|
||||||
|
# Flutter 3.44 bridge for Windows arm64: match that build's source/pubspec state so the
|
||||||
|
# generated *.freezed.dart compiles against the same Flutter/freezed it resolves.
|
||||||
|
bash .github/patches/apply_flutter_3.44_source_patches.sh
|
||||||
|
fi
|
||||||
|
pushd flutter && flutter pub get && popd
|
||||||
|
|
||||||
- name: Run flutter rust bridge
|
- name: Run flutter rust bridge
|
||||||
run: |
|
run: |
|
||||||
@@ -86,9 +104,9 @@ jobs:
|
|||||||
cp ./flutter/macos/Runner/bridge_generated.h ./flutter/ios/Runner/bridge_generated.h
|
cp ./flutter/macos/Runner/bridge_generated.h ./flutter/ios/Runner/bridge_generated.h
|
||||||
|
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@master
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: bridge-artifact
|
name: ${{ matrix.job.artifact-name }}
|
||||||
path: |
|
path: |
|
||||||
./src/bridge_generated.rs
|
./src/bridge_generated.rs
|
||||||
./src/bridge_generated.io.rs
|
./src/bridge_generated.io.rs
|
||||||
|
|||||||
43
.github/workflows/ci.yml
vendored
43
.github/workflows/ci.yml
vendored
@@ -29,13 +29,13 @@ jobs:
|
|||||||
# name: Ensure 'cargo fmt' has been run
|
# name: Ensure 'cargo fmt' has been run
|
||||||
# runs-on: ubuntu-20.04
|
# runs-on: ubuntu-20.04
|
||||||
# steps:
|
# steps:
|
||||||
# - uses: actions-rs/toolchain@v1
|
# - uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
|
||||||
# with:
|
# with:
|
||||||
# toolchain: stable
|
# toolchain: stable
|
||||||
# default: true
|
# default: true
|
||||||
# profile: minimal
|
# profile: minimal
|
||||||
# components: rustfmt
|
# components: rustfmt
|
||||||
# - uses: actions/checkout@v3
|
# - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||||
# - run: cargo fmt -- --check
|
# - run: cargo fmt -- --check
|
||||||
|
|
||||||
# min_version:
|
# min_version:
|
||||||
@@ -43,24 +43,24 @@ jobs:
|
|||||||
# runs-on: ubuntu-20.04
|
# runs-on: ubuntu-20.04
|
||||||
# steps:
|
# steps:
|
||||||
# - name: Checkout source code
|
# - name: Checkout source code
|
||||||
# uses: actions/checkout@v3
|
# uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||||
# with:
|
# with:
|
||||||
# submodules: recursive
|
# submodules: recursive
|
||||||
|
|
||||||
# - name: Install rust toolchain (v${{ env.MIN_SUPPORTED_RUST_VERSION }})
|
# - name: Install rust toolchain (v${{ env.MIN_SUPPORTED_RUST_VERSION }})
|
||||||
# uses: actions-rs/toolchain@v1
|
# uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
|
||||||
# with:
|
# with:
|
||||||
# toolchain: ${{ env.MIN_SUPPORTED_RUST_VERSION }}
|
# toolchain: ${{ env.MIN_SUPPORTED_RUST_VERSION }}
|
||||||
# default: true
|
# default: true
|
||||||
# profile: minimal # minimal component installation (ie, no documentation)
|
# profile: minimal # minimal component installation (ie, no documentation)
|
||||||
# components: clippy
|
# components: clippy
|
||||||
# - name: Run clippy (on minimum supported rust version to prevent warnings we can't fix)
|
# - name: Run clippy (on minimum supported rust version to prevent warnings we can't fix)
|
||||||
# uses: actions-rs/cargo@v1
|
# uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
||||||
# with:
|
# with:
|
||||||
# command: clippy
|
# command: clippy
|
||||||
# args: --locked --all-targets --all-features -- --allow clippy::unknown_clippy_lints
|
# args: --locked --all-targets --all-features -- --allow clippy::unknown_clippy_lints
|
||||||
# - name: Run tests
|
# - name: Run tests
|
||||||
# uses: actions-rs/cargo@v1
|
# uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
||||||
# with:
|
# with:
|
||||||
# command: test
|
# command: test
|
||||||
# args: --locked
|
# args: --locked
|
||||||
@@ -81,18 +81,33 @@ jobs:
|
|||||||
# - { target: x86_64-apple-darwin , os: macos-10.15 }
|
# - { target: x86_64-apple-darwin , os: macos-10.15 }
|
||||||
# - { target: x86_64-pc-windows-gnu , os: windows-2022 }
|
# - { target: x86_64-pc-windows-gnu , os: windows-2022 }
|
||||||
# - { target: x86_64-pc-windows-msvc , os: windows-2022 }
|
# - { target: x86_64-pc-windows-msvc , os: windows-2022 }
|
||||||
- { target: x86_64-unknown-linux-gnu , os: ubuntu-22.04 }
|
# - { target: aarch64-pc-windows-msvc , os: windows-11-arm }
|
||||||
|
- { target: x86_64-unknown-linux-gnu , os: ubuntu-24.04 }
|
||||||
# - { target: x86_64-unknown-linux-musl , os: ubuntu-20.04, use-cross: true }
|
# - { target: x86_64-unknown-linux-musl , os: ubuntu-20.04, use-cross: true }
|
||||||
steps:
|
steps:
|
||||||
|
- name: Free Disk Space (Ubuntu)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
# jlumbroso/free-disk-space@v1.3.1 is used in .github\workflows\flutter-build.yml
|
||||||
|
# But pinning to a specific version to avoid unexpected issues is preferred.
|
||||||
|
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
|
||||||
|
with:
|
||||||
|
tool-cache: false
|
||||||
|
android: true
|
||||||
|
dotnet: true
|
||||||
|
haskell: true
|
||||||
|
large-packages: false
|
||||||
|
docker-images: true
|
||||||
|
swap-storage: false
|
||||||
|
|
||||||
- name: Export GitHub Actions cache environment variables
|
- name: Export GitHub Actions cache environment variables
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||||
|
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
@@ -131,7 +146,7 @@ jobs:
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
- name: Setup vcpkg with Github Actions binary cache
|
- name: Setup vcpkg with Github Actions binary cache
|
||||||
uses: lukka/run-vcpkg@v11
|
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||||
with:
|
with:
|
||||||
vcpkgDirectory: /opt/artifacts/vcpkg
|
vcpkgDirectory: /opt/artifacts/vcpkg
|
||||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||||
@@ -142,7 +157,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@v1
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||||
with:
|
with:
|
||||||
toolchain: stable
|
toolchain: stable
|
||||||
targets: ${{ matrix.job.target }}
|
targets: ${{ matrix.job.target }}
|
||||||
@@ -158,10 +173,10 @@ jobs:
|
|||||||
cargo -V
|
cargo -V
|
||||||
rustc -V
|
rustc -V
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
uses: actions-rs/cargo@v1
|
uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
||||||
with:
|
with:
|
||||||
use-cross: ${{ matrix.job.use-cross }}
|
use-cross: ${{ matrix.job.use-cross }}
|
||||||
command: build
|
command: build
|
||||||
@@ -229,7 +244,7 @@ jobs:
|
|||||||
echo "CARGO_TEST_OPTIONS=${CARGO_TEST_OPTIONS}" >> $GITHUB_OUTPUT
|
echo "CARGO_TEST_OPTIONS=${CARGO_TEST_OPTIONS}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
uses: actions-rs/cargo@v1
|
uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
||||||
with:
|
with:
|
||||||
use-cross: ${{ matrix.job.use-cross }}
|
use-cross: ${{ matrix.job.use-cross }}
|
||||||
command: test
|
command: test
|
||||||
|
|||||||
4
.github/workflows/clear-cache.yml
vendored
4
.github/workflows/clear-cache.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Clear cache
|
- name: Clear cache
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
console.log("About to clear")
|
console.log("About to clear")
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
console.log("Clear completed")
|
console.log("Clear completed")
|
||||||
|
|
||||||
- name: Purge cache # Above seems not clear thouroughly, so add this to double clear
|
- name: Purge cache # Above seems not clear thouroughly, so add this to double clear
|
||||||
uses: MyAlbum/purge-cache@v2
|
uses: MyAlbum/purge-cache@881eb5957687193fa612bf74c0042adc78ea5e54 # v2
|
||||||
with:
|
with:
|
||||||
accessed: true # Purge caches by their last accessed time (default)
|
accessed: true # Purge caches by their last accessed time (default)
|
||||||
created: false # Purge caches by their created time (default)
|
created: false # Purge caches by their created time (default)
|
||||||
|
|||||||
2
.github/workflows/fdroid.yml
vendored
2
.github/workflows/fdroid.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Publish RustDesk version file
|
- name: Publish RustDesk version file
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: "fdroid-version"
|
tag_name: "fdroid-version"
|
||||||
|
|||||||
730
.github/workflows/flutter-build.yml
vendored
730
.github/workflows/flutter-build.yml
vendored
File diff suppressed because it is too large
Load Diff
36
.github/workflows/playground.yml
vendored
36
.github/workflows/playground.yml
vendored
@@ -17,7 +17,7 @@ env:
|
|||||||
TAG_NAME: "nightly"
|
TAG_NAME: "nightly"
|
||||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||||
VERSION: "1.4.2"
|
VERSION: "1.4.9"
|
||||||
NDK_VERSION: "r26d"
|
NDK_VERSION: "r26d"
|
||||||
#signing keys env variable checks
|
#signing keys env variable checks
|
||||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||||
@@ -79,21 +79,21 @@ jobs:
|
|||||||
}
|
}
|
||||||
steps:
|
steps:
|
||||||
- name: Export GitHub Actions cache environment variables
|
- name: Export GitHub Actions cache environment variables
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||||
|
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||||
with:
|
with:
|
||||||
ref: ${{ matrix.job.ref }}
|
ref: ${{ matrix.job.ref }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Import the codesign cert
|
- name: Import the codesign cert
|
||||||
if: env.MACOS_P12_BASE64 != null
|
if: env.MACOS_P12_BASE64 != null
|
||||||
uses: apple-actions/import-codesign-certs@v1
|
uses: apple-actions/import-codesign-certs@253ddeeac23f2bdad1646faac5c8c2832e800071 # v1
|
||||||
with:
|
with:
|
||||||
p12-file-base64: ${{ secrets.MACOS_P12_BASE64 }}
|
p12-file-base64: ${{ secrets.MACOS_P12_BASE64 }}
|
||||||
p12-password: ${{ secrets.MACOS_P12_PASSWORD }}
|
p12-password: ${{ secrets.MACOS_P12_PASSWORD }}
|
||||||
@@ -107,7 +107,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Import notarize key
|
- name: Import notarize key
|
||||||
if: env.MACOS_P12_BASE64 != null
|
if: env.MACOS_P12_BASE64 != null
|
||||||
uses: timheuer/base64-to-file@v1.2
|
uses: timheuer/base64-to-file@adaa40c0c581f276132199d4cf60afa07ce60eac # v1.2
|
||||||
with:
|
with:
|
||||||
# https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling
|
# https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling
|
||||||
fileName: rustdesk.json
|
fileName: rustdesk.json
|
||||||
@@ -129,19 +129,19 @@ jobs:
|
|||||||
brew install llvm create-dmg nasm pkg-config
|
brew install llvm create-dmg nasm pkg-config
|
||||||
|
|
||||||
- name: Install flutter
|
- name: Install flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: ${{ matrix.job.flutter }}
|
flutter-version: ${{ matrix.job.flutter }}
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@v1
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||||
with:
|
with:
|
||||||
toolchain: ${{ env.RUST_VERSION }}
|
toolchain: ${{ env.RUST_VERSION }}
|
||||||
targets: ${{ matrix.job.target }}
|
targets: ${{ matrix.job.target }}
|
||||||
components: "rustfmt"
|
components: "rustfmt"
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
with:
|
with:
|
||||||
prefix-key: ${{ matrix.job.os }}
|
prefix-key: ${{ matrix.job.os }}
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ jobs:
|
|||||||
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h
|
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h
|
||||||
|
|
||||||
- name: Setup vcpkg with Github Actions binary cache
|
- name: Setup vcpkg with Github Actions binary cache
|
||||||
uses: lukka/run-vcpkg@v11
|
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||||
with:
|
with:
|
||||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ jobs:
|
|||||||
$VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed"
|
$VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed"
|
||||||
|
|
||||||
- name: Restore from cache and install vcpkg
|
- name: Restore from cache and install vcpkg
|
||||||
uses: lukka/run-vcpkg@v7
|
uses: lukka/run-vcpkg@8a5116de2b552d6fc8894e9774aacaf2e5db4823 # v7 2026-05-26
|
||||||
if: false
|
if: false
|
||||||
with:
|
with:
|
||||||
setupOnly: true
|
setupOnly: true
|
||||||
@@ -222,7 +222,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
- name: Publish DMG package
|
- name: Publish DMG package
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ env.TAG_NAME }}
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
@@ -247,7 +247,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||||
with:
|
with:
|
||||||
ref: ${{ matrix.job.ref }}
|
ref: ${{ matrix.job.ref }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
@@ -290,13 +290,13 @@ jobs:
|
|||||||
wget
|
wget
|
||||||
|
|
||||||
- name: Install flutter
|
- name: Install flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@v1
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||||
with:
|
with:
|
||||||
toolchain: ${{ env.RUST_VERSION }}
|
toolchain: ${{ env.RUST_VERSION }}
|
||||||
components: "rustfmt"
|
components: "rustfmt"
|
||||||
@@ -310,14 +310,14 @@ jobs:
|
|||||||
pushd flutter ; flutter pub get ; popd
|
pushd flutter ; flutter pub get ; popd
|
||||||
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart
|
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart
|
||||||
|
|
||||||
- uses: nttld/setup-ndk@v1
|
- uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
|
||||||
id: setup-ndk
|
id: setup-ndk
|
||||||
with:
|
with:
|
||||||
ndk-version: ${{ env.NDK_VERSION }}
|
ndk-version: ${{ env.NDK_VERSION }}
|
||||||
add-to-path: true
|
add-to-path: true
|
||||||
|
|
||||||
- name: Setup vcpkg with Github Actions binary cache
|
- name: Setup vcpkg with Github Actions binary cache
|
||||||
uses: lukka/run-vcpkg@v11
|
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||||
with:
|
with:
|
||||||
vcpkgDirectory: /opt/artifacts/vcpkg
|
vcpkgDirectory: /opt/artifacts/vcpkg
|
||||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||||
@@ -395,7 +395,7 @@ jobs:
|
|||||||
mkdir -p signed-apk; pushd signed-apk
|
mkdir -p signed-apk; pushd signed-apk
|
||||||
mv ../rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk ./rustdesk-test-${{ matrix.job.ref }}-${{ matrix.job.ndk }}.apk
|
mv ../rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk ./rustdesk-test-${{ matrix.job.ref }}-${{ matrix.job.ndk }}.apk
|
||||||
|
|
||||||
- uses: r0adkll/sign-android-release@v1
|
- uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
|
||||||
name: Sign app APK
|
name: Sign app APK
|
||||||
if: env.ANDROID_SIGNING_KEY != null
|
if: env.ANDROID_SIGNING_KEY != null
|
||||||
id: sign-rustdesk
|
id: sign-rustdesk
|
||||||
@@ -410,7 +410,7 @@ jobs:
|
|||||||
BUILD_TOOLS_VERSION: "30.0.2"
|
BUILD_TOOLS_VERSION: "30.0.2"
|
||||||
|
|
||||||
- name: Publish signed apk package
|
- name: Publish signed apk package
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ env.TAG_NAME }}
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
|
|||||||
@@ -39,22 +39,21 @@ jobs:
|
|||||||
build_output_dir: RustDeskTempTopMostWindow/WindowInjection/${{ inputs.platform }}/${{ inputs.configuration }}
|
build_output_dir: RustDeskTempTopMostWindow/WindowInjection/${{ inputs.platform }}/${{ inputs.configuration }}
|
||||||
steps:
|
steps:
|
||||||
- name: Add MSBuild to PATH
|
- name: Add MSBuild to PATH
|
||||||
uses: microsoft/setup-msbuild@v2
|
uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2
|
||||||
|
|
||||||
- name: Download the source code
|
- name: Download the source code
|
||||||
run: |
|
run: |
|
||||||
git clone https://github.com/rustdesk-org/RustDeskTempTopMostWindow RustDeskTempTopMostWindow
|
git clone https://github.com/rustdesk-org/RustDeskTempTopMostWindow RustDeskTempTopMostWindow
|
||||||
|
|
||||||
# Build. commit 53b548a5398624f7149a382000397993542ad796 is tag v0.3
|
|
||||||
- name: Build the project
|
- name: Build the project
|
||||||
run: |
|
run: |
|
||||||
cd RustDeskTempTopMostWindow && git checkout 53b548a5398624f7149a382000397993542ad796
|
cd RustDeskTempTopMostWindow && git checkout ecd8d6a139eee76845ea66423fb739af450fda90
|
||||||
msbuild ${{ env.project_path }} -p:Configuration=${{ inputs.configuration }} -p:Platform=${{ inputs.platform }} /p:TargetVersion=${{ inputs.target_version }}
|
msbuild ${{ env.project_path }} -p:Configuration=${{ inputs.configuration }} -p:Platform=${{ inputs.platform }} /p:TargetVersion=${{ inputs.target_version }}
|
||||||
|
|
||||||
- name: Archive build artifacts
|
- name: Archive build artifacts
|
||||||
uses: actions/upload-artifact@master
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
if: ${{ inputs.upload-artifact }}
|
if: ${{ inputs.upload-artifact }}
|
||||||
with:
|
with:
|
||||||
name: topmostwindow-artifacts
|
name: topmostwindow-artifacts-${{ inputs.platform }}
|
||||||
path: |
|
path: |
|
||||||
./${{ env.build_output_dir }}/WindowInjection.dll
|
./${{ env.build_output_dir }}/WindowInjection.dll
|
||||||
|
|||||||
71
.github/workflows/update-webpki-roots.yml
vendored
Normal file
71
.github/workflows/update-webpki-roots.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
name: Update webpki-roots
|
||||||
|
|
||||||
|
# Weekly refresh of the compiled-in TLS root certificates (the webpki-roots
|
||||||
|
# crate, a snapshot of the Mozilla root store). Roots are otherwise frozen at
|
||||||
|
# whatever Cargo.lock pins, so old builds miss newly added CAs and keep
|
||||||
|
# removed (distrusted) ones. Changes go through a PR on purpose: added or
|
||||||
|
# removed roots should be reviewed, not silently baked into releases.
|
||||||
|
#
|
||||||
|
# Note: PRs created with the default GITHUB_TOKEN do not trigger other
|
||||||
|
# workflows (GitHub limitation). Close and reopen the PR, or push to its
|
||||||
|
# branch, to run CI on it.
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# A manual dispatch overlapping the weekly run would race it force-pushing
|
||||||
|
# the same branch; queue instead of overlapping, and never cancel a run
|
||||||
|
# that may have already pushed.
|
||||||
|
concurrency:
|
||||||
|
group: update-webpki-roots
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
env:
|
||||||
|
BRANCH: auto-update-webpki-roots
|
||||||
|
steps:
|
||||||
|
- name: Checkout source code
|
||||||
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
|
||||||
|
- name: Update webpki-roots in all lockfiles
|
||||||
|
id: update
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
git ls-files -z '*Cargo.lock' | while IFS= read -r -d '' lock; do
|
||||||
|
dir=$(dirname "$lock")
|
||||||
|
for v in $(sed -n '/name = "webpki-roots"/{n;s/.*version = "\(.*\)"/\1/p;}' "$lock" | sort -u); do
|
||||||
|
echo "updating webpki-roots@$v in $dir"
|
||||||
|
(cd "$dir" && cargo update -p "webpki-roots@$v")
|
||||||
|
done
|
||||||
|
done
|
||||||
|
if git diff --quiet -- '*Cargo.lock'; then
|
||||||
|
echo "changed=0" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "changed=1" >> "$GITHUB_OUTPUT"
|
||||||
|
git --no-pager diff -- '*Cargo.lock'
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create pull request
|
||||||
|
if: steps.update.outputs.changed == '1'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git checkout -B "$BRANCH"
|
||||||
|
git add -- '*Cargo.lock'
|
||||||
|
git commit -m "chore: update webpki-roots to latest Mozilla root store"
|
||||||
|
git push -f origin "$BRANCH"
|
||||||
|
if [ -z "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[].number')" ]; then
|
||||||
|
gh pr create \
|
||||||
|
--title "chore: update webpki-roots to latest Mozilla root store" \
|
||||||
|
--body "Automated weekly refresh of the compiled-in TLS root certificates (webpki-roots). Please review the added/removed roots. CI does not run automatically on PRs created by GITHUB_TOKEN; close and reopen this PR to trigger it."
|
||||||
|
fi
|
||||||
15
.github/workflows/winget.yml
vendored
15
.github/workflows/winget.yml
vendored
@@ -1,15 +0,0 @@
|
|||||||
name: Publish to WinGet
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [released]
|
|
||||||
workflow_dispatch:
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: vedantmgoyal9/winget-releaser@main
|
|
||||||
with:
|
|
||||||
identifier: RustDesk.RustDesk
|
|
||||||
version: "1.4.2"
|
|
||||||
release-tag: "1.4.2"
|
|
||||||
token: ${{ secrets.WINGET_TOKEN }}
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,6 +3,7 @@
|
|||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.env
|
||||||
libsciter-gtk.so
|
libsciter-gtk.so
|
||||||
src/ui/inline.rs
|
src/ui/inline.rs
|
||||||
extractor
|
extractor
|
||||||
@@ -55,3 +56,5 @@ vcpkg_installed
|
|||||||
flutter/lib/generated_plugin_registrant.dart
|
flutter/lib/generated_plugin_registrant.dart
|
||||||
libsciter.dylib
|
libsciter.dylib
|
||||||
flutter/web/
|
flutter/web/
|
||||||
|
# libdrmtap is cloned at build time by build.py (not a submodule)
|
||||||
|
/third_party/libdrmtap/
|
||||||
|
|||||||
105
AGENTS.md
Normal file
105
AGENTS.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# RustDesk Guide
|
||||||
|
|
||||||
|
## Project Layout
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
* `src/` Rust app
|
||||||
|
* `src/server/` audio / clipboard / input / video / network
|
||||||
|
* `src/platform/` platform-specific code
|
||||||
|
* `src/ui/` legacy Sciter UI (deprecated)
|
||||||
|
* `flutter/` current UI
|
||||||
|
* `libs/hbb_common/` config / proto / shared utils
|
||||||
|
* `libs/scrap/` screen capture
|
||||||
|
* `libs/enigo/` input control
|
||||||
|
* `libs/clipboard/` clipboard
|
||||||
|
* `libs/hbb_common/src/config.rs` all options
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
- **Remote Desktop Protocol**: Custom protocol implemented in `src/rendezvous_mediator.rs` for communicating with rustdesk-server
|
||||||
|
- **Screen Capture**: Platform-specific screen capture in `libs/scrap/`
|
||||||
|
- **Input Handling**: Cross-platform input simulation in `libs/enigo/`
|
||||||
|
- **Audio/Video Services**: Real-time audio/video streaming in `src/server/`
|
||||||
|
- **File Transfer**: Secure file transfer implementation in `libs/hbb_common/`
|
||||||
|
|
||||||
|
### UI Architecture
|
||||||
|
- **Legacy UI**: Sciter-based (deprecated) - files in `src/ui/`
|
||||||
|
- **Modern UI**: Flutter-based - files in `flutter/`
|
||||||
|
- Desktop: `flutter/lib/desktop/`
|
||||||
|
- Mobile: `flutter/lib/mobile/`
|
||||||
|
- Shared: `flutter/lib/common/` and `flutter/lib/models/`
|
||||||
|
|
||||||
|
## Rust Rules
|
||||||
|
|
||||||
|
* Avoid `unwrap()` / `expect()` in production code.
|
||||||
|
* Exceptions:
|
||||||
|
|
||||||
|
* tests;
|
||||||
|
* lock acquisition where failure means poisoning, not normal control flow.
|
||||||
|
* Otherwise prefer `Result` + `?` or explicit handling.
|
||||||
|
* Do not ignore errors silently.
|
||||||
|
* Avoid unnecessary `.clone()`.
|
||||||
|
* Prefer borrowing when practical.
|
||||||
|
* Do not add dependencies unless needed.
|
||||||
|
* Keep code simple and idiomatic.
|
||||||
|
|
||||||
|
## Tokio Rules
|
||||||
|
|
||||||
|
* Assume a Tokio runtime already exists.
|
||||||
|
* Never create nested runtimes.
|
||||||
|
* Never call `Runtime::block_on()` inside Tokio / async code.
|
||||||
|
* Do not hide runtime creation inside helpers or libraries.
|
||||||
|
* Do not hold locks across `.await`.
|
||||||
|
* Prefer `.await`, `tokio::spawn`, channels.
|
||||||
|
* Use `spawn_blocking` or dedicated threads for blocking work.
|
||||||
|
* Do not use `std::thread::sleep()` in async code.
|
||||||
|
|
||||||
|
## Editing Hygiene
|
||||||
|
|
||||||
|
* Change only what is required.
|
||||||
|
* Prefer the smallest valid diff.
|
||||||
|
* Do not refactor unrelated code.
|
||||||
|
* Do not make formatting-only changes.
|
||||||
|
* Keep naming/style consistent with nearby code.
|
||||||
|
|
||||||
|
### Comments
|
||||||
|
|
||||||
|
* Keep them short: one line by default, three at most.
|
||||||
|
* Say **why**, never what. If the code already says it, delete the comment.
|
||||||
|
* A comment must never be longer than the code it describes.
|
||||||
|
* Applies to YAML, shell and Python too, not just Rust.
|
||||||
|
|
||||||
|
### Be minimally invasive
|
||||||
|
|
||||||
|
* Prefer purely additive changes: layer new (`#[cfg]`-gated) blocks or new functions around existing code instead of restructuring it. The ideal diff for a fix adds lines and modifies/deletes none.
|
||||||
|
* Do not extract or reshape existing code just to enable your new code; look for a mechanism that leaves existing lines untouched (e.g. hide/show an existing object instead of refactoring its construction into a helper for rebuilding).
|
||||||
|
* Put new logic in self-contained functions in the module it belongs to (platform-specific logic in `src/platform/`, with `use` inside the function body to avoid churning shared import blocks). Call sites in shared files (`src/tray.rs`, `src/core_main.rs`, `src/server/connection.rs`, …) should be thin one-line hooks.
|
||||||
|
|
||||||
|
## Localization (`src/lang/*.rs`)
|
||||||
|
|
||||||
|
Each file is a `HashMap<key, translation>`. Layout:
|
||||||
|
|
||||||
|
* `template.rs` is the master list of every key. **Never edit it** as part of translation work.
|
||||||
|
* `en.rs` holds only the keys whose English display text differs from the key itself.
|
||||||
|
* Every other file (`de.rs`, `fr.rs`, …) carries the full key set; an untranslated entry has an empty value: `("key", "")`.
|
||||||
|
|
||||||
|
### Finding the English source for a key
|
||||||
|
|
||||||
|
When filling an empty entry, determine the source English text with this rule:
|
||||||
|
|
||||||
|
* If `key` exists in `en.rs` **with a non-empty value**, that value is the source text (look it up in `en.rs`).
|
||||||
|
* Otherwise the **key string itself is the source text** (the key is already plain English).
|
||||||
|
|
||||||
|
Then translate that source into the file's target language (infer the language from the file's existing non-empty entries / filename).
|
||||||
|
|
||||||
|
### Translation hygiene
|
||||||
|
|
||||||
|
* Only fill empty values. Never change keys, and never touch existing non-empty translations.
|
||||||
|
* Preserve placeholders (`{}`) and escape sequences (`\n`, `\"`) exactly as in the source.
|
||||||
|
* Do not translate brand or technical tokens: `RustDesk`, `Socks5`, `TLS`, `UAC`, `Wayland`, `X11`, `TCP`, `UDP`, `2FA`, `RDP`, `D3D`, etc.
|
||||||
|
* Copy URL values (e.g. `doc_*` keys) verbatim from `en.rs`.
|
||||||
|
|
||||||
|
### Adding new keys (feature work)
|
||||||
|
|
||||||
|
* New English-text keys use sentence case, not Title Case: `Use ID whitelisting`, **not** `Use ID Whitelisting`. Acronyms (ID, IP, 2FA…) stay uppercase. Legacy Title-Case keys (e.g. `Use IP Whitelisting`) stay as-is — do not rename them.
|
||||||
|
* Since the key itself is the English display text, a sentence-case key usually needs **no** `en.rs` entry; add one only when the display text must differ from the key (e.g. `*_tip` keys).
|
||||||
|
* Append each new key to `template.rs` (with `""`) and to every `src/lang/*.rs` file (translated, or `""` if unsure), at the end of the list.
|
||||||
92
CLAUDE.md
92
CLAUDE.md
@@ -1,91 +1 @@
|
|||||||
# CLAUDE.md
|
AGENTS.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Development Commands
|
|
||||||
|
|
||||||
### Build Commands
|
|
||||||
- `cargo run` - Build and run the desktop application (requires libsciter library)
|
|
||||||
- `python3 build.py --flutter` - Build Flutter version (desktop)
|
|
||||||
- `python3 build.py --flutter --release` - Build Flutter version in release mode
|
|
||||||
- `python3 build.py --hwcodec` - Build with hardware codec support
|
|
||||||
- `python3 build.py --vram` - Build with VRAM feature (Windows only)
|
|
||||||
- `cargo build --release` - Build Rust binary in release mode
|
|
||||||
- `cargo build --features hwcodec` - Build with specific features
|
|
||||||
|
|
||||||
### Flutter Mobile Commands
|
|
||||||
- `cd flutter && flutter build android` - Build Android APK
|
|
||||||
- `cd flutter && flutter build ios` - Build iOS app
|
|
||||||
- `cd flutter && flutter run` - Run Flutter app in development mode
|
|
||||||
- `cd flutter && flutter test` - Run Flutter tests
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- `cargo test` - Run Rust tests
|
|
||||||
- `cd flutter && flutter test` - Run Flutter tests
|
|
||||||
|
|
||||||
### Platform-Specific Build Scripts
|
|
||||||
- `flutter/build_android.sh` - Android build script
|
|
||||||
- `flutter/build_ios.sh` - iOS build script
|
|
||||||
- `flutter/build_fdroid.sh` - F-Droid build script
|
|
||||||
|
|
||||||
## Project Architecture
|
|
||||||
|
|
||||||
### Directory Structure
|
|
||||||
- **`src/`** - Main Rust application code
|
|
||||||
- `src/ui/` - Legacy Sciter UI (deprecated, use Flutter instead)
|
|
||||||
- `src/server/` - Audio/clipboard/input/video services and network connections
|
|
||||||
- `src/client.rs` - Peer connection handling
|
|
||||||
- `src/platform/` - Platform-specific code
|
|
||||||
- **`flutter/`** - Flutter UI code for desktop and mobile
|
|
||||||
- **`libs/`** - Core libraries
|
|
||||||
- `libs/hbb_common/` - Video codec, config, network wrapper, protobuf, file transfer utilities
|
|
||||||
- `libs/scrap/` - Screen capture functionality
|
|
||||||
- `libs/enigo/` - Platform-specific keyboard/mouse control
|
|
||||||
- `libs/clipboard/` - Cross-platform clipboard implementation
|
|
||||||
|
|
||||||
### Key Components
|
|
||||||
- **Remote Desktop Protocol**: Custom protocol implemented in `src/rendezvous_mediator.rs` for communicating with rustdesk-server
|
|
||||||
- **Screen Capture**: Platform-specific screen capture in `libs/scrap/`
|
|
||||||
- **Input Handling**: Cross-platform input simulation in `libs/enigo/`
|
|
||||||
- **Audio/Video Services**: Real-time audio/video streaming in `src/server/`
|
|
||||||
- **File Transfer**: Secure file transfer implementation in `libs/hbb_common/`
|
|
||||||
|
|
||||||
### UI Architecture
|
|
||||||
- **Legacy UI**: Sciter-based (deprecated) - files in `src/ui/`
|
|
||||||
- **Modern UI**: Flutter-based - files in `flutter/`
|
|
||||||
- Desktop: `flutter/lib/desktop/`
|
|
||||||
- Mobile: `flutter/lib/mobile/`
|
|
||||||
- Shared: `flutter/lib/common/` and `flutter/lib/models/`
|
|
||||||
|
|
||||||
## Important Build Notes
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
- Requires vcpkg for C++ dependencies: `libvpx`, `libyuv`, `opus`, `aom`
|
|
||||||
- Set `VCPKG_ROOT` environment variable
|
|
||||||
- Download appropriate Sciter library for legacy UI support
|
|
||||||
|
|
||||||
### Ignore Patterns
|
|
||||||
When working with files, ignore these directories:
|
|
||||||
- `target/` - Rust build artifacts
|
|
||||||
- `flutter/build/` - Flutter build output
|
|
||||||
- `flutter/.dart_tool/` - Flutter tooling files
|
|
||||||
|
|
||||||
### Cross-Platform Considerations
|
|
||||||
- Windows builds require additional DLLs and virtual display drivers
|
|
||||||
- macOS builds need proper signing and notarization for distribution
|
|
||||||
- Linux builds support multiple package formats (deb, rpm, AppImage)
|
|
||||||
- Mobile builds require platform-specific toolchains (Android SDK, Xcode)
|
|
||||||
|
|
||||||
### Feature Flags
|
|
||||||
- `hwcodec` - Hardware video encoding/decoding
|
|
||||||
- `vram` - VRAM optimization (Windows only)
|
|
||||||
- `flutter` - Enable Flutter UI
|
|
||||||
- `unix-file-copy-paste` - Unix file clipboard support
|
|
||||||
- `screencapturekit` - macOS ScreenCaptureKit (macOS only)
|
|
||||||
|
|
||||||
### Config
|
|
||||||
All configurations or options are under `libs/hbb_common/src/config.rs` file, 4 types:
|
|
||||||
- Settings
|
|
||||||
- Local
|
|
||||||
- Display
|
|
||||||
- Built-in
|
|
||||||
|
|||||||
1900
Cargo.lock
generated
1900
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
55
Cargo.toml
55
Cargo.toml
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustdesk"
|
name = "rustdesk"
|
||||||
version = "1.4.2"
|
version = "1.4.9"
|
||||||
authors = ["rustdesk <info@rustdesk.com>"]
|
authors = ["rustdesk <info@rustdesk.com>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
build= "build.rs"
|
build= "build.rs"
|
||||||
@@ -22,7 +22,6 @@ path = "src/service.rs"
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
inline = []
|
inline = []
|
||||||
cli = []
|
|
||||||
use_samplerate = ["samplerate"]
|
use_samplerate = ["samplerate"]
|
||||||
use_rubato = ["rubato"]
|
use_rubato = ["rubato"]
|
||||||
use_dasp = ["dasp"]
|
use_dasp = ["dasp"]
|
||||||
@@ -31,6 +30,13 @@ default = ["use_dasp"]
|
|||||||
hwcodec = ["scrap/hwcodec"]
|
hwcodec = ["scrap/hwcodec"]
|
||||||
vram = ["scrap/vram"]
|
vram = ["scrap/vram"]
|
||||||
mediacodec = ["scrap/mediacodec"]
|
mediacodec = ["scrap/mediacodec"]
|
||||||
|
drm = ["scrap/drm"]
|
||||||
|
# The display wake, as its OWN compile gate on top of `drm`. Everything else in the drm backend
|
||||||
|
# READS (it captures a scanout); the wake WRITES, injecting one synthetic pointer event from the
|
||||||
|
# root service so a compositor that idle-disabled its outputs re-enables them. That is a different
|
||||||
|
# kind of operation and deserves a switch that can remove it from the binary entirely, without
|
||||||
|
# giving up DRM capture: `--features drm` builds the capture path with no wake code compiled in.
|
||||||
|
drm-wake = ["drm"]
|
||||||
plugin_framework = []
|
plugin_framework = []
|
||||||
linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"]
|
linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"]
|
||||||
unix-file-copy-paste = [
|
unix-file-copy-paste = [
|
||||||
@@ -62,8 +68,6 @@ dasp = { version = "0.11", features = ["signal", "interpolate-linear", "interpol
|
|||||||
rubato = { version = "0.12", optional = true }
|
rubato = { version = "0.12", optional = true }
|
||||||
samplerate = { version = "0.2", optional = true }
|
samplerate = { version = "0.2", optional = true }
|
||||||
uuid = { version = "1.3", features = ["v4"] }
|
uuid = { version = "1.3", features = ["v4"] }
|
||||||
clap = "4.2"
|
|
||||||
rpassword = "7.2"
|
|
||||||
num_cpus = "1.15"
|
num_cpus = "1.15"
|
||||||
bytes = { version = "1.4", features = ["serde"] }
|
bytes = { version = "1.4", features = ["serde"] }
|
||||||
default-net = "0.14"
|
default-net = "0.14"
|
||||||
@@ -76,13 +80,14 @@ crossbeam-queue = "0.3"
|
|||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
cidr-utils = "0.5"
|
cidr-utils = "0.5"
|
||||||
libloading = "0.8"
|
|
||||||
fon = "0.6"
|
fon = "0.6"
|
||||||
zip = "0.6"
|
zip = "0.6"
|
||||||
shutdown_hooks = "0.1"
|
shutdown_hooks = "0.1"
|
||||||
totp-rs = { version = "5.4", default-features = false, features = ["gen_secret", "otpauth"] }
|
totp-rs = { version = "5.4", default-features = false, features = ["gen_secret", "otpauth"] }
|
||||||
stunclient = "0.4"
|
stunclient = "0.4"
|
||||||
kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys"}
|
kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys"}
|
||||||
|
reqwest = { version = "0.12", features = ["blocking", "socks", "json", "native-tls", "rustls-tls", "rustls-tls-native-roots", "gzip", "zstd"], default-features=false }
|
||||||
|
|
||||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||||
# https://github.com/rustdesk/rustdesk/discussions/10197, not use cpal on linux
|
# https://github.com/rustdesk/rustdesk/discussions/10197, not use cpal on linux
|
||||||
cpal = { git = "https://github.com/rustdesk-org/cpal", branch = "osx-screencapturekit" }
|
cpal = { git = "https://github.com/rustdesk-org/cpal", branch = "osx-screencapturekit" }
|
||||||
@@ -121,10 +126,23 @@ winapi = { version = "0.3", features = [
|
|||||||
] }
|
] }
|
||||||
windows = { version = "0.61", features = [
|
windows = { version = "0.61", features = [
|
||||||
"Win32",
|
"Win32",
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_Security",
|
||||||
|
"Win32_Security_Authorization",
|
||||||
|
"Win32_Storage_FileSystem",
|
||||||
"Win32_System",
|
"Win32_System",
|
||||||
|
"Win32_System_Com",
|
||||||
"Win32_System_Diagnostics",
|
"Win32_System_Diagnostics",
|
||||||
"Win32_System_Threading",
|
|
||||||
"Win32_System_Diagnostics_ToolHelp",
|
"Win32_System_Diagnostics_ToolHelp",
|
||||||
|
"Win32_System_Environment",
|
||||||
|
"Win32_System_IO",
|
||||||
|
"Win32_System_Memory",
|
||||||
|
"Win32_System_Pipes",
|
||||||
|
"Win32_System_Registry",
|
||||||
|
"Win32_System_SystemInformation",
|
||||||
|
"Win32_System_Threading",
|
||||||
|
"Win32_UI_Shell",
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
] }
|
] }
|
||||||
winreg = "0.11"
|
winreg = "0.11"
|
||||||
windows-service = "0.6"
|
windows-service = "0.6"
|
||||||
@@ -133,7 +151,6 @@ remote_printer = { path = "libs/remote_printer" }
|
|||||||
impersonate_system = { git = "https://github.com/rustdesk-org/impersonate-system" }
|
impersonate_system = { git = "https://github.com/rustdesk-org/impersonate-system" }
|
||||||
shared_memory = "0.12"
|
shared_memory = "0.12"
|
||||||
tauri-winrt-notification = "0.1"
|
tauri-winrt-notification = "0.1"
|
||||||
runas = "1.2"
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
objc = "0.2"
|
objc = "0.2"
|
||||||
@@ -150,7 +167,7 @@ piet-coregraphics = "0.6"
|
|||||||
foreign-types = "0.3"
|
foreign-types = "0.3"
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies]
|
[target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies]
|
||||||
tray-icon = { git = "https://github.com/tauri-apps/tray-icon" }
|
tray-icon = { git = "https://github.com/tauri-apps/tray-icon", version = "0.21.3" }
|
||||||
tao = { git = "https://github.com/rustdesk-org/tao", branch = "dev" }
|
tao = { git = "https://github.com/rustdesk-org/tao", branch = "dev" }
|
||||||
image = "0.24"
|
image = "0.24"
|
||||||
|
|
||||||
@@ -165,14 +182,8 @@ fontdb = "0.23"
|
|||||||
bytemuck = "1.23"
|
bytemuck = "1.23"
|
||||||
ttf-parser = "0.25"
|
ttf-parser = "0.25"
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
|
|
||||||
# https://github.com/rustdesk/rustdesk-server-pro/issues/189, using native-tls for better tls support
|
|
||||||
reqwest = { git = "https://github.com/rustdesk-org/reqwest", features = ["blocking", "socks", "json", "native-tls", "gzip"], default-features=false }
|
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "macos", target_os = "windows")))'.dependencies]
|
|
||||||
reqwest = { git = "https://github.com/rustdesk-org/reqwest", features = ["blocking", "socks", "json", "rustls-tls", "rustls-tls-native-roots", "gzip"], default-features=false }
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
libxdo-sys = "0.11"
|
||||||
psimple = { package = "libpulse-simple-binding", version = "2.27" }
|
psimple = { package = "libpulse-simple-binding", version = "2.27" }
|
||||||
pulse = { package = "libpulse-binding", version = "2.27" }
|
pulse = { package = "libpulse-binding", version = "2.27" }
|
||||||
rust-pulsectl = { git = "https://github.com/rustdesk-org/pulsectl" }
|
rust-pulsectl = { git = "https://github.com/rustdesk-org/pulsectl" }
|
||||||
@@ -181,7 +192,6 @@ evdev = { git="https://github.com/rustdesk-org/evdev" }
|
|||||||
dbus = "0.9"
|
dbus = "0.9"
|
||||||
dbus-crossroads = "0.5"
|
dbus-crossroads = "0.5"
|
||||||
pam = { git="https://github.com/rustdesk-org/pam" }
|
pam = { git="https://github.com/rustdesk-org/pam" }
|
||||||
users = { version = "0.11" }
|
|
||||||
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
|
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
|
||||||
x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
|
x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
|
||||||
percent-encoding = {version = "2.3", optional = true}
|
percent-encoding = {version = "2.3", optional = true}
|
||||||
@@ -192,6 +202,9 @@ termios = "0.3"
|
|||||||
terminfo = "0.8"
|
terminfo = "0.8"
|
||||||
winit = "0.30"
|
winit = "0.30"
|
||||||
|
|
||||||
|
[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies]
|
||||||
|
openssl = { version = "0.10", features = ["vendored"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
android_logger = "0.13"
|
android_logger = "0.13"
|
||||||
jni = "0.21"
|
jni = "0.21"
|
||||||
@@ -201,8 +214,13 @@ android-wakelock = { git = "https://github.com/rustdesk-org/android-wakelock" }
|
|||||||
members = ["libs/scrap", "libs/hbb_common", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable", "libs/remote_printer"]
|
members = ["libs/scrap", "libs/hbb_common", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable", "libs/remote_printer"]
|
||||||
exclude = ["vdi/host", "examples/custom_plugin"]
|
exclude = ["vdi/host", "examples/custom_plugin"]
|
||||||
|
|
||||||
|
# Patch libxdo-sys to use a stub implementation that doesn't require libxdo
|
||||||
|
# This allows building and running on systems without libxdo installed (e.g., Wayland-only)
|
||||||
|
[patch.crates-io]
|
||||||
|
libxdo-sys = { path = "libs/libxdo-sys-stub" }
|
||||||
|
|
||||||
[package.metadata.winres]
|
[package.metadata.winres]
|
||||||
LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved."
|
LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved."
|
||||||
ProductName = "RustDesk"
|
ProductName = "RustDesk"
|
||||||
FileDescription = "RustDesk Remote Desktop"
|
FileDescription = "RustDesk Remote Desktop"
|
||||||
OriginalFilename = "rustdesk.exe"
|
OriginalFilename = "rustdesk.exe"
|
||||||
@@ -234,3 +252,6 @@ panic = 'abort'
|
|||||||
strip = true
|
strip = true
|
||||||
#opt-level = 'z' # only have smaller size after strip
|
#opt-level = 'z' # only have smaller size after strip
|
||||||
rpath = true
|
rpath = true
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
debug = 1
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<a href="#how-to-build-with-docker">Docker</a> •
|
<a href="#how-to-build-with-docker">Docker</a> •
|
||||||
<a href="#file-structure">Structure</a> •
|
<a href="#file-structure">Structure</a> •
|
||||||
<a href="#snapshot">Snapshot</a><br>
|
<a href="#snapshot">Snapshot</a><br>
|
||||||
[<a href="docs/README-UA.md">Українська</a>] | [<a href="docs/README-CS.md">česky</a>] | [<a href="docs/README-ZH.md">中文</a>] | [<a href="docs/README-HU.md">Magyar</a>] | [<a href="docs/README-ES.md">Español</a>] | [<a href="docs/README-FA.md">فارسی</a>] | [<a href="docs/README-FR.md">Français</a>] | [<a href="docs/README-DE.md">Deutsch</a>] | [<a href="docs/README-PL.md">Polski</a>] | [<a href="docs/README-ID.md">Indonesian</a>] | [<a href="docs/README-FI.md">Suomi</a>] | [<a href="docs/README-ML.md">മലയാളം</a>] | [<a href="docs/README-JP.md">日本語</a>] | [<a href="docs/README-NL.md">Nederlands</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Русский</a>] | [<a href="docs/README-PTBR.md">Português (Brasil)</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">한국어</a>] | [<a href="docs/README-AR.md">العربي</a>] | [<a href="docs/README-VN.md">Tiếng Việt</a>] | [<a href="docs/README-DA.md">Dansk</a>] | [<a href="docs/README-GR.md">Ελληνικά</a>] | [<a href="docs/README-TR.md">Türkçe</a>] | [<a href="docs/README-NO.md">Norsk</a>]<br>
|
[<a href="docs/README-UA.md">Українська</a>] | [<a href="docs/README-CS.md">česky</a>] | [<a href="docs/README-ZH.md">中文</a>] | [<a href="docs/README-HU.md">Magyar</a>] | [<a href="docs/README-ES.md">Español</a>] | [<a href="docs/README-FA.md">فارسی</a>] | [<a href="docs/README-FR.md">Français</a>] | [<a href="docs/README-DE.md">Deutsch</a>] | [<a href="docs/README-PL.md">Polski</a>] | [<a href="docs/README-ID.md">Indonesian</a>] | [<a href="docs/README-FI.md">Suomi</a>] | [<a href="docs/README-ML.md">മലയാളം</a>] | [<a href="docs/README-JP.md">日本語</a>] | [<a href="docs/README-NL.md">Nederlands</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Русский</a>] | [<a href="docs/README-PTBR.md">Português (Brasil)</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">한국어</a>] | [<a href="docs/README-AR.md">العربي</a>] | [<a href="docs/README-VN.md">Tiếng Việt</a>] | [<a href="docs/README-DA.md">Dansk</a>] | [<a href="docs/README-GR.md">Ελληνικά</a>] | [<a href="docs/README-TR.md">Türkçe</a>] | [<a href="docs/README-NO.md">Norsk</a>] | [<a href="docs/README-RO.md">Română</a>]<br>
|
||||||
<b>We need your help to translate this README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> and <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Doc</a> to your native language</b>
|
<b>We need your help to translate this README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> and <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Doc</a> to your native language</b>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ RustDesk welcomes contribution from everyone. See [CONTRIBUTING.md](docs/CONTRIB
|
|||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
Desktop versions use Flutter or Sciter (deprecated) for GUI, this tutorial is for Sciter only, since it is easier and more friendly to start. Check out our [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) for building Flutter version.
|
Desktop versions use Flutter or Sciter (deprecated) for GUI. This tutorial is for Sciter only, since it is easier and more friendly to start. Check out our [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) for building the Flutter version.
|
||||||
|
|
||||||
Please download Sciter dynamic library yourself.
|
Please download Sciter dynamic library yourself.
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ AppDir:
|
|||||||
id: rustdesk
|
id: rustdesk
|
||||||
name: rustdesk
|
name: rustdesk
|
||||||
icon: rustdesk
|
icon: rustdesk
|
||||||
version: 1.4.2
|
version: 1.4.9
|
||||||
exec: usr/share/rustdesk/rustdesk
|
exec: usr/share/rustdesk/rustdesk
|
||||||
exec_args: $@
|
exec_args: $@
|
||||||
apt:
|
apt:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ AppDir:
|
|||||||
id: rustdesk
|
id: rustdesk
|
||||||
name: rustdesk
|
name: rustdesk
|
||||||
icon: rustdesk
|
icon: rustdesk
|
||||||
version: 1.4.2
|
version: 1.4.9
|
||||||
exec: usr/share/rustdesk/rustdesk
|
exec: usr/share/rustdesk/rustdesk
|
||||||
exec_args: $@
|
exec_args: $@
|
||||||
apt:
|
apt:
|
||||||
|
|||||||
551
build.py
551
build.py
@@ -1,23 +1,33 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import glob
|
||||||
|
import contextlib
|
||||||
import pathlib
|
import pathlib
|
||||||
import platform
|
import platform
|
||||||
import zipfile
|
import zipfile
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import shutil
|
import shutil
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Captured at import, while cwd is still the repo root: before Python 3.9 the main script's __file__
|
||||||
|
# stays relative (bpo-20443), so abspath() re-resolves it against the cwd -- and the ubuntu18.04
|
||||||
|
# packaging container runs 3.6 and chdir's into flutter/ before it reaches the libdrmtap code.
|
||||||
|
REPO_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
windows = platform.platform().startswith('Windows')
|
windows = platform.platform().startswith('Windows')
|
||||||
osx = platform.platform().startswith(
|
osx = platform.platform().startswith(
|
||||||
'Darwin') or platform.platform().startswith("macOS")
|
'Darwin') or platform.platform().startswith("macOS")
|
||||||
hbb_name = 'rustdesk' + ('.exe' if windows else '')
|
hbb_name = 'rustdesk' + ('.exe' if windows else '')
|
||||||
exe_path = 'target/release/' + hbb_name
|
exe_path = 'target/release/' + hbb_name
|
||||||
if windows:
|
if windows:
|
||||||
flutter_build_dir = 'build/windows/x64/runner/Release/'
|
win_arch = 'arm64' if platform.machine().lower() in ('arm64', 'aarch64') else 'x64'
|
||||||
|
flutter_build_dir = f'build/windows/{win_arch}/runner/Release/'
|
||||||
elif osx:
|
elif osx:
|
||||||
flutter_build_dir = 'build/macos/Build/Products/Release/'
|
flutter_build_dir = 'build/macos/Build/Products/Release/'
|
||||||
else:
|
else:
|
||||||
@@ -129,6 +139,19 @@ def make_parser():
|
|||||||
action='store_true',
|
action='store_true',
|
||||||
help='Build with unix file copy paste feature'
|
help='Build with unix file copy paste feature'
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--drm',
|
||||||
|
action='store_true',
|
||||||
|
help='Linux only: build the DRM/KMS capture backend (bundles libdrmtap.so, '
|
||||||
|
'dlopen-ed in-process by the root service). Off by default.'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--print-features',
|
||||||
|
action='store_true',
|
||||||
|
help='Print the cargo feature list these flags select, and exit without building. For a '
|
||||||
|
'caller that runs its own cargo line and then packages with --skip-cargo: it can ask '
|
||||||
|
'for the list rather than repeat it, so the two cannot drift.'
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--skip-cargo',
|
'--skip-cargo',
|
||||||
action='store_true',
|
action='store_true',
|
||||||
@@ -172,7 +195,7 @@ def generate_build_script_for_docker():
|
|||||||
# flutter_rust_bridge
|
# flutter_rust_bridge
|
||||||
dart pub global activate ffigen --version 5.0.1
|
dart pub global activate ffigen --version 5.0.1
|
||||||
pushd /tmp && git clone https://github.com/SoLongAndThanksForAllThePizza/flutter_rust_bridge --depth=1 && popd
|
pushd /tmp && git clone https://github.com/SoLongAndThanksForAllThePizza/flutter_rust_bridge --depth=1 && popd
|
||||||
pushd /tmp/flutter_rust_bridge/frb_codegen && cargo install --path . && popd
|
pushd /tmp/flutter_rust_bridge/frb_codegen && cargo install --path . --locked && popd
|
||||||
pushd flutter && flutter pub get && popd
|
pushd flutter && flutter pub get && popd
|
||||||
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart
|
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart
|
||||||
# install vcpkg
|
# install vcpkg
|
||||||
@@ -271,6 +294,24 @@ def external_resources(flutter, args, res_dir):
|
|||||||
shutil.copytree(f, f'{flutter_build_dir_2}{f.stem}')
|
shutil.copytree(f, f'{flutter_build_dir_2}{f.stem}')
|
||||||
|
|
||||||
|
|
||||||
|
def linux_packaging_branch():
|
||||||
|
"""Which packaging path `main()` will take on THIS host.
|
||||||
|
|
||||||
|
MUST mirror the elif chain in main() (pacman / yum / zypper / else), and exists so `--drm` can
|
||||||
|
refuse a branch that is not drm-aware instead of silently producing a stock-named package with
|
||||||
|
the capture backend compiled in. Only the final `deb` branch reaches `build_flutter_deb`, which
|
||||||
|
is what bundles libdrmtap, renames the package, adds Conflicts/Provides and asserts the staged
|
||||||
|
binary really is a drm build.
|
||||||
|
"""
|
||||||
|
if os.path.isfile('/usr/bin/pacman'):
|
||||||
|
return 'pacman'
|
||||||
|
if os.path.isfile('/usr/bin/yum'):
|
||||||
|
return 'yum'
|
||||||
|
if os.path.isfile('/usr/bin/zypper'):
|
||||||
|
return 'zypper'
|
||||||
|
return 'deb'
|
||||||
|
|
||||||
|
|
||||||
def get_features(args):
|
def get_features(args):
|
||||||
features = ['inline'] if not args.flutter else []
|
features = ['inline'] if not args.flutter else []
|
||||||
if args.hwcodec:
|
if args.hwcodec:
|
||||||
@@ -281,6 +322,30 @@ def get_features(args):
|
|||||||
features.append('flutter')
|
features.append('flutter')
|
||||||
if args.unix_file_copy_paste:
|
if args.unix_file_copy_paste:
|
||||||
features.append('unix-file-copy-paste')
|
features.append('unix-file-copy-paste')
|
||||||
|
if args.drm:
|
||||||
|
# Say so rather than quietly handing back a stock build: the backend is Linux-only, so on
|
||||||
|
# any other host the flag cannot be honoured and the resulting binary would look like a
|
||||||
|
# DRM build without being one.
|
||||||
|
if windows or osx:
|
||||||
|
raise Exception('--drm is Linux only')
|
||||||
|
# And only on the deb branch. The other three Linux paths (pacman/yum/zypper) package
|
||||||
|
# straight from `target/release` without bundling libdrmtap, without the rename, without
|
||||||
|
# Conflicts/Provides and without assert_staged_binary_is_drm() -- so they would emit a
|
||||||
|
# package NAMED `rustdesk` carrying the consent-bypass backend and the root-side uinput
|
||||||
|
# injection. The separate package name is the informed consent this feature rests on, so
|
||||||
|
# refuse rather than ship a stock-named build of it.
|
||||||
|
branch = linux_packaging_branch()
|
||||||
|
if branch != 'deb':
|
||||||
|
raise Exception(
|
||||||
|
f'--drm is only supported on the deb packaging path; this host would package via '
|
||||||
|
f'{branch}, which cannot bundle libdrmtap or name the package distinctly')
|
||||||
|
features.append('drm')
|
||||||
|
# The display wake is its own compile gate on top of `drm`, and the unattended package is
|
||||||
|
# exactly where it belongs: that variant exists to reach a machine nobody is sitting at,
|
||||||
|
# and a machine whose screen went dark is the case it is for. Dropping `drm-wake` from
|
||||||
|
# this line builds the same capture backend with no wake code in the binary at all.
|
||||||
|
# It is ALSO switchable at runtime; see OPTION_ENABLE_DRM_DISPLAY_WAKE.
|
||||||
|
features.append('drm-wake')
|
||||||
if osx:
|
if osx:
|
||||||
if args.screencapturekit:
|
if args.screencapturekit:
|
||||||
features.append('screencapturekit')
|
features.append('screencapturekit')
|
||||||
@@ -299,7 +364,7 @@ Version: %s
|
|||||||
Architecture: %s
|
Architecture: %s
|
||||||
Maintainer: rustdesk <info@rustdesk.com>
|
Maintainer: rustdesk <info@rustdesk.com>
|
||||||
Homepage: https://rustdesk.com
|
Homepage: https://rustdesk.com
|
||||||
Depends: libgtk-3-0, libxcb-randr0, libxdo3, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
|
Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
|
||||||
Recommends: libayatana-appindicator3-1
|
Recommends: libayatana-appindicator3-1
|
||||||
Description: A remote control software.
|
Description: A remote control software.
|
||||||
|
|
||||||
@@ -315,9 +380,325 @@ def ffi_bindgen_function_refactor():
|
|||||||
'sed -i "s/ffi.NativeFunction<ffi.Bool Function(DartPort/ffi.NativeFunction<ffi.Uint8 Function(DartPort/g" flutter/lib/generated_bridge.dart')
|
'sed -i "s/ffi.NativeFunction<ffi.Bool Function(DartPort/ffi.NativeFunction<ffi.Uint8 Function(DartPort/g" flutter/lib/generated_bridge.dart')
|
||||||
|
|
||||||
|
|
||||||
|
# libdrmtap is fetched at build time from the rustdesk-org fork at a pinned
|
||||||
|
# commit — the same way rustdesk sources its other native build deps (vcpkg,
|
||||||
|
# flutter_rust_bridge, ...), rather than carrying a git submodule. It is the ONLY
|
||||||
|
# pin for the drm backend: rustdesk dlopens this .so at runtime and does not depend on
|
||||||
|
# the libdrmtap-sys crate (whose build.rs would statically link the C tree, a helper and
|
||||||
|
# libdrm/seccomp/cap). DRMTAP_REPO, DRMTAP_SHA and DRMTAP_PREBUILT_DIR override it for local testing
|
||||||
|
# or another fork, and each requires DRMTAP_ALLOW_UNPINNED=1 alongside it (see below).
|
||||||
|
# The commit is fetched directly by sha, so no branch or tag name takes part in the build: see
|
||||||
|
# build_libdrmtap_so(). This is the SINGLE source of truth for the pin, deliberately not duplicated in
|
||||||
|
# any workflow, so a bump is one edit here (plus the informational version comment in
|
||||||
|
# libs/scrap/Cargo.toml). This commit is libdrmtap v0.5.2.
|
||||||
|
LIBDRMTAP_REPO_PINNED = 'https://github.com/rustdesk-org/libdrmtap'
|
||||||
|
LIBDRMTAP_SHA_PINNED = '653de8c774bc245eaf960611ca7a136f7a544d21'
|
||||||
|
LIBDRMTAP_REPO = os.environ.get('DRMTAP_REPO', LIBDRMTAP_REPO_PINNED)
|
||||||
|
LIBDRMTAP_SHA = os.environ.get('DRMTAP_SHA', LIBDRMTAP_SHA_PINNED)
|
||||||
|
# Every way of getting a different .so than the pin needs the same explicit opt-in. Otherwise the
|
||||||
|
# claim this feature rests on -- that the privileged capture library is the reviewed object at
|
||||||
|
# LIBDRMTAP_SHA_PINNED -- would hold only as long as nobody happened to have one of these set, and a
|
||||||
|
# build that silently used something else would be indistinguishable from one that did not.
|
||||||
|
# DRMTAP_PREBUILT_DIR is in the list because it is the widest of the three: it skips both the fetch
|
||||||
|
# and the sha verification and hands over an object built from nothing this script can see.
|
||||||
|
DRMTAP_UNPINNED_OK = os.environ.get('DRMTAP_ALLOW_UNPINNED') == '1'
|
||||||
|
|
||||||
|
|
||||||
|
def _prebuilt_dir_is_the_pinned_checkout(prebuilt_dir):
|
||||||
|
# A .so built from this repo's own third_party/libdrmtap at the pinned sha is the pinned object,
|
||||||
|
# not an override, so it must not need the opt-in. This is how CI hands the library from a step
|
||||||
|
# that has meson to a packaging container that does not.
|
||||||
|
src = os.path.join(REPO_ROOT, 'third_party', 'libdrmtap')
|
||||||
|
try:
|
||||||
|
inside = os.path.commonpath([os.path.abspath(prebuilt_dir), src]) == src
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if not inside or not os.path.isdir(os.path.join(src, '.git')):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
head = subprocess.check_output(['git', '-C', src, 'rev-parse', 'HEAD']).decode().strip()
|
||||||
|
except (subprocess.SubprocessError, OSError):
|
||||||
|
return False
|
||||||
|
return head == LIBDRMTAP_SHA
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_libdrmtap_pin():
|
||||||
|
# Called from build_libdrmtap_so(), NOT at import: a stock (non --drm) build must stay
|
||||||
|
# byte-identical to upstream in behaviour too, and leftover DRMTAP_* variables in the
|
||||||
|
# environment (or a malformed sha) must not be able to fail a build that never touches
|
||||||
|
# libdrmtap.
|
||||||
|
# `or None` so an empty value reads as unset here exactly as it does in build_libdrmtap_so(),
|
||||||
|
# which tests it for truthiness.
|
||||||
|
prebuilt = os.environ.get('DRMTAP_PREBUILT_DIR') or None
|
||||||
|
if prebuilt and _prebuilt_dir_is_the_pinned_checkout(prebuilt):
|
||||||
|
prebuilt = None
|
||||||
|
overridden = [
|
||||||
|
name
|
||||||
|
for name, value, pinned in (
|
||||||
|
('DRMTAP_REPO', LIBDRMTAP_REPO, LIBDRMTAP_REPO_PINNED),
|
||||||
|
('DRMTAP_SHA', LIBDRMTAP_SHA, LIBDRMTAP_SHA_PINNED),
|
||||||
|
('DRMTAP_PREBUILT_DIR', prebuilt, None),
|
||||||
|
)
|
||||||
|
if value != pinned
|
||||||
|
]
|
||||||
|
if overridden and not DRMTAP_UNPINNED_OK:
|
||||||
|
raise Exception(
|
||||||
|
f'{", ".join(overridden)} would build libdrmtap from something other than the pinned '
|
||||||
|
f'{LIBDRMTAP_REPO_PINNED} at {LIBDRMTAP_SHA_PINNED}. That is supported for local work and '
|
||||||
|
'cross-builds, but it has to be deliberate: set DRMTAP_ALLOW_UNPINNED=1 as well.')
|
||||||
|
if overridden:
|
||||||
|
print(f'WARNING: libdrmtap is NOT the pinned build ({", ".join(overridden)} set)')
|
||||||
|
# Both are interpolated into shell commands below, and both are env-overridable, so validate
|
||||||
|
# their SHAPE before they get there. This is not only about a hostile environment: a truncated
|
||||||
|
# or abbreviated sha would otherwise reach `git fetch` and fail with something far less obvious
|
||||||
|
# than saying so here, and an abbreviated one would defeat the point of pinning.
|
||||||
|
if not re.fullmatch(r'[0-9a-f]{40}', LIBDRMTAP_SHA):
|
||||||
|
raise Exception(
|
||||||
|
f'DRMTAP_SHA must be a full 40-character commit sha, got {LIBDRMTAP_SHA!r}')
|
||||||
|
if not re.fullmatch(r'(https://|git@)[A-Za-z0-9._~:/@-]+', LIBDRMTAP_REPO):
|
||||||
|
raise Exception(f'DRMTAP_REPO does not look like a git remote url: {LIBDRMTAP_REPO!r}')
|
||||||
|
|
||||||
|
|
||||||
|
def _single_real_so(paths, where):
|
||||||
|
# Return the one real libdrmtap.so.0.* object among `paths`, failing if there are zero or several.
|
||||||
|
# glob order is arbitrary, so silently taking [0] could ship a stale or wrong-arch object left
|
||||||
|
# over from an earlier build; a mismatch should fail the build loudly instead.
|
||||||
|
real = sorted(p for p in paths if os.path.isfile(p) and not os.path.islink(p))
|
||||||
|
if len(real) != 1:
|
||||||
|
raise Exception(
|
||||||
|
f'expected exactly one real libdrmtap.so.0.* in {where}, found {len(real)}: {real}')
|
||||||
|
return real[0]
|
||||||
|
|
||||||
|
|
||||||
|
def build_libdrmtap_so():
|
||||||
|
# Build libdrmtap.so from the rustdesk-org fork, fetched at the pinned LIBDRMTAP_SHA. The
|
||||||
|
# pivot dlopen-s this .so in-process in the root service (which already holds
|
||||||
|
# CAP_SYS_ADMIN) — no setcap helper, no privileged child. Only the shared
|
||||||
|
# library target is built (the source also carries a helper binary we do not
|
||||||
|
# ship). Returns the path to the built versioned .so (e.g. libdrmtap.so.0.4.x).
|
||||||
|
_validate_libdrmtap_pin()
|
||||||
|
# Allow a caller (e.g. CI) to build the .so ahead of time and hand it in via
|
||||||
|
# DRMTAP_PREBUILT_DIR (must contain the real libdrmtap.so.0.* object).
|
||||||
|
prebuilt_dir = os.environ.get('DRMTAP_PREBUILT_DIR')
|
||||||
|
if prebuilt_dir:
|
||||||
|
# DRMTAP_PREBUILT_DIR explicitly names the artifact source, so honor it strictly: fail
|
||||||
|
# (rather than silently falling back to a source build) if it holds no single real .so.
|
||||||
|
prebuilt = glob.glob(os.path.join(prebuilt_dir, 'libdrmtap.so.0.*'))
|
||||||
|
so = _single_real_so(prebuilt, f'DRMTAP_PREBUILT_DIR={prebuilt_dir}')
|
||||||
|
# Check the stub case HERE too, not only on the source path below. This is the widest
|
||||||
|
# override of the three -- no fetch, no sha verification, an object built by something this
|
||||||
|
# script cannot see -- so it is the likeliest to hand over a CPU-only build, and skipping the
|
||||||
|
# assertion on exactly this path would leave the check guarding only the case that was
|
||||||
|
# already trustworthy.
|
||||||
|
_assert_so_has_egl(so)
|
||||||
|
return so
|
||||||
|
# Fetch the pinned source if it is not already present. third_party/libdrmtap is not a submodule
|
||||||
|
# anymore; it is git-ignored. The commit is fetched BY SHA rather than by cloning a branch:
|
||||||
|
# `clone --depth 1 --branch main` only ever fetches the tip, so the moment upstream pushes to
|
||||||
|
# `main` the pinned commit is not in the shallow clone at all and the build fails on an unreachable
|
||||||
|
# object. Fetching the sha needs no branch name, so it keeps working across every upstream push and
|
||||||
|
# is immune to a ref being moved or repointed.
|
||||||
|
src = os.path.join(REPO_ROOT, 'third_party', 'libdrmtap')
|
||||||
|
if not os.path.exists(os.path.join(src, 'meson.build')):
|
||||||
|
if os.path.isdir(src):
|
||||||
|
shutil.rmtree(src)
|
||||||
|
os.makedirs(src, exist_ok=True)
|
||||||
|
system2(f'git -C "{src}" init -q')
|
||||||
|
system2(f'git -C "{src}" remote add origin {LIBDRMTAP_REPO}')
|
||||||
|
system2(f'git -C "{src}" fetch --depth 1 origin {LIBDRMTAP_SHA}')
|
||||||
|
system2(f'git -C "{src}" checkout -q FETCH_HEAD')
|
||||||
|
# Verify the pin whenever the source is a GIT checkout. A fetch by sha cannot resolve to anything
|
||||||
|
# else, so this now guards the OTHER case: a reused checkout left by an earlier build at a
|
||||||
|
# different pin, which is what a bump leaves behind. Reject and remove it so the next run re-fetches
|
||||||
|
# cleanly. A NON-git tree placed here on purpose (a developer building unreleased local libdrmtap
|
||||||
|
# source) has nothing to verify and is used as-is.
|
||||||
|
if os.path.isdir(os.path.join(src, '.git')):
|
||||||
|
got_sha = subprocess.check_output(
|
||||||
|
['git', '-C', src, 'rev-parse', 'HEAD']).decode().strip()
|
||||||
|
if got_sha != LIBDRMTAP_SHA:
|
||||||
|
shutil.rmtree(src, ignore_errors=True)
|
||||||
|
raise Exception(
|
||||||
|
f'libdrmtap at {src} is {got_sha}, expected {LIBDRMTAP_SHA} '
|
||||||
|
f'(stale checkout from a different pin; removed, re-run to re-fetch)')
|
||||||
|
build_dir = os.path.join(src, 'build-pkg')
|
||||||
|
if not os.path.exists(os.path.join(build_dir, 'build.ninja')):
|
||||||
|
system2(f'meson setup "{build_dir}" "{src}" --buildtype=release')
|
||||||
|
# Build only the shared library, not the bundled helper binary or the static archive. Since
|
||||||
|
# libdrmtap 0.4.11 the project is `both_libraries` (a version-scripted .so + a static .a), so the
|
||||||
|
# bare `drmtap` target is ambiguous ("drmtap:shared_library" vs "drmtap:static_library"); ask for
|
||||||
|
# the shared one explicitly (rustdesk dlopens the .so and never needs the archive).
|
||||||
|
system2(f'meson compile -C "{build_dir}" drmtap:shared_library')
|
||||||
|
sos = glob.glob(os.path.join(build_dir, 'libdrmtap.so.0.*'))
|
||||||
|
# keep the real object (libdrmtap.so.0.4.x), not the .so/.so.0 symlinks or meson's .p dir, and
|
||||||
|
# require exactly one so a stale object from an earlier build is never silently picked.
|
||||||
|
so = _single_real_so(sos, f'the libdrmtap meson build dir {build_dir}')
|
||||||
|
_assert_so_has_egl(so)
|
||||||
|
return so
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_so_has_egl(so_path):
|
||||||
|
# libdrmtap treats egl/glesv2 as OPTIONAL dependencies: without their headers and pkg-config
|
||||||
|
# files, meson silently builds a CPU-only stub. That stub still exports every symbol the loader
|
||||||
|
# checks for, so nothing downstream notices -- and the split architecture depends entirely on the
|
||||||
|
# unprivileged side EGL-detiling the scanout it receives. The result is a build where DRM capture
|
||||||
|
# quietly degrades to PipeWire on every tiled-scanout host, which is most of them.
|
||||||
|
#
|
||||||
|
# Assert on the ARTIFACT rather than passing an option that demands it: `-Degl=enabled` exists
|
||||||
|
# only in libdrmtap past 0.4.15, and checking what was actually produced also catches a stale or
|
||||||
|
# hand-substituted object, which a build flag cannot.
|
||||||
|
#
|
||||||
|
# EGL is reached by lazy dlopen, on purpose, so that the privileged service never links the GPU
|
||||||
|
# stack. That means there is no DT_NEEDED to look for and an ELF-level check reports "no EGL" on a
|
||||||
|
# perfectly good library; the dlopen name and an extension symbol are what a CPU-only stub really
|
||||||
|
# lacks.
|
||||||
|
try:
|
||||||
|
with open(so_path, 'rb') as f:
|
||||||
|
blob = f.read()
|
||||||
|
except OSError as err:
|
||||||
|
raise Exception(f'cannot read the built libdrmtap at {so_path}: {err}') from err
|
||||||
|
missing = [m for m in (b'libEGL.so.1', b'eglCreateImageKHR') if m not in blob]
|
||||||
|
if missing:
|
||||||
|
raise Exception(
|
||||||
|
f'{so_path} looks like a CPU-only libdrmtap stub (missing '
|
||||||
|
f'{", ".join(m.decode() for m in missing)}): the EGL detile path the split capture '
|
||||||
|
'depends on is not in it, and DRM capture would silently fall back to PipeWire. '
|
||||||
|
'Install the EGL development packages and rebuild (Debian/Ubuntu: libegl-dev '
|
||||||
|
'libgles2-mesa-dev; Arch: mesa libglvnd).')
|
||||||
|
|
||||||
|
|
||||||
|
DRM_PACKAGE_NAME = 'rustdesk-unattended-wayland'
|
||||||
|
|
||||||
|
|
||||||
|
def assert_so_satisfies_the_runtime_abi_gate(so_path):
|
||||||
|
"""The .so we are about to ship must be one the RUNTIME will actually accept.
|
||||||
|
|
||||||
|
`abi_accepted` in libs/scrap/src/common/drmtap_dl.rs is the only place the pinned library's
|
||||||
|
version is ever validated, and it runs at dlopen time on the USER's machine. Nothing in the
|
||||||
|
build or in CI compared the two, so the pin and the gate could drift apart and every existing
|
||||||
|
assertion would still pass: the EGL check does not look at the version, the CI symbol contract
|
||||||
|
does not call drmtap_version(), and the deb-contents regex matches any `libdrmtap.so.0.X.Y`.
|
||||||
|
A green pipeline could therefore produce a deb in which DRM capture can never start, and the
|
||||||
|
only symptom on the host is one log line before it falls back to the portal.
|
||||||
|
|
||||||
|
So parse the gate out of the Rust and apply it here, to the object being staged. This is the
|
||||||
|
same rule, not a copy of the numbers: if someone bumps the constants, this reads the new ones.
|
||||||
|
"""
|
||||||
|
m = re.search(r'libdrmtap\.so\.(\d+)\.(\d+)\.(\d+)', os.path.basename(so_path))
|
||||||
|
if not m:
|
||||||
|
# Not a versioned soname (a local dev build, say). The gate cannot be evaluated, and
|
||||||
|
# inventing a verdict would be worse than saying so.
|
||||||
|
print(f'[drm] cannot read a version out of {so_path}; skipping the ABI-gate cross-check')
|
||||||
|
return
|
||||||
|
so_ver = tuple(int(g) for g in m.groups())
|
||||||
|
# REPO_ROOT, not abspath(__file__): both callers have chdir'd into flutter/ by now.
|
||||||
|
gate_path = os.path.join(REPO_ROOT, 'libs', 'scrap', 'src', 'common', 'drmtap_dl.rs')
|
||||||
|
with open(gate_path) as f:
|
||||||
|
gate_src = f.read()
|
||||||
|
|
||||||
|
def _const(name):
|
||||||
|
mm = re.search(rf'const {name}: c_int = (\d+);', gate_src)
|
||||||
|
return int(mm.group(1)) if mm else None
|
||||||
|
|
||||||
|
major, minor = _const('DRMTAP_ABI_MAJOR'), _const('DRMTAP_ABI_MINOR')
|
||||||
|
mm = re.search(r'const DRMTAP_MIN_MINOR_PATCH: \(c_int, c_int\) = \((\d+), (\d+)\);', gate_src)
|
||||||
|
floor = (int(mm.group(1)), int(mm.group(2))) if mm else None
|
||||||
|
if major is None or minor is None or floor is None:
|
||||||
|
raise Exception(
|
||||||
|
'could not parse the libdrmtap ABI gate out of drmtap_dl.rs (DRMTAP_ABI_MAJOR / '
|
||||||
|
'DRMTAP_ABI_MINOR / DRMTAP_MIN_MINOR_PATCH). The gate moved and this check did not; '
|
||||||
|
'fix the check rather than removing it, or the pin and the gate can drift silently.')
|
||||||
|
accepted = so_ver[0] == major and so_ver[1] == minor and (so_ver[1], so_ver[2]) >= floor
|
||||||
|
if not accepted:
|
||||||
|
raise Exception(
|
||||||
|
f'the libdrmtap being packaged is {so_ver[0]}.{so_ver[1]}.{so_ver[2]}, which the '
|
||||||
|
f'runtime loader would REFUSE: drmtap_dl.rs accepts exactly major {major}, minor '
|
||||||
|
f'{minor}, patch >= {floor[1]}. Shipping it produces a deb whose DRM capture can never '
|
||||||
|
'start. Move the build pin and the gate together, or fix whichever one is wrong.')
|
||||||
|
print(f'[drm] libdrmtap {so_ver[0]}.{so_ver[1]}.{so_ver[2]} satisfies the runtime ABI gate '
|
||||||
|
f'(major {major}, minor {minor}, patch >= {floor[1]})')
|
||||||
|
|
||||||
|
|
||||||
|
def stage_libdrmtap_into_deb(so_path):
|
||||||
|
# Put the built libdrmtap object plus its soname symlink into the staged deb. Only the soname
|
||||||
|
# symlink is needed: libdrmtap is resolved by ABSOLUTE path (/usr/lib/rustdesk/libdrmtap.so.0) at
|
||||||
|
# the in-process dlopen site (drmtap_dl.rs), so the deb does NOT drop /usr/lib/rustdesk into the
|
||||||
|
# system-wide /etc/ld.so.conf.d search path, which would let this private library shadow a system
|
||||||
|
# library for every binary on the host (Debian Policy 10.2 forbids that). No ld.so.conf.d drop-in
|
||||||
|
# and no ldconfig trigger are shipped, so the stock postinst is used unchanged.
|
||||||
|
assert_so_satisfies_the_runtime_abi_gate(so_path)
|
||||||
|
so_basename = os.path.basename(so_path)
|
||||||
|
system2('mkdir -p tmpdeb/usr/lib/rustdesk')
|
||||||
|
# Quoted: so_path comes from the repo root or from DRMTAP_PREBUILT_DIR, either of which can
|
||||||
|
# contain a space, and an unquoted interpolation would split the argument and fail obscurely.
|
||||||
|
system2(f'cp "{so_path}" tmpdeb/usr/lib/rustdesk/')
|
||||||
|
system2(f'ln -sf "{so_basename}" tmpdeb/usr/lib/rustdesk/libdrmtap.so.0')
|
||||||
|
|
||||||
|
|
||||||
|
def _max_glibc_minor(path):
|
||||||
|
# Read from .dynstr rather than via objdump so packaging needs no binutils; chunked because
|
||||||
|
# librustdesk.so is ~45 MB.
|
||||||
|
best = 0
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
tail = b''
|
||||||
|
while True:
|
||||||
|
chunk = f.read(1 << 20)
|
||||||
|
if not chunk:
|
||||||
|
return best
|
||||||
|
for m in re.finditer(rb'GLIBC_2\.(\d+)', tail + chunk):
|
||||||
|
best = max(best, int(m.group(1)))
|
||||||
|
tail = chunk[-16:]
|
||||||
|
|
||||||
|
|
||||||
|
def measured_glibc_floor():
|
||||||
|
# libdrmtap is built on a newer base than the rest of the deb, so the floor is whichever staged
|
||||||
|
# object is higher -- and it moves whenever either base does.
|
||||||
|
paths = [p for p in glob.glob('tmpdeb/usr/lib/rustdesk/libdrmtap.so.0.*')
|
||||||
|
+ glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so')
|
||||||
|
+ glob.glob('tmpdeb/usr/share/rustdesk/rustdesk')
|
||||||
|
if os.path.isfile(p) and not os.path.islink(p)]
|
||||||
|
minor = max((_max_glibc_minor(p) for p in paths), default=0)
|
||||||
|
if not minor:
|
||||||
|
raise Exception(
|
||||||
|
f'could not measure a GLIBC_2.x floor from any staged object ({paths or "none found"}); '
|
||||||
|
'refusing to ship the unattended-wayland variant with an undeclared libc6 floor, which '
|
||||||
|
'is what lets it install on a host where libdrmtap can never load')
|
||||||
|
return f'2.{minor}'
|
||||||
|
|
||||||
|
|
||||||
|
def retarget_control_to_drm_variant():
|
||||||
|
# Rewrite the control file that generate_control_file just produced, instead of parameterizing that
|
||||||
|
# function: the stock packaging path stays exactly as upstream wrote it, and everything specific to
|
||||||
|
# this variant lives here. The variant installs the same files as the stock package, so it must
|
||||||
|
# conflict with and replace it: you install one or the other, never both. It also needs libdrmtap's
|
||||||
|
# own runtime deps, which the stock package has no reason to carry.
|
||||||
|
path = '../res/DEBIAN/control'
|
||||||
|
floor = measured_glibc_floor()
|
||||||
|
print(f'[drm] {DRM_PACKAGE_NAME} libc6 floor measured at {floor}')
|
||||||
|
with open(path) as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
out = []
|
||||||
|
for line in lines:
|
||||||
|
if line.startswith('Package: rustdesk'):
|
||||||
|
out.append(f'Package: {DRM_PACKAGE_NAME}\n')
|
||||||
|
out.append('Conflicts: rustdesk\nReplaces: rustdesk\nProvides: rustdesk\n')
|
||||||
|
elif line.startswith('Depends:'):
|
||||||
|
# 2.4.101 is where drmModeGetFB2 landed; below it libdrmtap loads and can never capture.
|
||||||
|
out.append(line.rstrip('\n') + ', libdrm2 (>= 2.4.101), libegl1, libgles2, '
|
||||||
|
f'libc6 (>= {floor})\n')
|
||||||
|
else:
|
||||||
|
out.append(line)
|
||||||
|
body = ''.join(out)
|
||||||
|
# Fail loudly rather than silently shipping a package that says `rustdesk`: a stock control file
|
||||||
|
# that stopped matching either anchor would otherwise produce a variant deb wearing the stock name.
|
||||||
|
if f'Package: {DRM_PACKAGE_NAME}\n' not in body or 'libegl1' not in body:
|
||||||
|
raise Exception(f'could not retarget {path} to the drm variant; upstream control layout changed')
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
f.write(body)
|
||||||
|
|
||||||
|
|
||||||
def build_flutter_deb(version, features):
|
def build_flutter_deb(version, features):
|
||||||
if not skip_cargo:
|
if not skip_cargo:
|
||||||
system2(f'cargo build --features {features} --lib --release')
|
system2(f'cargo build --locked --features {features} --lib --release')
|
||||||
ffi_bindgen_function_refactor()
|
ffi_bindgen_function_refactor()
|
||||||
os.chdir('flutter')
|
os.chdir('flutter')
|
||||||
system2('flutter build linux --release')
|
system2('flutter build linux --release')
|
||||||
@@ -351,9 +732,22 @@ def build_flutter_deb(version, features):
|
|||||||
'cp ../res/pam.d/rustdesk.debian tmpdeb/etc/pam.d/rustdesk')
|
'cp ../res/pam.d/rustdesk.debian tmpdeb/etc/pam.d/rustdesk')
|
||||||
system2(
|
system2(
|
||||||
"echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit")
|
"echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit")
|
||||||
|
# Bundle libdrmtap.so only when this build actually enabled the `drm` feature, so stock packages
|
||||||
|
# stay exactly what they were. The root service dlopens it in-process by absolute path.
|
||||||
|
# `features` is the comma-joined string, so split it: a bare substring test would also match any
|
||||||
|
# future feature merely containing "drm" (drm-lease, vaapi-drm) and rename the deb to the
|
||||||
|
# consent-bypass variant without --drm ever being passed.
|
||||||
|
ships_so = 'drm' in features.split(',')
|
||||||
|
if ships_so:
|
||||||
|
# Same artifact assertion as the --package path. Under --skip-cargo nothing here rebuilt the
|
||||||
|
# binary, so `features` says what was ASKED for while the staged bundle can be anything.
|
||||||
|
assert_staged_binary_is_drm()
|
||||||
|
stage_libdrmtap_into_deb(build_libdrmtap_so())
|
||||||
|
|
||||||
system2('mkdir -p tmpdeb/DEBIAN')
|
system2('mkdir -p tmpdeb/DEBIAN')
|
||||||
generate_control_file(version)
|
generate_control_file(version)
|
||||||
|
if ships_so:
|
||||||
|
retarget_control_to_drm_variant()
|
||||||
system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/')
|
system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/')
|
||||||
md5_file_folder("tmpdeb/")
|
md5_file_folder("tmpdeb/")
|
||||||
system2('dpkg-deb -b tmpdeb rustdesk.deb;')
|
system2('dpkg-deb -b tmpdeb rustdesk.deb;')
|
||||||
@@ -361,10 +755,68 @@ def build_flutter_deb(version, features):
|
|||||||
system2('/bin/rm -rf tmpdeb/')
|
system2('/bin/rm -rf tmpdeb/')
|
||||||
system2('/bin/rm -rf ../res/DEBIAN/control')
|
system2('/bin/rm -rf ../res/DEBIAN/control')
|
||||||
os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version)
|
os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version)
|
||||||
|
if ships_so:
|
||||||
|
# Named apart from the stock package so installing the consent-free variant is a deliberate act.
|
||||||
|
os.rename('../rustdesk-%s.deb' % version, f'../{DRM_PACKAGE_NAME}-{version}.deb')
|
||||||
os.chdir("..")
|
os.chdir("..")
|
||||||
|
|
||||||
|
|
||||||
def build_deb_from_folder(version, binary_folder):
|
DRMTAP_DLOPEN_MARKER = b'/usr/lib/rustdesk/libdrmtap.so.0'
|
||||||
|
# Present only when `drm-wake` is compiled in: the runtime option constant is itself
|
||||||
|
# #[cfg(feature = "drm-wake")] (src/ipc/drm.rs). The dlopen marker above cannot stand in for it -
|
||||||
|
# `--features drm` alone produces a binary that carries the dlopen path and NO wake code, and that
|
||||||
|
# is exactly the deb this assertion is here to refuse.
|
||||||
|
DRMTAP_WAKE_MARKER = b'enable-drm-display-wake'
|
||||||
|
|
||||||
|
|
||||||
|
def _carries_drmtap_marker(path, marker=DRMTAP_DLOPEN_MARKER):
|
||||||
|
# Chunked, with an overlap of len(marker)-1 so the marker cannot be missed at a chunk boundary:
|
||||||
|
# librustdesk.so is ~45 MB and there is no reason to hold it all in memory, and the `with`
|
||||||
|
# closes deterministically instead of relying on refcounting.
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
tail = b''
|
||||||
|
while True:
|
||||||
|
chunk = f.read(1 << 20)
|
||||||
|
if not chunk:
|
||||||
|
return False
|
||||||
|
if marker in tail + chunk:
|
||||||
|
return True
|
||||||
|
tail = chunk[-(len(marker) - 1):]
|
||||||
|
|
||||||
|
|
||||||
|
def assert_staged_binary_is_drm():
|
||||||
|
"""The staged BINARY must really be a drm build before it is named the unattended-wayland
|
||||||
|
variant. That package conflicts with and replaces the stock one, so shipping a stock binary
|
||||||
|
under that name produces something that can never capture and cannot be installed alongside
|
||||||
|
what it replaced. The marker is the absolute dlopen path from drmtap_dl.rs, present only when
|
||||||
|
the feature is compiled in -- assert what was produced, not what was asked for.
|
||||||
|
|
||||||
|
Called from BOTH packaging paths. It used to guard only one of them, and `--skip-cargo` (which
|
||||||
|
is how CI packages) reaches the other, where nothing had rebuilt the binary at all.
|
||||||
|
"""
|
||||||
|
binaries = [p for p in glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so')
|
||||||
|
+ glob.glob('tmpdeb/usr/share/rustdesk/rustdesk') if os.path.isfile(p)]
|
||||||
|
if not any(_carries_drmtap_marker(p) for p in binaries):
|
||||||
|
raise Exception(
|
||||||
|
f'--drm was requested but the staged bundle does not look like a drm build (no '
|
||||||
|
f'{DRMTAP_DLOPEN_MARKER.decode()} dlopen path in {binaries or "any staged binary"}); '
|
||||||
|
'refusing to package it as the unattended-wayland variant, which conflicts with and '
|
||||||
|
'replaces the stock package but could never capture')
|
||||||
|
# And the WAKE half. `--drm` enables `drm-wake` too (see get_features), and the deb is named and
|
||||||
|
# documented as the variant that can reach a machine whose screen has gone dark. The dlopen
|
||||||
|
# marker above does not distinguish them: `--features drm` alone carries it and has no wake code
|
||||||
|
# at all. Asserting only the first half is how a deb can be named for a feature it does not have.
|
||||||
|
if not any(_carries_drmtap_marker(p, DRMTAP_WAKE_MARKER) for p in binaries):
|
||||||
|
raise Exception(
|
||||||
|
f'--drm was requested but the staged binary has no {DRMTAP_WAKE_MARKER.decode()} '
|
||||||
|
f'marker in {binaries or "any staged binary"}, so it was built without `drm-wake`; '
|
||||||
|
'refusing to package it as the unattended-wayland variant, which is named and '
|
||||||
|
'documented as the build that can wake an idle-disabled display. If this fired under '
|
||||||
|
'--skip-cargo, the cargo line that produced the bundle is missing the feature: '
|
||||||
|
'--features ...,drm,drm-wake')
|
||||||
|
|
||||||
|
|
||||||
|
def build_deb_from_folder(version, binary_folder, want_drm=False):
|
||||||
os.chdir('flutter')
|
os.chdir('flutter')
|
||||||
system2('mkdir -p tmpdeb/usr/bin/')
|
system2('mkdir -p tmpdeb/usr/bin/')
|
||||||
system2('mkdir -p tmpdeb/usr/share/rustdesk')
|
system2('mkdir -p tmpdeb/usr/share/rustdesk')
|
||||||
@@ -388,9 +840,53 @@ def build_deb_from_folder(version, binary_folder):
|
|||||||
'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop')
|
'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop')
|
||||||
system2(
|
system2(
|
||||||
"echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit")
|
"echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit")
|
||||||
|
# Where the capture library comes from for a `--package <folder> --drm` build. Two shapes are
|
||||||
|
# supported, because two exist in practice: a bundle that already carries libdrmtap.so.0.*
|
||||||
|
# (someone staged it, e.g. a CI artifact), and a plain bundle, which is what every build path
|
||||||
|
# here actually produces -- the flutter deb builds the library straight into the staged deb, so
|
||||||
|
# nothing ever puts it inside the bundle folder. Demanding it in the bundle made this flag
|
||||||
|
# combination impossible to satisfy.
|
||||||
|
bundled_glob = glob.glob('tmpdeb/usr/share/rustdesk/libdrmtap.so.0.*')
|
||||||
|
bundle_carries_so = any(os.path.isfile(p) and not os.path.islink(p) for p in bundled_glob)
|
||||||
|
# The variant must be decided by the EXPLICIT --drm request, not merely by what happens to be
|
||||||
|
# staged: a bundle that carries the .so must NOT be shipped as the consent-bypass variant when
|
||||||
|
# --drm was never passed.
|
||||||
|
if bundle_carries_so and not want_drm:
|
||||||
|
raise Exception(
|
||||||
|
'the staged bundle carries libdrmtap.so.0.* but --drm was not passed; refusing '
|
||||||
|
'to silently ship the consent-bypass unattended-wayland variant (pass --drm to '
|
||||||
|
'build it deliberately)')
|
||||||
|
if want_drm:
|
||||||
|
# Whichever shape we are in, the staged BINARY must really be a drm build. This is the
|
||||||
|
# property the old presence-of-the-.so test stood in for, badly: a stock binary packaged as
|
||||||
|
# the unattended-wayland variant would carry the consent-bypass name, conflict with and
|
||||||
|
# replace the stock package, and never be able to capture. The marker is the absolute
|
||||||
|
# dlopen path from drmtap_dl.rs, present only when the feature is compiled in -- the same
|
||||||
|
# kind of artifact assertion as _assert_so_has_egl, and for the same reason: assert what
|
||||||
|
# was produced, not what was asked for.
|
||||||
|
assert_staged_binary_is_drm()
|
||||||
|
if bundle_carries_so:
|
||||||
|
so = _single_real_so(bundled_glob, 'the staged --drm bundle')
|
||||||
|
# The THIRD artifact source, and the last one that was missing the check: --package
|
||||||
|
# takes the .so straight out of a bundle somebody else produced, so it has the same
|
||||||
|
# exposure as DRMTAP_PREBUILT_DIR (see the comment on that branch). A CPU-only stub
|
||||||
|
# would ship, the loader would accept it, and capture would degrade to PipeWire
|
||||||
|
# without a word.
|
||||||
|
_assert_so_has_egl(so)
|
||||||
|
stage_libdrmtap_into_deb(so)
|
||||||
|
system2(f'rm -f "{so}"')
|
||||||
|
system2('rm -f tmpdeb/usr/share/rustdesk/libdrmtap.so tmpdeb/usr/share/rustdesk/libdrmtap.so.0')
|
||||||
|
else:
|
||||||
|
# Build it here, exactly as the flutter deb path does (build_libdrmtap_so asserts the
|
||||||
|
# EGL backend itself). The library is independent of the staged binary.
|
||||||
|
stage_libdrmtap_into_deb(build_libdrmtap_so())
|
||||||
|
|
||||||
system2('mkdir -p tmpdeb/DEBIAN')
|
system2('mkdir -p tmpdeb/DEBIAN')
|
||||||
generate_control_file(version)
|
generate_control_file(version)
|
||||||
|
# Keyed on the EXPLICIT request, not on what happened to be staged: by here a --drm build has
|
||||||
|
# its library in tmpdeb whichever of the two shapes it came from.
|
||||||
|
if want_drm:
|
||||||
|
retarget_control_to_drm_variant()
|
||||||
system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/')
|
system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/')
|
||||||
md5_file_folder("tmpdeb/")
|
md5_file_folder("tmpdeb/")
|
||||||
system2('dpkg-deb -b tmpdeb rustdesk.deb;')
|
system2('dpkg-deb -b tmpdeb rustdesk.deb;')
|
||||||
@@ -398,6 +894,8 @@ def build_deb_from_folder(version, binary_folder):
|
|||||||
system2('/bin/rm -rf tmpdeb/')
|
system2('/bin/rm -rf tmpdeb/')
|
||||||
system2('/bin/rm -rf ../res/DEBIAN/control')
|
system2('/bin/rm -rf ../res/DEBIAN/control')
|
||||||
os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version)
|
os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version)
|
||||||
|
if want_drm:
|
||||||
|
os.rename('../rustdesk-%s.deb' % version, f'../{DRM_PACKAGE_NAME}-{version}.deb')
|
||||||
os.chdir("..")
|
os.chdir("..")
|
||||||
|
|
||||||
|
|
||||||
@@ -405,12 +903,17 @@ def build_flutter_dmg(version, features):
|
|||||||
if not skip_cargo:
|
if not skip_cargo:
|
||||||
# set minimum osx build target, now is 10.14, which is the same as the flutter xcode project
|
# set minimum osx build target, now is 10.14, which is the same as the flutter xcode project
|
||||||
system2(
|
system2(
|
||||||
f'MACOSX_DEPLOYMENT_TARGET=10.14 cargo build --features {features} --release')
|
f'MACOSX_DEPLOYMENT_TARGET=10.14 cargo build --locked --features {features} --release')
|
||||||
# copy dylib
|
# copy dylib
|
||||||
system2(
|
system2(
|
||||||
"cp target/release/liblibrustdesk.dylib target/release/librustdesk.dylib")
|
"cp target/release/liblibrustdesk.dylib target/release/librustdesk.dylib")
|
||||||
os.chdir('flutter')
|
os.chdir('flutter')
|
||||||
system2('flutter build macos --release')
|
# cargo builds a single-arch dylib for the host; restrict Xcode to the same arch
|
||||||
|
# so the universal-by-default ARCHS_STANDARD doesn't try to link a missing slice.
|
||||||
|
# FLUTTER_XCODE_* env vars are forwarded to xcodebuild as build settings.
|
||||||
|
mac_arch = 'arm64' if platform.machine().lower() in ('arm64', 'aarch64') else 'x86_64'
|
||||||
|
system2(
|
||||||
|
f'FLUTTER_XCODE_ARCHS={mac_arch} FLUTTER_XCODE_ONLY_ACTIVE_ARCH=YES flutter build macos --release')
|
||||||
system2('cp -rf ../target/release/service ./build/macos/Build/Products/Release/RustDesk.app/Contents/MacOS/')
|
system2('cp -rf ../target/release/service ./build/macos/Build/Products/Release/RustDesk.app/Contents/MacOS/')
|
||||||
'''
|
'''
|
||||||
system2(
|
system2(
|
||||||
@@ -422,7 +925,7 @@ def build_flutter_dmg(version, features):
|
|||||||
|
|
||||||
def build_flutter_arch_manjaro(version, features):
|
def build_flutter_arch_manjaro(version, features):
|
||||||
if not skip_cargo:
|
if not skip_cargo:
|
||||||
system2(f'cargo build --features {features} --lib --release')
|
system2(f'cargo build --locked --features {features} --lib --release')
|
||||||
ffi_bindgen_function_refactor()
|
ffi_bindgen_function_refactor()
|
||||||
os.chdir('flutter')
|
os.chdir('flutter')
|
||||||
system2('flutter build linux --release')
|
system2('flutter build linux --release')
|
||||||
@@ -433,7 +936,7 @@ def build_flutter_arch_manjaro(version, features):
|
|||||||
|
|
||||||
def build_flutter_windows(version, features, skip_portable_pack):
|
def build_flutter_windows(version, features, skip_portable_pack):
|
||||||
if not skip_cargo:
|
if not skip_cargo:
|
||||||
system2(f'cargo build --features {features} --lib --release')
|
system2(f'cargo build --locked --features {features} --lib --release')
|
||||||
if not os.path.exists("target/release/librustdesk.dll"):
|
if not os.path.exists("target/release/librustdesk.dll"):
|
||||||
print("cargo build failed, please check rust source code.")
|
print("cargo build failed, please check rust source code.")
|
||||||
exit(-1)
|
exit(-1)
|
||||||
@@ -467,6 +970,19 @@ def main():
|
|||||||
parser = make_parser()
|
parser = make_parser()
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Before anything with a side effect: this is a query, and a caller uses it to build the very
|
||||||
|
# binary it will then package. `get_features` stays the single definition of what a flag
|
||||||
|
# combination means; a caller that hardcodes the list instead is one edit away from compiling
|
||||||
|
# something other than what it ships.
|
||||||
|
if args.print_features:
|
||||||
|
# stdout carries the list and nothing else, so a caller can use it directly in a command
|
||||||
|
# substitution. `get_features` prints a human-readable line of its own; send that to stderr
|
||||||
|
# for this call rather than silencing it, which would change what every other path prints.
|
||||||
|
with contextlib.redirect_stdout(sys.stderr):
|
||||||
|
feats = ','.join(get_features(args))
|
||||||
|
print(feats)
|
||||||
|
return
|
||||||
|
|
||||||
if os.path.exists(exe_path):
|
if os.path.exists(exe_path):
|
||||||
os.unlink(exe_path)
|
os.unlink(exe_path)
|
||||||
if os.path.isfile('/usr/bin/pacman'):
|
if os.path.isfile('/usr/bin/pacman'):
|
||||||
@@ -482,20 +998,20 @@ def main():
|
|||||||
portable = args.portable
|
portable = args.portable
|
||||||
package = args.package
|
package = args.package
|
||||||
if package:
|
if package:
|
||||||
build_deb_from_folder(version, package)
|
build_deb_from_folder(version, package, args.drm)
|
||||||
return
|
return
|
||||||
res_dir = 'resources'
|
res_dir = 'resources'
|
||||||
external_resources(flutter, args, res_dir)
|
external_resources(flutter, args, res_dir)
|
||||||
if windows:
|
if windows:
|
||||||
# build virtual display dynamic library
|
# build virtual display dynamic library
|
||||||
os.chdir('libs/virtual_display/dylib')
|
os.chdir('libs/virtual_display/dylib')
|
||||||
system2('cargo build --release')
|
system2('cargo build --locked --release')
|
||||||
os.chdir('../../..')
|
os.chdir('../../..')
|
||||||
|
|
||||||
if flutter:
|
if flutter:
|
||||||
build_flutter_windows(version, features, args.skip_portable_pack)
|
build_flutter_windows(version, features, args.skip_portable_pack)
|
||||||
return
|
return
|
||||||
system2('cargo build --release --features ' + features)
|
system2('cargo build --locked --release --features ' + features)
|
||||||
# system2('upx.exe target/release/rustdesk.exe')
|
# system2('upx.exe target/release/rustdesk.exe')
|
||||||
system2('mv target/release/rustdesk.exe target/release/RustDesk.exe')
|
system2('mv target/release/rustdesk.exe target/release/RustDesk.exe')
|
||||||
pa = os.environ.get('P')
|
pa = os.environ.get('P')
|
||||||
@@ -506,20 +1022,21 @@ def main():
|
|||||||
'target\\release\\rustdesk.exe')
|
'target\\release\\rustdesk.exe')
|
||||||
else:
|
else:
|
||||||
print('Not signed')
|
print('Not signed')
|
||||||
|
os.makedirs(res_dir, exist_ok=True)
|
||||||
system2(
|
system2(
|
||||||
f'cp -rf target/release/RustDesk.exe {res_dir}')
|
f'cp -rf target/release/RustDesk.exe {res_dir}')
|
||||||
os.chdir('libs/portable')
|
os.chdir('libs/portable')
|
||||||
system2('pip3 install -r requirements.txt')
|
system2('pip3 install -r requirements.txt')
|
||||||
system2(
|
system2(
|
||||||
f'python3 ./generate.py -f ../../{res_dir} -o . -e ../../{res_dir}/rustdesk-{version}-win7-install.exe')
|
f'python3 ./generate.py -f ../../{res_dir} -o . -e ../../{res_dir}/rustdesk-{version}-win7-install.exe')
|
||||||
system2('mv ../../{res_dir}/rustdesk-{version}-win7-install.exe ../..')
|
system2(f'mv ../../{res_dir}/rustdesk-{version}-win7-install.exe ../..')
|
||||||
elif os.path.isfile('/usr/bin/pacman'):
|
elif os.path.isfile('/usr/bin/pacman'):
|
||||||
# pacman -S -needed base-devel
|
# pacman -S -needed base-devel
|
||||||
system2("sed -i 's/pkgver=.*/pkgver=%s/g' res/PKGBUILD" % version)
|
system2("sed -i 's/pkgver=.*/pkgver=%s/g' res/PKGBUILD" % version)
|
||||||
if flutter:
|
if flutter:
|
||||||
build_flutter_arch_manjaro(version, features)
|
build_flutter_arch_manjaro(version, features)
|
||||||
else:
|
else:
|
||||||
system2('cargo build --release --features ' + features)
|
system2('cargo build --locked --release --features ' + features)
|
||||||
system2('git checkout src/ui/common.tis')
|
system2('git checkout src/ui/common.tis')
|
||||||
system2('strip target/release/rustdesk')
|
system2('strip target/release/rustdesk')
|
||||||
system2('ln -s res/pacman_install && ln -s res/PKGBUILD')
|
system2('ln -s res/pacman_install && ln -s res/PKGBUILD')
|
||||||
@@ -528,7 +1045,7 @@ def main():
|
|||||||
version, version))
|
version, version))
|
||||||
# pacman -U ./rustdesk.pkg.tar.zst
|
# pacman -U ./rustdesk.pkg.tar.zst
|
||||||
elif os.path.isfile('/usr/bin/yum'):
|
elif os.path.isfile('/usr/bin/yum'):
|
||||||
system2('cargo build --release --features ' + features)
|
system2('cargo build --locked --release --features ' + features)
|
||||||
system2('strip target/release/rustdesk')
|
system2('strip target/release/rustdesk')
|
||||||
system2(
|
system2(
|
||||||
"sed -i 's/Version: .*/Version: %s/g' res/rpm.spec" % version)
|
"sed -i 's/Version: .*/Version: %s/g' res/rpm.spec" % version)
|
||||||
@@ -538,7 +1055,7 @@ def main():
|
|||||||
version, version))
|
version, version))
|
||||||
# yum localinstall rustdesk.rpm
|
# yum localinstall rustdesk.rpm
|
||||||
elif os.path.isfile('/usr/bin/zypper'):
|
elif os.path.isfile('/usr/bin/zypper'):
|
||||||
system2('cargo build --release --features ' + features)
|
system2('cargo build --locked --release --features ' + features)
|
||||||
system2('strip target/release/rustdesk')
|
system2('strip target/release/rustdesk')
|
||||||
system2(
|
system2(
|
||||||
"sed -i 's/Version: .*/Version: %s/g' res/rpm-suse.spec" % version)
|
"sed -i 's/Version: .*/Version: %s/g' res/rpm-suse.spec" % version)
|
||||||
@@ -557,7 +1074,7 @@ def main():
|
|||||||
# 'mv target/release/bundle/deb/rustdesk*.deb ./flutter/rustdesk.deb')
|
# 'mv target/release/bundle/deb/rustdesk*.deb ./flutter/rustdesk.deb')
|
||||||
build_flutter_deb(version, features)
|
build_flutter_deb(version, features)
|
||||||
else:
|
else:
|
||||||
system2('cargo bundle --release --features ' + features)
|
system2('cargo --locked bundle --release --features ' + features)
|
||||||
if osx:
|
if osx:
|
||||||
system2(
|
system2(
|
||||||
'strip target/release/bundle/osx/RustDesk.app/Contents/MacOS/rustdesk')
|
'strip target/release/bundle/osx/RustDesk.app/Contents/MacOS/rustdesk')
|
||||||
|
|||||||
2
build.rs
2
build.rs
@@ -18,7 +18,7 @@ fn build_mac() {
|
|||||||
b.flag("-DNO_InputMonitoringAuthStatus=1");
|
b.flag("-DNO_InputMonitoringAuthStatus=1");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.file(file).compile("macos");
|
b.flag("-std=c++17").file(file).compile("macos");
|
||||||
println!("cargo:rerun-if-changed={}", file);
|
println!("cargo:rerun-if-changed={}", file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
143
docs/CODE_OF_CONDUCT-FR.md
Normal file
143
docs/CODE_OF_CONDUCT-FR.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
|
||||||
|
# Code de conduite des contributeurs
|
||||||
|
|
||||||
|
## Notre engagement
|
||||||
|
|
||||||
|
En tant que membres, contributeurs et responsables, nous nous engageons à faire
|
||||||
|
de la participation à notre communauté une expérience exempte de harcèlement pour
|
||||||
|
tous, indépendamment de l'âge, de la taille corporelle, du handicap visible ou
|
||||||
|
invisible, de l'origine ethnique, des caractéristiques sexuelles, de l'identité
|
||||||
|
et de l'expression de genre, du niveau d'expérience, de l'éducation, du statut
|
||||||
|
socio-économique, de la nationalité, de l'apparence personnelle, de la race, de
|
||||||
|
la religion ou de l'identité et de l'orientation sexuelle.
|
||||||
|
|
||||||
|
Nous nous engageons à agir et à interagir de manière à contribuer à une
|
||||||
|
communauté ouverte, accueillante, diversifiée, inclusive et saine.
|
||||||
|
|
||||||
|
## Nos standards
|
||||||
|
|
||||||
|
Exemples de comportements qui contribuent à un environnement positif pour notre
|
||||||
|
communauté :
|
||||||
|
|
||||||
|
* Faire preuve d'empathie et de bienveillance envers les autres
|
||||||
|
* Respecter les opinions, les points de vue et les expériences différents
|
||||||
|
* Donner et accepter gracieusement les retours constructifs
|
||||||
|
* Assumer ses responsabilités, s'excuser auprès des personnes affectées par nos
|
||||||
|
erreurs et apprendre de l'expérience
|
||||||
|
* Se concentrer sur ce qui est le mieux non seulement pour nous en tant
|
||||||
|
qu'individus, mais pour l'ensemble de la communauté
|
||||||
|
|
||||||
|
Exemples de comportements inacceptables :
|
||||||
|
|
||||||
|
* L'utilisation de langage ou d'images à caractère sexuel, et les attentions ou
|
||||||
|
avances sexuelles de quelque nature que ce soit
|
||||||
|
* Le trolling, les commentaires insultants ou désobligeants, et les attaques
|
||||||
|
personnelles ou politiques
|
||||||
|
* Le harcèlement public ou privé
|
||||||
|
* La publication d'informations privées d'autrui, telles qu'une adresse physique
|
||||||
|
ou électronique, sans autorisation explicite
|
||||||
|
* Tout autre comportement qui pourrait raisonnablement être considéré comme
|
||||||
|
inapproprié dans un cadre professionnel
|
||||||
|
|
||||||
|
## Responsabilités en matière d'application
|
||||||
|
|
||||||
|
Les responsables de la communauté sont chargés de clarifier et d'appliquer nos
|
||||||
|
standards de comportement acceptable et prendront des mesures correctives
|
||||||
|
appropriées et équitables en réponse à tout comportement qu'ils jugent
|
||||||
|
inapproprié, menaçant, offensant ou nuisible.
|
||||||
|
|
||||||
|
Les responsables de la communauté ont le droit et la responsabilité de
|
||||||
|
supprimer, modifier ou rejeter les commentaires, commits, code, modifications
|
||||||
|
du wiki, issues et autres contributions qui ne sont pas conformes à ce Code de
|
||||||
|
conduite, et communiqueront les raisons de leurs décisions de modération le cas
|
||||||
|
échéant.
|
||||||
|
|
||||||
|
## Portée
|
||||||
|
|
||||||
|
Ce Code de conduite s'applique dans tous les espaces communautaires, et
|
||||||
|
s'applique également lorsqu'une personne représente officiellement la communauté
|
||||||
|
dans les espaces publics. Les exemples de représentation de notre communauté
|
||||||
|
incluent l'utilisation d'une adresse e-mail officielle, la publication via un
|
||||||
|
compte de réseau social officiel, ou le fait d'agir en tant que représentant
|
||||||
|
désigné lors d'un événement en ligne ou hors ligne.
|
||||||
|
|
||||||
|
## Application
|
||||||
|
|
||||||
|
Les cas de comportements abusifs, harcelants ou autrement inacceptables peuvent
|
||||||
|
être signalés aux responsables de la communauté chargés de l'application à
|
||||||
|
[info@rustdesk.com](mailto:info@rustdesk.com).
|
||||||
|
Toutes les plaintes seront examinées et feront l'objet d'une enquête rapide et
|
||||||
|
équitable.
|
||||||
|
|
||||||
|
Tous les responsables de la communauté sont tenus de respecter la vie privée et
|
||||||
|
la sécurité de la personne ayant signalé un incident.
|
||||||
|
|
||||||
|
## Directives d'application
|
||||||
|
|
||||||
|
Les responsables de la communauté suivront ces Directives d'impact communautaire
|
||||||
|
pour déterminer les conséquences de toute action qu'ils jugent en violation de ce
|
||||||
|
Code de conduite :
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Impact communautaire** : Utilisation d'un langage inapproprié ou autre
|
||||||
|
comportement jugé non professionnel ou indésirable dans la communauté.
|
||||||
|
|
||||||
|
**Conséquence** : Un avertissement écrit et privé de la part des responsables de
|
||||||
|
la communauté, expliquant la nature de la violation et pourquoi le comportement
|
||||||
|
était inapproprié. Des excuses publiques peuvent être demandées.
|
||||||
|
|
||||||
|
### 2. Avertissement
|
||||||
|
|
||||||
|
**Impact communautaire** : Une violation par un incident isolé ou une série
|
||||||
|
d'actions.
|
||||||
|
|
||||||
|
**Conséquence** : Un avertissement avec des conséquences en cas de comportement
|
||||||
|
répété. Aucune interaction avec les personnes impliquées, y compris les
|
||||||
|
interactions non sollicitées avec les personnes chargées d'appliquer le Code de
|
||||||
|
conduite, pendant une période déterminée. Cela inclut d'éviter les interactions
|
||||||
|
dans les espaces communautaires ainsi que dans les canaux externes comme les
|
||||||
|
réseaux sociaux. Le non-respect de ces conditions peut entraîner une exclusion
|
||||||
|
temporaire ou permanente.
|
||||||
|
|
||||||
|
### 3. Exclusion temporaire
|
||||||
|
|
||||||
|
**Impact communautaire** : Une violation grave des standards communautaires, y
|
||||||
|
compris un comportement inapproprié persistant.
|
||||||
|
|
||||||
|
**Conséquence** : Une exclusion temporaire de toute interaction ou communication
|
||||||
|
publique avec la communauté pendant une période déterminée. Aucune interaction
|
||||||
|
publique ou privée avec les personnes impliquées, y compris les interactions non
|
||||||
|
sollicitées avec les personnes chargées d'appliquer le Code de conduite, n'est
|
||||||
|
autorisée pendant cette période. Le non-respect de ces conditions peut entraîner
|
||||||
|
une exclusion permanente.
|
||||||
|
|
||||||
|
### 4. Exclusion permanente
|
||||||
|
|
||||||
|
**Impact communautaire** : Démontrer un schéma de violation des standards
|
||||||
|
communautaires, y compris un comportement inapproprié persistant, le harcèlement
|
||||||
|
d'une personne, ou une agression envers des catégories de personnes ou leur
|
||||||
|
dénigrement.
|
||||||
|
|
||||||
|
**Conséquence** : Une exclusion permanente de toute interaction publique au sein
|
||||||
|
de la communauté.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
Ce Code de conduite est adapté du [Contributor Covenant][homepage], version 2.0,
|
||||||
|
disponible à l'adresse
|
||||||
|
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
|
||||||
|
|
||||||
|
Les Directives d'impact communautaire ont été inspirées par
|
||||||
|
[l'échelle d'application du code de conduite de Mozilla][Mozilla CoC].
|
||||||
|
|
||||||
|
Pour des réponses aux questions fréquentes sur ce code de conduite, consultez la
|
||||||
|
FAQ à l'adresse [https://www.contributor-covenant.org/faq][FAQ]. Des traductions
|
||||||
|
sont disponibles à l'adresse
|
||||||
|
[https://www.contributor-covenant.org/translations][translations].
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
|
||||||
|
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||||
|
[FAQ]: https://www.contributor-covenant.org/faq
|
||||||
|
[translations]: https://www.contributor-covenant.org/translations
|
||||||
85
docs/CODE_OF_CONDUCT-RO.md
Normal file
85
docs/CODE_OF_CONDUCT-RO.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Codul de Conduită al Contributorilor
|
||||||
|
|
||||||
|
## Angajamentul Nostru
|
||||||
|
|
||||||
|
Noi, ca membri, contribuitori și lideri, ne angajăm să facem ca participarea în comunitatea noastră să fie o experiență fără hărțuire pentru toată lumea, indiferent de vârstă, dimensiunea corpului, dizabilități vizibile sau invizibile, etnie, caracteristici sexuale, identitate și exprimare de gen, nivel de experiență, educație, statut socio-economic, naționalitate, aspect personal, rasă, religie sau identitate și orientare sexuală.
|
||||||
|
|
||||||
|
Ne angajăm să acționăm și să interacționăm în moduri care contribuie la o comunitate deschisă, primitoare, diversă, incluzivă și sănătoasă.
|
||||||
|
|
||||||
|
## Standardele Noastre
|
||||||
|
|
||||||
|
Exemple de comportamente care contribuie la un mediu pozitiv pentru comunitatea noastră includ:
|
||||||
|
|
||||||
|
* Demonstrarea empatiei și a bunătății față de ceilalți
|
||||||
|
* Respectarea opiniilor, punctelor de vedere și experiențelor diferite
|
||||||
|
* Oferirea și acceptarea cu grație a feedback-ului constructiv
|
||||||
|
* Asumarea responsabilității și cererea de scuze celor afectați de greșelile noastre și învățarea din experiență
|
||||||
|
* Concentrarea pe ceea ce este cel mai bun nu doar pentru noi ca indivizi, ci pentru întreaga comunitate
|
||||||
|
|
||||||
|
Exemple de comportamente inacceptabile includ:
|
||||||
|
|
||||||
|
* Utilizarea limbajului sau imaginilor sexualizate, precum și atenția sau avansurile sexuale de orice fel
|
||||||
|
* Trollare, insulte sau comentarii denigratoare și atacuri personale sau politice
|
||||||
|
* Hărțuire publică sau privată
|
||||||
|
* Publicarea informațiilor private ale altora, cum ar fi adresa fizică sau de e-mail, fără permisiunea explicită
|
||||||
|
* Alte comportamente care ar putea fi considerate inadecvate într-un cadru profesional
|
||||||
|
|
||||||
|
## Responsabilități de Aplicare
|
||||||
|
|
||||||
|
Liderii comunității sunt responsabili pentru clarificarea și aplicarea standardelor noastre de comportament acceptabil și vor lua măsuri corective adecvate și echitabile ca răspuns la orice comportament pe care îl consideră inadecvat, amenințător, ofensator sau dăunător.
|
||||||
|
|
||||||
|
Liderii comunității au dreptul și responsabilitatea de a elimina, edita sau respinge comentarii, commit-uri, cod, editări wiki, probleme și alte contribuții care nu se aliniază acestui Cod de Conduită și vor comunica motivele pentru deciziile de moderare atunci când este cazul.
|
||||||
|
|
||||||
|
## Domeniu de Aplicare
|
||||||
|
|
||||||
|
Acest Cod de Conduită se aplică în toate spațiile comunității și se aplică și atunci când un individ reprezintă oficial comunitatea în spații publice.
|
||||||
|
Exemple de reprezentare a comunității includ utilizarea unei adrese de e-mail oficiale, postarea printr-un cont oficial de social media sau acționarea ca reprezentant desemnat la un eveniment online sau offline.
|
||||||
|
|
||||||
|
## Aplicare
|
||||||
|
|
||||||
|
Cazurile de comportament abuziv, hărțuitor sau altfel inacceptabil pot fi raportate liderilor comunității responsabili pentru aplicare la [info@rustdesk.com](mailto:info@rustdesk.com).
|
||||||
|
Toate plângerile vor fi revizuite și investigate prompt și corect.
|
||||||
|
|
||||||
|
Toți liderii comunității sunt obligați să respecte confidențialitatea și securitatea persoanei care raportează orice incident.
|
||||||
|
|
||||||
|
## Ghiduri de Aplicare
|
||||||
|
|
||||||
|
Liderii comunității vor urma aceste Ghiduri privind Impactul Comunității pentru a stabili consecințele pentru orice acțiune pe care o consideră o încălcare a acestui Cod de Conduită:
|
||||||
|
|
||||||
|
### 1. Corectare
|
||||||
|
|
||||||
|
**Impact asupra comunității**: Utilizarea limbajului neadecvat sau alte comportamente considerate neprofesionale sau nedorite în comunitate.
|
||||||
|
|
||||||
|
**Consecință**: O avertizare scrisă și privată din partea liderilor comunității, oferind claritate asupra naturii încălcării și o explicație despre motivul pentru care comportamentul a fost inadecvat. Poate fi cerută o scuză publică.
|
||||||
|
|
||||||
|
### 2. Avertisment
|
||||||
|
|
||||||
|
**Impact asupra comunității**: Încălcare printr-un incident singular sau o serie de acțiuni.
|
||||||
|
|
||||||
|
**Consecință**: Un avertisment cu consecințe pentru continuarea comportamentului. Nicio interacțiune cu persoanele implicate, inclusiv interacțiuni nesolicitate cu cei care aplică Codul de Conduită, pentru o perioadă specificată. Aceasta include evitarea interacțiunilor în spațiile comunității, precum și pe canale externe, cum ar fi rețelele sociale. Încălcarea acestor termeni poate duce la o suspendare temporară sau permanentă.
|
||||||
|
|
||||||
|
### 3. Suspendare Temporară
|
||||||
|
|
||||||
|
**Impact asupra comunității**: O încălcare serioasă a standardelor comunității, inclusiv comportament neadecvat susținut.
|
||||||
|
|
||||||
|
**Consecință**: Suspendare temporară de la orice tip de interacțiune sau comunicare publică cu comunitatea pentru o perioadă specificată. Nicio interacțiune publică sau privată cu persoanele implicate, inclusiv interacțiuni nesolicitate cu cei care aplică Codul de Conduită, nu este permisă în această perioadă. Încălcarea acestor termeni poate duce la o interdicție permanentă.
|
||||||
|
|
||||||
|
### 4. Interdicție Permanentă
|
||||||
|
|
||||||
|
**Impact asupra comunității**: Demonstrând un tipar de încălcare a standardelor comunității, inclusiv comportament neadecvat susținut, hărțuire a unei persoane sau agresiune față de sau denigrare a unor grupuri de persoane.
|
||||||
|
|
||||||
|
**Consecință**: Interdicție permanentă de la orice tip de interacțiune publică în cadrul comunității.
|
||||||
|
|
||||||
|
## Atribuire
|
||||||
|
|
||||||
|
Acest Cod de Conduită este adaptat din [Contributor Covenant][homepage], versiunea 2.0, disponibil la [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
|
||||||
|
|
||||||
|
Ghidurile privind Impactul Comunității au fost inspirate de [scara de aplicare a codului de conduită Mozilla][Mozilla CoC].
|
||||||
|
|
||||||
|
Pentru răspunsuri la întrebări frecvente despre acest cod de conduită, vezi FAQ la [https://www.contributor-covenant.org/faq][FAQ]. Traduceri sunt disponibile la [https://www.contributor-covenant.org/translations][translations].
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
|
||||||
|
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||||
|
[FAQ]: https://www.contributor-covenant.org/faq
|
||||||
|
[translations]: https://www.contributor-covenant.org/translations
|
||||||
55
docs/CONTRIBUTING-FR.md
Normal file
55
docs/CONTRIBUTING-FR.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
|
||||||
|
# Contribuer à RustDesk
|
||||||
|
|
||||||
|
RustDesk accueille les contributions de tous. Voici les directives si vous
|
||||||
|
envisagez de nous aider :
|
||||||
|
|
||||||
|
## Contributions
|
||||||
|
|
||||||
|
Les contributions à RustDesk ou à ses dépendances doivent être soumises sous
|
||||||
|
forme de pull requests GitHub. Chaque pull request sera examinée par un
|
||||||
|
contributeur principal (une personne ayant la permission d'intégrer des
|
||||||
|
correctifs) et sera soit intégrée dans la branche principale, soit accompagnée
|
||||||
|
de retours sur les modifications requises. Toutes les contributions doivent
|
||||||
|
suivre ce format, même celles des contributeurs principaux.
|
||||||
|
|
||||||
|
Si vous souhaitez travailler sur une issue, veuillez d'abord la revendiquer en
|
||||||
|
commentant sur l'issue GitHub indiquant que vous souhaitez la traiter. Cela
|
||||||
|
permet d'éviter les efforts en double de la part des contributeurs sur la même
|
||||||
|
issue.
|
||||||
|
|
||||||
|
## Liste de vérification pour les pull requests
|
||||||
|
|
||||||
|
- Partez de la branche master et, si nécessaire, effectuez un rebase sur la
|
||||||
|
branche master actuelle avant de soumettre votre pull request. Si elle ne
|
||||||
|
fusionne pas proprement avec master, il vous sera peut-être demandé de
|
||||||
|
rebaser vos modifications.
|
||||||
|
|
||||||
|
- Les commits doivent être aussi petits que possible, tout en s'assurant que
|
||||||
|
chaque commit est correct de manière indépendante (c.-à-d. que chaque commit
|
||||||
|
doit compiler et passer les tests).
|
||||||
|
|
||||||
|
- Les commits doivent être accompagnés d'une signature Developer Certificate of
|
||||||
|
Origin (http://developercertificate.org), indiquant que vous (et votre
|
||||||
|
employeur le cas échéant) acceptez d'être liés par les termes de la
|
||||||
|
[licence du projet](../LICENCE). Dans git, il s'agit de l'option `-s` de
|
||||||
|
`git commit`.
|
||||||
|
|
||||||
|
- Si votre correctif n'est pas examiné ou si vous avez besoin qu'une personne
|
||||||
|
spécifique l'examine, vous pouvez @-mentionner un relecteur pour demander une
|
||||||
|
revue dans la pull request ou un commentaire, ou vous pouvez demander une
|
||||||
|
revue par [e-mail](mailto:info@rustdesk.com).
|
||||||
|
|
||||||
|
- Ajoutez des tests relatifs au bug corrigé ou à la nouvelle fonctionnalité.
|
||||||
|
|
||||||
|
Pour des instructions git spécifiques, consultez le
|
||||||
|
[GitHub workflow 101](https://github.com/servo/servo/wiki/GitHub-workflow).
|
||||||
|
|
||||||
|
## Conduite
|
||||||
|
|
||||||
|
https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md
|
||||||
|
|
||||||
|
## Communication
|
||||||
|
|
||||||
|
Les contributeurs de RustDesk se retrouvent fréquemment sur
|
||||||
|
[Discord](https://discord.gg/nDceKgxnkV).
|
||||||
31
docs/CONTRIBUTING-RO.md
Normal file
31
docs/CONTRIBUTING-RO.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Contribuții la RustDesk
|
||||||
|
|
||||||
|
RustDesk primește cu plăcere contribuții din partea tuturor. Iată ghidurile dacă te gândești să ne ajuți:
|
||||||
|
|
||||||
|
## Contribuții
|
||||||
|
|
||||||
|
Contribuțiile la RustDesk sau la dependențele sale ar trebui făcute sub forma de pull request-uri pe GitHub. Fiecare pull request va fi revizuit de un contributor principal (cineva cu permisiunea de a aplica patch-uri) și fie va fi integrat în arborele principal, fie vor fi oferite sugestii pentru modificările necesare. Toate contribuțiile trebuie să urmeze acest format, chiar și cele ale contributorilor principali.
|
||||||
|
|
||||||
|
Dacă dorești să lucrezi la o problemă, te rugăm să o revendici mai întâi comentând pe GitHub issue-ul pe care vrei să lucrezi. Aceasta previne eforturi duplicate din partea contributorilor asupra aceleiași probleme.
|
||||||
|
|
||||||
|
## Lista de verificare pentru Pull Request
|
||||||
|
|
||||||
|
- Creează un branch din branch-ul `master` și, dacă este necesar, fă rebase la branch-ul `master` curent înainte de a trimite pull request-ul. Dacă nu se poate integra curat cu `master`, ți se poate cere să faci rebase la modificările tale.
|
||||||
|
|
||||||
|
- Commit-urile ar trebui să fie cât mai mici posibil, asigurând totodată că fiecare commit este corect independent (adică fiecare commit ar trebui să compileze și să treacă testele).
|
||||||
|
|
||||||
|
- Commit-urile trebuie să fie însoțite de un semnătura Developer Certificate of Origin (http://developercertificate.org), care indică faptul că tu (și angajatorul tău, dacă este cazul) ești de acord să respecți termenii [licenței proiectului](../LICENCE). În git, aceasta este opțiunea `-s` la `git commit`.
|
||||||
|
|
||||||
|
- Dacă patch-ul tău nu este revizuit sau ai nevoie ca o anumită persoană să-l revizuiască, poți @-reply unui reviewer cerând o revizuire în pull request sau într-un comentariu, sau poți solicita o revizuire prin [email](mailto:info@rustdesk.com).
|
||||||
|
|
||||||
|
- Adaugă teste relevante pentru bug-ul corectat sau pentru funcționalitatea nouă.
|
||||||
|
|
||||||
|
Pentru instrucțiuni specifice git, vezi [GitHub workflow 101](https://github.com/servo/servo/wiki/GitHub-workflow).
|
||||||
|
|
||||||
|
## Conduită
|
||||||
|
|
||||||
|
[Codul de Conduită RustDesk](https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md)
|
||||||
|
|
||||||
|
## Comunicare
|
||||||
|
|
||||||
|
Contributorii RustDesk frecventează [Discord](https://discord.gg/nDceKgxnkV).
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Contributing to RustDesk
|
# Contributing to RustDesk
|
||||||
|
|
||||||
RustDesk welcomes contribution from everyone. Here are the guidelines if you are
|
RustDesk welcomes contributions from everyone. Here are the guidelines if you are
|
||||||
thinking of helping us:
|
thinking of helping us:
|
||||||
|
|
||||||
## Contributions
|
## Contributions
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../res/logo-header.svg" alt="RustDesk - Your remote desktop"><br>
|
<img src="../res/logo-header.svg" alt="RustDesk - Dein Remote-Desktop"><br>
|
||||||
<a href="#freie-öffentliche-server">Server</a> •
|
|
||||||
<a href="#grobe-schritte-zum-kompilieren">Kompilieren</a> •
|
<a href="#grobe-schritte-zum-kompilieren">Kompilieren</a> •
|
||||||
<a href="#auf-docker-kompilieren">Docker</a> •
|
<a href="#auf-docker-kompilieren">Docker</a> •
|
||||||
<a href="#dateistruktur">Dateistruktur</a> •
|
<a href="#dateistruktur">Dateistruktur</a> •
|
||||||
<a href="#screenshots">Screenshots</a><br>
|
<a href="#screenshots">Screenshots</a><br>
|
||||||
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-PTBR.md">Português (Brasil)</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-DA.md">Dansk</a>] | [<a href="README-GR.md">Ελληνικά</a>]<br>
|
[<a href="docs/README-UA.md">Українська</a>] | [<a href="docs/README-CS.md">česky</a>] | [<a href="docs/README-ZH.md">中文</a>] | [<a href="docs/README-HU.md">Magyar</a>] | [<a href="docs/README-ES.md">Español</a>] | [<a href="docs/README-FA.md">فارسی</a>] | [<a href="docs/README-FR.md">Français</a>] | [<a href="docs/README-DE.md">Deutsch</a>] | [<a href="docs/README-PL.md">Polski</a>] | [<a href="docs/README-ID.md">Indonesian</a>] | [<a href="docs/README-FI.md">Suomi</a>] | [<a href="docs/README-ML.md">മലയാളം</a>] | [<a href="docs/README-JP.md">日本語</a>] | [<a href="docs/README-NL.md">Nederlands</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Русский</a>] | [<a href="docs/README-PTBR.md">Português (Brasil)</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">한국어</a>] | [<a href="docs/README-AR.md">العربي</a>] | [<a href="docs/README-VN.md">Tiếng Việt</a>] | [<a href="docs/README-DA.md">Dansk</a>] | [<a href="docs/README-GR.md">Ελληνικά</a>] | [<a href="docs/README-TR.md">Türkçe</a>] | [<a href="docs/README-NO.md">Norsk</a>] | [<a href="docs/README-RO.md">Română</a>]<br>
|
||||||
<b>Wir brauchen Ihre Hilfe, um dieses README, die <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk-Benutzeroberfläche</a> und die <a href="https://github.com/rustdesk/doc.rustdesk.com">Dokumentation</a> in Ihre Muttersprache zu übersetzen.</b>
|
<b>Wir brauchen Ihre Hilfe, um dieses README, die <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk-Benutzeroberfläche</a> und die <a href="https://github.com/rustdesk/doc.rustdesk.com">Dokumentation</a> in Ihre Muttersprache zu übersetzen.</b>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> [!Vorsicht]
|
> [!Caution]
|
||||||
> **Haftungsausschluss bei Missbrauch::** <br>
|
> **Haftungsausschluss bei Missbrauch::** <br>
|
||||||
> Die Entwickler von RustDesk billigen oder unterstützen keine unethische oder illegale Nutzung dieser Software. Missbrauch, wie unbefugter Zugriff, unbefugte Kontrolle oder Verletzung der Privatsphäre, verstößt strikt gegen unsere Richtlinien. Die Autoren sind nicht verantwortlich für jeglichen Missbrauch der Anwendung.
|
> Die Entwickler von RustDesk billigen oder unterstützen keine unethische oder illegale Nutzung dieser Software. Missbrauch, wie unbefugter Zugriff, unbefugte Kontrolle oder Verletzung der Privatsphäre, verstößt strikt gegen unsere Richtlinien. Die Autoren sind nicht verantwortlich für jeglichen Missbrauch der Anwendung.
|
||||||
|
|
||||||
@@ -28,11 +27,14 @@ RustDesk heißt jegliche Mitarbeit willkommen. Schauen Sie sich [CONTRIBUTING-DE
|
|||||||
|
|
||||||
[**Programm herunterladen**](https://github.com/rustdesk/rustdesk/releases)
|
[**Programm herunterladen**](https://github.com/rustdesk/rustdesk/releases)
|
||||||
|
|
||||||
[**Nächtliche Erstellung**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
|
[**Nightly Builds**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
|
||||||
|
|
||||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
[<img src="https://f-droid.org/badge/get-it-on.png"
|
||||||
alt="Get it on F-Droid"
|
alt="Get it on F-Droid"
|
||||||
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
|
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
|
||||||
|
[<img src="https://flathub.org/api/badge?svg&locale=en"
|
||||||
|
alt="Get it on Flathub"
|
||||||
|
height="80">](https://flathub.org/apps/com.rustdesk.RustDesk)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
|
|
||||||
@@ -64,18 +66,19 @@ Bitte laden Sie die dynamische Bibliothek Sciter selbst herunter.
|
|||||||
```sh
|
```sh
|
||||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
||||||
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
|
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
|
||||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### openSUSE Tumbleweed
|
### openSUSE Tumbleweed
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||||
```
|
```
|
||||||
|
|
||||||
### Fedora 28 (CentOS 8)
|
### Fedora 28 (CentOS 8)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel
|
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||||
```
|
```
|
||||||
|
|
||||||
### Arch (Manjaro)
|
### Arch (Manjaro)
|
||||||
@@ -114,7 +117,7 @@ cd
|
|||||||
```sh
|
```sh
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
source $HOME/.cargo/env
|
source $HOME/.cargo/env
|
||||||
git clone https://github.com/rustdesk/rustdesk
|
git clone --recurse-submodules https://github.com/rustdesk/rustdesk
|
||||||
cd rustdesk
|
cd rustdesk
|
||||||
mkdir -p target/debug
|
mkdir -p target/debug
|
||||||
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
|
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
|
||||||
@@ -129,6 +132,7 @@ Beginnen Sie damit, das Repository zu klonen und den Docker-Container zu bauen:
|
|||||||
```sh
|
```sh
|
||||||
git clone https://github.com/rustdesk/rustdesk
|
git clone https://github.com/rustdesk/rustdesk
|
||||||
cd rustdesk
|
cd rustdesk
|
||||||
|
git submodule update --init --recursive
|
||||||
docker build -t "rustdesk-builder" .
|
docker build -t "rustdesk-builder" .
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -157,6 +161,7 @@ Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDes
|
|||||||
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: Video-Codec, Konfiguration, TCP/UDP-Wrapper, Protokoll-Puffer, fs-Funktionen für Dateitransfer und ein paar andere nützliche Funktionen
|
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: Video-Codec, Konfiguration, TCP/UDP-Wrapper, Protokoll-Puffer, fs-Funktionen für Dateitransfer und ein paar andere nützliche Funktionen
|
||||||
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: Bildschirmaufnahme
|
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: Bildschirmaufnahme
|
||||||
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: Plattformspezifische Maus- und Tastatursteuerung
|
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: Plattformspezifische Maus- und Tastatursteuerung
|
||||||
|
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: Datei kopieren und einfügen Implementierung für Windows, Linux, macOS.
|
||||||
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI
|
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI
|
||||||
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: Audio/Zwischenablage/Eingabe/Videodienste und Netzwerkverbindungen
|
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: Audio/Zwischenablage/Eingabe/Videodienste und Netzwerkverbindungen
|
||||||
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: Starten einer Peer-Verbindung
|
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: Starten einer Peer-Verbindung
|
||||||
@@ -167,10 +172,11 @@ Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDes
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||

|
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ Les versions de bureau utilisent [sciter](https://sciter.com/) pour l'interface
|
|||||||
- Installez [vcpkg](https://github.com/microsoft/vcpkg), et définissez correctement la variable d'environnement `VCPKG_ROOT`.
|
- Installez [vcpkg](https://github.com/microsoft/vcpkg), et définissez correctement la variable d'environnement `VCPKG_ROOT`.
|
||||||
|
|
||||||
- Windows : vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static
|
- Windows : vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static
|
||||||
- Linux/Osx : vcpkg install libvpx libyuv opus aom
|
- Linux/macOS : vcpkg install libvpx libyuv opus aom
|
||||||
|
|
||||||
- Exécuter `cargo run`
|
- Exécutez `cargo run`
|
||||||
|
|
||||||
## Comment compiler/build sous Linux
|
## Comment compiler/build sous Linux
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ cd rustdesk
|
|||||||
mkdir -p target/debug
|
mkdir -p target/debug
|
||||||
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
|
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
|
||||||
mv libsciter-gtk.so target/debug
|
mv libsciter-gtk.so target/debug
|
||||||
Exécution du cargo
|
cargo run
|
||||||
```
|
```
|
||||||
|
|
||||||
## Comment construire avec Docker
|
## Comment construire avec Docker
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../res/logo-header.svg" alt="RustDesk - Your remote desktop"><br>
|
<img src="../res/logo-header.svg" alt="RustDesk - Your remote desktop"><br>
|
||||||
<a href="#빌드를 위한 원시 단계">빌드</a> •
|
<a href="#빌드를_위한_원시_단계">빌드</a> •
|
||||||
<a href="#Docker로 빌드하는 방법">Docker</a> •
|
<a href="#Docker로_빌드하는_방법">Docker</a> •
|
||||||
<a href="#파일 구조">구조</a> •
|
<a href="#파일_구조">구조</a> •
|
||||||
<a href="#스크린샷">스냇샷</a><br>
|
<a href="#스크린샷">스냅샷</a><br>
|
||||||
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-PTBR.md">Português (Brasil)</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-DA.md">Dansk</a>] | [<a href="README-GR.md">Ελληνικά</a>] | [<a href="README-TR.md">Türkçe</a>] | [<a href="README-NO.md">Norsk</a>]<br>
|
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-PTBR.md">Português (Brasil)</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-DA.md">Dansk</a>] | [<a href="README-GR.md">Ελληνικά</a>] | [<a href="README-TR.md">Türkçe</a>] | [<a href="README-NO.md">Norsk</a>] | [<a href="README-RO.md">Română</a>]<br>
|
||||||
<b>이 README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> 및 <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk 문서</a>를 귀하의 모국어로 번역하는 데 도움이 필요합니다</b>
|
<b>이 README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> 및 <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk 문서</a>를 귀하의 모국어로 번역하는 데 도움이 필요합니다</b>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -46,9 +46,9 @@ Sciter 동적 라이브러리를 직접 다운로드하세요.
|
|||||||
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
|
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
|
||||||
[macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
|
[macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
|
||||||
|
|
||||||
## 빌드를 위한 원시 단계
|
## 빌드를_위한_원시_단계
|
||||||
|
|
||||||
- Rust 개발 환경과 C++ 빌드 환경을 준비합니다
|
- Rust 개발 환경과 C++ 빌드 환경 준비
|
||||||
|
|
||||||
- [vcpkg](https://github.com/microsoft/vcpkg)를 설치하고 `VCPKG_ROOT` 환경 변수를 올바르게 설정합니다
|
- [vcpkg](https://github.com/microsoft/vcpkg)를 설치하고 `VCPKG_ROOT` 환경 변수를 올바르게 설정합니다
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ mv libsciter-gtk.so target/debug
|
|||||||
VCPKG_ROOT=$HOME/vcpkg cargo run
|
VCPKG_ROOT=$HOME/vcpkg cargo run
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker로 빌드하는 방법
|
## Docker로_빌드하는_방법
|
||||||
|
|
||||||
먼저 리포지토리를 복제하고 Docker 컨테이너를 빌드합니다:
|
먼저 리포지토리를 복제하고 Docker 컨테이너를 빌드합니다:
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ target/release/rustdesk
|
|||||||
|
|
||||||
RustDesk 리포지토리의 루트에서 이러한 명령을 실행하고 있는지 확인하세요. 그렇지 않으면 응용 프로그램이 필요한 리소스를 찾지 못할 수 있습니다. 또한 `install` 또는 `run` 과 같은 다른 cargo 하위 명령은 호스트가 아닌 컨테이너 내부에 프로그램을 설치하거나 실행하므로 현재 이 방법을 통해 지원되지 않는다는 점에 유의하세요.
|
RustDesk 리포지토리의 루트에서 이러한 명령을 실행하고 있는지 확인하세요. 그렇지 않으면 응용 프로그램이 필요한 리소스를 찾지 못할 수 있습니다. 또한 `install` 또는 `run` 과 같은 다른 cargo 하위 명령은 호스트가 아닌 컨테이너 내부에 프로그램을 설치하거나 실행하므로 현재 이 방법을 통해 지원되지 않는다는 점에 유의하세요.
|
||||||
|
|
||||||
## 파일 구조
|
## 파일_구조
|
||||||
|
|
||||||
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: 비디오 코덱, 구성, tcp/udp wrapper, protobuf, 파일 전송을 위한 fs 함수 및 기타 유틸리티 함수
|
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: 비디오 코덱, 구성, tcp/udp wrapper, protobuf, 파일 전송을 위한 fs 함수 및 기타 유틸리티 함수
|
||||||
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: 화면 캡쳐
|
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: 화면 캡쳐
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ Porozmawiaj z nami na: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](http
|
|||||||
|
|
||||||
[](https://rustdesk.com/pricing.html)
|
[](https://rustdesk.com/pricing.html)
|
||||||
|
|
||||||
Kolejny program do zdalnego pulpitu, napisany w Rust. Działa od samego początku, nie wymaga konfiguracji. Masz pełną kontrolę nad swoimi danymi, bez obaw o bezpieczeństwo. Możesz skorzystać z naszego darmowego serwera publicznego, [skonfigurować własny](https://rustdesk.com/server), lub [napisać własny serwer](https://github.com/rustdesk/rustdesk-server-demo).
|
## O projekcie
|
||||||
|
|
||||||
|
RustDesk to wieloplatformowe oprogramowanie do zdalnego pulpitu, napisane w języku Rust, zaprojektowane z myślą o prostocie wdrożenia, bezpieczeństwie i pełnej kontroli użytkownika nad danymi. Aplikacja działa od razu po uruchomieniu i nie wymaga skomplikowanej konfiguracji. Możesz skorzystać z naszego darmowego serwera publicznego, [skonfigurować własny](https://rustdesk.com/server), lub [napisać własny serwer](https://github.com/rustdesk/rustdesk-server-demo).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ RustDesk zaprasza do współpracy każdego. Zobacz [`docs/CONTRIBUTING-PL.md`](C
|
|||||||
|
|
||||||
## Zależności
|
## Zależności
|
||||||
|
|
||||||
Wersje desktopowe używają [sciter](https://sciter.com/) dla GUI, proszę pobrać samodzielnie bibliotekę sciter.
|
Wersje desktopowe korzystają z biblioteki [sciter](https://sciter.com/) jako silnika GUI. Bibliotekę Sciter należy pobrać i zainstalować samodzielnie.
|
||||||
|
|
||||||
[Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) |
|
[Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) |
|
||||||
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
|
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
|
||||||
|
|||||||
@@ -1,55 +1,82 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../res/logo-header.svg" alt="RustDesk - Seu desktop remoto"><br>
|
<img src="../res/logo-header.svg" alt="RustDesk - Seu desktop remoto"><br>
|
||||||
<a href="#servidores-públicos-grátis">Servidores</a> •
|
<a href="#compilar">Compilar</a> •
|
||||||
<a href="#compilação-crua">Compilar</a> •
|
<a href="#como-compilar-com-o-docker">Docker</a> •
|
||||||
<a href="#como-compilar-com-docker">Docker</a> •
|
|
||||||
<a href="#estrutura-de-arquivos">Estrutura</a> •
|
<a href="#estrutura-de-arquivos">Estrutura</a> •
|
||||||
<a href="#screenshots">Screenshots</a><br>
|
<a href="#capturas-de-tela">Capturas de Tela</a><br>
|
||||||
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-GR.md">Ελληνικά</a>]<br>
|
[<a href="../README.md">Inglês</a>] | [<a href="docs/README-UA.md">Ucraniano</a>] | [<a href="docs/README-CS.md">Tcheco</a>] | [<a href="docs/README-ZH.md">Chinês</a>] | [<a href="docs/README-HU.md">Húngaro</a>] | [<a href="docs/README-ES.md">Espanhol</a>] | [<a href="docs/README-FA.md">Persa</a>] | [<a href="docs/README-FR.md">Francês</a>] | [<a href="docs/README-DE.md">Alemão</a>] | [<a href="docs/README-PL.md">Polonês</a>] | [<a href="docs/README-ID.md">Indonésio</a>] | [<a href="docs/README-FI.md">Finlandês</a>] | [<a href="docs/README-ML.md">Malaiala</a>] | [<a href="docs/README-JP.md">Japonês</a>] | [<a href="docs/README-NL.md">Holandês</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Russo</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">Coreano</a>] | [<a href="docs/README-AR.md">Árabe</a>] | [<a href="docs/README-VN.md">Vietnamita</a>] | [<a href="docs/README-DA.md">Dinamarquês</a>] | [<a href="docs/README-GR.md">Grego</a>] | [<a href="docs/README-TR.md">Turco</a>] | [<a href="docs/README-NO.md">Norueguês</a>] | [<a href="docs/README-RO.md">Romeno</a>]<br>
|
||||||
<b>Precisamos de sua ajuda para traduzir este README e a <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">UI do RustDesk</a> para sua língua nativa</b>
|
<b>Precisamos da sua ajuda para traduzir este README, a <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">Interface do RustDesk</a> e a <a href="https://github.com/rustdesk/doc.rustdesk.com">Documentação do RustDesk</a> para o seu idioma nativo</b>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
> [!Caution]
|
||||||
|
> **Aviso de Isenção de Responsabilidade por Uso Indevido:** <br>
|
||||||
|
> Os desenvolvedores do RustDesk não toleram ou apoiam qualquer uso antiético ou ilegal deste software. O uso indevido, como acesso não autorizado, controle ou invasão de privacidade, viola estritamente nossas diretrizes. Os autores não são responsáveis por qualquer uso indevido do aplicativo.
|
||||||
|
|
||||||
|
|
||||||
Converse conosco: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
Converse conosco: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||||
|
|
||||||
[](https://rustdesk.com/pricing.html)
|
[](https://rustdesk.com/pricing.html)
|
||||||
|
|
||||||
Mais um software de desktop remoto, escrito em Rust. Funciona por padrão, sem necessidade de configuração. Você tem completo controle de seus dados, sem se preocupar com segurança. Você pode usar nossos servidores de rendezvous/relay, [configurar seu próprio](https://rustdesk.com/server), ou [escrever seu próprio servidor de rendezvous/relay](https://github.com/rustdesk/rustdesk-server-demo).
|
Mais uma solução de desktop remoto, escrita em Rust. Funciona imediatamente, sem necessidade de configuração. Você tem controle total dos seus dados, sem preocupações com segurança. Você pode usar nosso servidor de conexão/retransmissão (rendezvous/relay), [configurar o seu próprio](https://rustdesk.com/server) ou [escrever seu próprio servidor de conexão/retransmissão](https://github.com/rustdesk/rustdesk-server-demo).
|
||||||
|
|
||||||
RustDesk acolhe contribuições de todos. Leia [`docs/CONTRIBUTING.md`](CONTRIBUTING.md) para ver como começar.
|

|
||||||
|
|
||||||
[**DOWNLOAD DE BINÁRIOS**](https://github.com/rustdesk/rustdesk/releases)
|
O RustDesk acolhe a contribuição de todos. Veja [CONTRIBUTING.md](docs/CONTRIBUTING.md) para ajuda em como começar.
|
||||||
|
|
||||||
|
[**Perguntas Frequentes (FAQ)**](https://github.com/rustdesk/rustdesk/wiki/FAQ)
|
||||||
|
|
||||||
|
[**DOWNLOAD DOS BINÁRIOS**](https://github.com/rustdesk/rustdesk/releases)
|
||||||
|
|
||||||
|
[**VERSÕES NIGHTLY (EM DESENVOLVIMENTO)**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
|
||||||
|
|
||||||
|
[<img src="https://f-droid.org/badge/get-it-on.png"
|
||||||
|
alt="Baixe no F-Droid"
|
||||||
|
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
|
||||||
|
[<img src="https://flathub.org/api/badge?svg&locale=en"
|
||||||
|
alt="Baixe no Flathub"
|
||||||
|
height="80">](https://flathub.org/apps/com.rustdesk.RustDesk)
|
||||||
|
|
||||||
## Dependências
|
## Dependências
|
||||||
|
|
||||||
Versões de desktop utilizam [sciter](https://sciter.com/) para a GUI, por favor baixe a biblioteca dinâmica sciter por conta própria.
|
As versões de desktop usam Flutter ou Sciter (descontinuado) para a interface gráfica (GUI). Este tutorial é apenas para o Sciter, por ser mais fácil e amigável para começar. Verifique nosso [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) para instruções de compilação da versão em Flutter.
|
||||||
|
|
||||||
|
Por favor, faça o download da biblioteca dinâmica do Sciter por conta própria.
|
||||||
|
|
||||||
[Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) |
|
[Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) |
|
||||||
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
|
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
|
||||||
[MacOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
|
[macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
|
||||||
|
|
||||||
## Compilação crua
|
## Passos básicos para compilar
|
||||||
|
|
||||||
- Prepare seu ambiente de desenvolvimento Rust e ambiente de compilação C++
|
- Prepare seu ambiente de desenvolvimento Rust e o ambiente de compilação C++
|
||||||
|
|
||||||
- Instale [vcpkg](https://github.com/microsoft/vcpkg), e configure a variável de ambiente `VCPKG_ROOT` corretamente
|
- Instale o [vcpkg](https://github.com/microsoft/vcpkg) e configure a variável de ambiente `VCPKG_ROOT` corretamente
|
||||||
|
|
||||||
- Windows: vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static
|
- Windows: `vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static`
|
||||||
- Linux/MacOS: vcpkg install libvpx libyuv opus aom
|
- Linux/macOS: `vcpkg install libvpx libyuv opus aom`
|
||||||
|
|
||||||
- Execute `cargo run`
|
- Execute `cargo run`
|
||||||
|
|
||||||
## Como compilar no Linux
|
## [Compilar](https://rustdesk.com/docs/en/dev/build/)
|
||||||
|
|
||||||
|
## Como Compilar no Linux
|
||||||
|
|
||||||
### Ubuntu 18 (Debian 10)
|
### Ubuntu 18 (Debian 10)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo apt install -y g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake
|
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### openSUSE Tumbleweed
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||||
```
|
```
|
||||||
|
|
||||||
### Fedora 28 (CentOS 8)
|
### Fedora 28 (CentOS 8)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel
|
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||||
```
|
```
|
||||||
|
|
||||||
### Arch (Manjaro)
|
### Arch (Manjaro)
|
||||||
@@ -58,7 +85,7 @@ sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-
|
|||||||
sudo pacman -Syu --needed unzip git cmake gcc curl wget yasm nasm zip make pkg-config clang gtk3 xdotool libxcb libxfixes alsa-lib pipewire
|
sudo pacman -Syu --needed unzip git cmake gcc curl wget yasm nasm zip make pkg-config clang gtk3 xdotool libxcb libxfixes alsa-lib pipewire
|
||||||
```
|
```
|
||||||
|
|
||||||
### Instale vcpkg
|
### Instalar o vcpkg
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/microsoft/vcpkg
|
git clone https://github.com/microsoft/vcpkg
|
||||||
@@ -70,7 +97,7 @@ export VCPKG_ROOT=$HOME/vcpkg
|
|||||||
vcpkg/vcpkg install libvpx libyuv opus aom
|
vcpkg/vcpkg install libvpx libyuv opus aom
|
||||||
```
|
```
|
||||||
|
|
||||||
### Conserte libvpx (Para o Fedora)
|
### Corrigir o libvpx (Para Fedora)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd vcpkg/buildtrees/libvpx/src
|
cd vcpkg/buildtrees/libvpx/src
|
||||||
@@ -83,12 +110,12 @@ cp libvpx.a $HOME/vcpkg/installed/x64-linux/lib/
|
|||||||
cd
|
cd
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compile
|
### Compilar
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
source $HOME/.cargo/env
|
source $HOME/.cargo/env
|
||||||
git clone https://github.com/rustdesk/rustdesk
|
git clone --recurse-submodules https://github.com/rustdesk/rustdesk
|
||||||
cd rustdesk
|
cd rustdesk
|
||||||
mkdir -p target/debug
|
mkdir -p target/debug
|
||||||
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
|
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
|
||||||
@@ -96,57 +123,57 @@ mv libsciter-gtk.so target/debug
|
|||||||
VCPKG_ROOT=$HOME/vcpkg cargo run
|
VCPKG_ROOT=$HOME/vcpkg cargo run
|
||||||
```
|
```
|
||||||
|
|
||||||
## Como compilar com Docker
|
## Como compilar com o Docker
|
||||||
|
|
||||||
Comece clonando o repositório e montando o container docker:
|
Comece clonando o repositório e construindo o contêiner Docker:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/rustdesk/rustdesk
|
git clone https://github.com/rustdesk/rustdesk
|
||||||
cd rustdesk
|
cd rustdesk
|
||||||
|
git submodule update --init --recursive
|
||||||
docker build -t "rustdesk-builder" .
|
docker build -t "rustdesk-builder" .
|
||||||
```
|
```
|
||||||
|
|
||||||
Então, sempre que precisar compilar a aplicação, execute este comando:
|
Depois, cada vez que precisar compilar o aplicativo, execute o seguinte comando:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder
|
docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder
|
||||||
```
|
```
|
||||||
|
|
||||||
Note que a primeira compilação pode demorar mais antes que as dependências sejam armazenadas em cache, as compilações subsequentes serão mais rápidas. Adicionalmente, se você precisar especificar argumentos diferentes para o comando de compilação, você pode fazê-lo ao final do comando na posição do `<OPTIONAL-ARGS>`. Por exemplo, se você gostaria de compilar uma versão de release otimizada, você executaria o comando acima seguido de `--release`. O executável gerado estará disponível no diretório alvo no seu sistema, e pode ser executado com:
|
Note que a primeira compilação pode demorar mais até que as dependências sejam armazenadas em cache; as compilações subsequentes serão mais rápidas. Além disso, se você precisar especificar argumentos diferentes para o comando de compilação, poderá fazê-lo ao final do comando na posição `<ARGUMENTOS-OPCIONAIS>`. Por exemplo, se você quiser compilar uma versão de lançamento (release) otimizada, executaria o comando acima seguido de `--release`. O executável resultante estará disponível na pasta `target` do seu sistema e pode ser executado com:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
target/debug/rustdesk
|
target/debug/rustdesk
|
||||||
```
|
```
|
||||||
|
|
||||||
Ou, se estiver rodando um executável de release:
|
Ou, se estiver executando o executável de lançamento:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
target/release/rustdesk
|
target/release/rustdesk
|
||||||
```
|
```
|
||||||
|
|
||||||
Por favor verifique que está executando estes comandos da raiz do repositório do RustDesk, senão a aplicação pode não encontrar os recursos necessários. Note também que outros subcomandos do cargo como `install` ou `run` não são suportados atualmente via este método, já que eles iriam instalar ou rodar o programa dentro do container ao invés do host.
|
Certifique-se de executar esses comandos a partir da raiz do repositório do RustDesk, do contrário o aplicativo pode não encontrar os recursos necessários. Note também que outros subcomandos do cargo, como `install` ou `run`, não são suportados atualmente por este método, pois instalariam ou executariam o programa dentro do contêiner em vez de no sistema hospedeiro.
|
||||||
|
|
||||||
## Estrutura de arquivos
|
## Estrutura de Arquivos
|
||||||
|
|
||||||
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: codec de vídeo, configurações, wrapper de tcp/udp, protobuf, funções de sistema de arquivos para transferência de arquivos, e outras funções utilitárias
|
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: codec de vídeo, configuração, encapsulador (wrapper) tcp/udp, protobuf, funções de sistema de arquivos para transferência de arquivos e algumas outras funções utilitárias.
|
||||||
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: captura de tela
|
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: captura de tela.
|
||||||
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: controle de teclado/mouse específico a cada plataforma
|
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: controle de teclado/mouse específico de cada plataforma.
|
||||||
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI
|
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: implementação de copiar e colar arquivos para Windows, Linux e macOS.
|
||||||
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: serviços de áudio/área de transferência/entrada/vídeo, e conexões de rede
|
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: interface Sciter antiga (descontinuada).
|
||||||
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: iniciar uma conexão "peer to peer"
|
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: serviços de áudio/área de transferência/entrada/vídeo e conexões de rede.
|
||||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunicação com [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguardar pela conexão remota direta (TCP hole punching) ou conexão indireta (relayed)
|
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: inicia uma conexão direta (peer connection).
|
||||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico a cada plataforma
|
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunica-se com o [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguarda por conexão remota direta (perfuração de túnel TCP / hole punching) ou retransmitida.
|
||||||
|
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico de cada plataforma.
|
||||||
|
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: código Flutter para desktop e dispositivos móveis.
|
||||||
|
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript para o cliente web do Flutter.
|
||||||
|
|
||||||
> [!Cuidadob]
|
## Capturas de Tela
|
||||||
> **Aviso de uso indevido:** <br>
|
|
||||||
> Os desenvolvedores do RustDesk não aprovam nem apoiam qualquer uso antiético ou ilegal deste software. O uso indevido, como acesso não autorizado, controle ou invasão de privacidade, é estritamente contra nossas diretrizes. Os autores não são responsáveis por qualquer uso indevido da aplicação.
|
|
||||||
|
|
||||||
## Screenshots
|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|
|
||||||
|
|||||||
181
docs/README-RO.md
Normal file
181
docs/README-RO.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="../res/logo-header.svg" alt="RustDesk - desktopul tău la distanță"><br>
|
||||||
|
<a href="../README.md#raw-steps-to-build">Construire</a> •
|
||||||
|
<a href="../README.md#how-to-build-with-docker">Docker</a> •
|
||||||
|
<a href="../README.md#file-structure">Structură</a> •
|
||||||
|
<a href="../README.md#snapshot">Capturi</a><br>
|
||||||
|
[<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-PTBR.md">Português (Brasil)</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-DA.md">Dansk</a>] | [<a href="README-GR.md">Ελληνικά</a>] | [<a href="README-TR.md">Türkçe</a>] | [<a href="README-NO.md">Norsk</a>] | [<a href="README-RO.md">Română</a>]<br>
|
||||||
|
<b>Avem nevoie de ajutorul tău pentru a traduce acest README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> și <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Doc</a> în limba ta maternă</b>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
> [!Atenție]
|
||||||
|
> **Declinare de responsabilitate privind utilizarea abuzivă:** <br>
|
||||||
|
> Dezvoltatorii RustDesk nu susțin sau aprobă utilizarea neetică sau ilegală a acestui software. Utilizarea abuzivă, cum ar fi accesul neautorizat, controlul sau invadarea intimității, este strict împotriva regulilor noastre. Autorii nu sunt responsabili pentru utilizarea necorespunzătoare a aplicației.
|
||||||
|
|
||||||
|
|
||||||
|
Conversați cu noi: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||||
|
|
||||||
|
[](https://rustdesk.com/pricing.html)
|
||||||
|
|
||||||
|
Încă o soluție de desktop la distanță scrisă în Rust. Funcționează imediat, fără configurare necesară. Ai control total asupra datelor tale, fără probleme de securitate. Poți folosi serverul nostru de rendezvous/relay, [să-ți configurezi propriul server](https://rustdesk.com/server) sau [să scrii propriul server de rendezvous/relay](https://github.com/rustdesk/rustdesk-server-demo).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
RustDesk primește contribuții de la oricine. Vezi [CONTRIBUTING.md](../docs/CONTRIBUTING.md) pentru ajutor la început.
|
||||||
|
|
||||||
|
[**ÎNTREBĂRI FRECVENTE (FAQ)**](https://github.com/rustdesk/rustdesk/wiki/FAQ)
|
||||||
|
|
||||||
|
[**DESCĂRCARE BINARE**](https://github.com/rustdesk/rustdesk/releases)
|
||||||
|
|
||||||
|
[**BUILD NIGHTLY**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
|
||||||
|
|
||||||
|
[<img src="https://f-droid.org/badge/get-it-on.png"
|
||||||
|
alt="Get it on F-Droid"
|
||||||
|
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
|
||||||
|
[<img src="https://flathub.org/api/badge?svg&locale=en"
|
||||||
|
alt="Get it on Flathub"
|
||||||
|
height="80">](https://flathub.org/apps/com.rustdesk.RustDesk)
|
||||||
|
|
||||||
|
## Dependențe
|
||||||
|
|
||||||
|
Versiunile desktop folosesc Flutter sau Sciter (depreciat) pentru interfață; acest ghid este pentru Sciter doar, deoarece este mai ușor și mai prietenos pentru început. Vezi [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) pentru construire cu Flutter.
|
||||||
|
|
||||||
|
Te rugăm să descarci singur librăria dinamică Sciter.
|
||||||
|
|
||||||
|
[Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) |
|
||||||
|
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
|
||||||
|
[macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
|
||||||
|
|
||||||
|
## Pași pentru construire (Raw Steps to build)
|
||||||
|
|
||||||
|
- Pregătește mediul de dezvoltare Rust și mediul de construire C++
|
||||||
|
|
||||||
|
- Instalează [vcpkg](https://github.com/microsoft/vcpkg) și setează corect variabila de mediu `VCPKG_ROOT`
|
||||||
|
|
||||||
|
- Windows: vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static
|
||||||
|
- Linux/macOS: vcpkg install libvpx libyuv opus aom
|
||||||
|
|
||||||
|
- rulează `cargo run`
|
||||||
|
|
||||||
|
## [Construire](https://rustdesk.com/docs/en/dev/build/)
|
||||||
|
|
||||||
|
## Cum se construiește pe Linux
|
||||||
|
|
||||||
|
### Ubuntu 18 (Debian 10)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
||||||
|
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
|
||||||
|
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### openSUSE Tumbleweed
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fedora 28 (CentOS 8)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arch (Manjaro)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo pacman -Syu --needed unzip git cmake gcc curl wget yasm nasm zip make pkg-config clang gtk3 xdotool libxcb libxfixes alsa-lib pipewire
|
||||||
|
```
|
||||||
|
|
||||||
|
### Instalează vcpkg
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/microsoft/vcpkg
|
||||||
|
cd vcpkg
|
||||||
|
git checkout 2023.04.15
|
||||||
|
cd ..
|
||||||
|
vcpkg/bootstrap-vcpkg.sh
|
||||||
|
export VCPKG_ROOT=$HOME/vcpkg
|
||||||
|
vcpkg/vcpkg install libvpx libyuv opus aom
|
||||||
|
```
|
||||||
|
|
||||||
|
### Repară libvpx (Pentru Fedora)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd vcpkg/buildtrees/libvpx/src
|
||||||
|
cd *
|
||||||
|
./configure
|
||||||
|
sed -i 's/CFLAGS+=-I/CFLAGS+=-fPIC -I/g' Makefile
|
||||||
|
sed -i 's/CXXFLAGS+=-I/CXXFLAGS+=-fPIC -I/g' Makefile
|
||||||
|
make
|
||||||
|
cp libvpx.a $HOME/vcpkg/installed/x64-linux/lib/
|
||||||
|
cd
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
source $HOME/.cargo/env
|
||||||
|
git clone --recurse-submodules https://github.com/rustdesk/rustdesk
|
||||||
|
cd rustdesk
|
||||||
|
mkdir -p target/debug
|
||||||
|
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
|
||||||
|
mv libsciter-gtk.so target/debug
|
||||||
|
VCPKG_ROOT=$HOME/vcpkg cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cum să construiești cu Docker
|
||||||
|
|
||||||
|
Începe prin clonarea repository-ului și construirea imaginii Docker:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/rustdesk/rustdesk
|
||||||
|
cd rustdesk
|
||||||
|
git submodule update --init --recursive
|
||||||
|
docker build -t "rustdesk-builder" .
|
||||||
|
```
|
||||||
|
|
||||||
|
Apoi, de fiecare dată când trebuie să construiești aplicația, rulează comanda următoare:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder
|
||||||
|
```
|
||||||
|
|
||||||
|
Reține că prima construire poate dura mai mult până când dependențele sunt în cache; construirile ulterioare vor fi mai rapide. De asemenea, dacă trebuie să specifici argumente diferite comenzii de build, le poți adăuga la finalul comenzii în poziția `<OPTIONAL-ARGS>`. De exemplu, pentru a construi o versiune optimizată de release, adaugă `--release`. Executabilul rezultat va fi disponibil în folderul `target` pe sistemul tău, și poate fi rulat cu:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
target/debug/rustdesk
|
||||||
|
```
|
||||||
|
|
||||||
|
Sau, dacă rulezi un executabil release:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
target/release/rustdesk
|
||||||
|
```
|
||||||
|
|
||||||
|
Asigură-te că rulezi aceste comenzi din rădăcina repository-ului RustDesk, altfel aplicația poate să nu găsească resursele necesare. De asemenea, reține că alte subcomenzi cargo, cum ar fi `install` sau `run`, nu sunt acceptate în prezent prin această metodă, deoarece ar instala sau rula programul în interiorul containerului în loc de gazdă.
|
||||||
|
|
||||||
|
## Structura fișierelor
|
||||||
|
|
||||||
|
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: codec video, config, wrapper tcp/udp, protobuf, funcții fs pentru transfer de fișiere și alte funcții utilitare
|
||||||
|
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: capturare ecran
|
||||||
|
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: control tastatură/mouse specific platformei
|
||||||
|
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: implementare copy/paste pentru fișiere pentru Windows, Linux, macOS.
|
||||||
|
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: interfață Sciter învechită (depreciată)
|
||||||
|
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: servicii audio/clipboard/input/video și conexiuni de rețea
|
||||||
|
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: inițiază o conexiune peer
|
||||||
|
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: comunică cu [rustdesk-server](https://github.com/rustdesk/rustdesk-server), așteaptă conexiune directă remote (TCP hole punching) sau prin relay
|
||||||
|
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: cod specific platformei
|
||||||
|
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: cod Flutter pentru desktop și mobil
|
||||||
|
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript pentru clientul Flutter web
|
||||||
|
|
||||||
|
## Capturi de ecran
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
@@ -167,7 +167,7 @@ target/release/rustdesk
|
|||||||
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: графический пользовательский интерфейс на Sciter (устаревшее)
|
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: графический пользовательский интерфейс на Sciter (устаревшее)
|
||||||
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: сервисы аудио, буфера обмена, ввода, видео и сетевых подключений
|
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: сервисы аудио, буфера обмена, ввода, видео и сетевых подключений
|
||||||
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: одноранговое соединение
|
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: одноранговое соединение
|
||||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: связь с [сервером Rustdesk](https://github.com/rustdesk/rustdesk-server), ожидает удаленного прямого (через TCP hole punching) или ретранслируемого соединения
|
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: связь с [сервером RustDesk](https://github.com/rustdesk/rustdesk-server), ожидает удаленного прямого (через TCP hole punching) или ретранслируемого соединения
|
||||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфичный для платформы код
|
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфичный для платформы код
|
||||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для ПК-версии и мобильных устройств
|
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для ПК-версии и мобильных устройств
|
||||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript для Web-клиента Flutter
|
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript для Web-клиента Flutter
|
||||||
|
|||||||
@@ -7,34 +7,37 @@
|
|||||||
<a href="#file-structure">Dosya Yapısı</a> •
|
<a href="#file-structure">Dosya Yapısı</a> •
|
||||||
<a href="#snapshot">Ekran Görüntüleri</a><br>
|
<a href="#snapshot">Ekran Görüntüleri</a><br>
|
||||||
[<a href="docs/README-UA.md">Українська</a>] | [<a href="docs/README-CS.md">česky</a>] | [<a href="docs/README-ZH.md">中文</a>] | [<a href="docs/README-HU.md">Magyar</a>] | [<a href="docs/README-ES.md">Español</a>] | [<a href="docs/README-FA.md">فارسی</a>] | [<a href="docs/README-FR.md">Français</a>] | [<a href="docs/README-DE.md">Deutsch</a>] | [<a href="docs/README-PL.md">Polski</a>] | [<a href="docs/README-ID.md">Indonesian</a>] | [<a href="docs/README-FI.md">Suomi</a>] | [<a href="docs/README-ML.md">മലയാളം</a>] | [<a href="docs/README-JP.md">日本語</a>] | [<a href="docs/README-NL.md">Nederlands</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Русский</a>] | [<a href="docs/README-PTBR.md">Português (Brasil)</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">한국어</a>] | [<a href="docs/README-AR.md">العربي</a>] | [<a href="docs/README-VN.md">Tiếng Việt</a>] | [<a href="docs/README-DA.md">Dansk</a>] | [<a href="docs/README-GR.md">Ελληνικά</a>]<br>
|
[<a href="docs/README-UA.md">Українська</a>] | [<a href="docs/README-CS.md">česky</a>] | [<a href="docs/README-ZH.md">中文</a>] | [<a href="docs/README-HU.md">Magyar</a>] | [<a href="docs/README-ES.md">Español</a>] | [<a href="docs/README-FA.md">فارسی</a>] | [<a href="docs/README-FR.md">Français</a>] | [<a href="docs/README-DE.md">Deutsch</a>] | [<a href="docs/README-PL.md">Polski</a>] | [<a href="docs/README-ID.md">Indonesian</a>] | [<a href="docs/README-FI.md">Suomi</a>] | [<a href="docs/README-ML.md">മലയാളം</a>] | [<a href="docs/README-JP.md">日本語</a>] | [<a href="docs/README-NL.md">Nederlands</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Русский</a>] | [<a href="docs/README-PTBR.md">Português (Brasil)</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">한국어</a>] | [<a href="docs/README-AR.md">العربي</a>] | [<a href="docs/README-VN.md">Tiếng Việt</a>] | [<a href="docs/README-DA.md">Dansk</a>] | [<a href="docs/README-GR.md">Ελληνικά</a>]<br>
|
||||||
<b>README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> ve <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Belge</a>'sini ana dilinize çevirmemiz için yardımınıza ihtiyacımız var</b>
|
<b>README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> ve <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Dökümantasyonu</a>'nu ana dilinize çevirmemiz için yardımınıza ihtiyacımız var</b>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
> [!Dikkat]
|
||||||
|
> **Yanlış Kullanım Uyarısı:** <br>
|
||||||
|
> RustDesk geliştiricileri, bu yazılımın etik olmayan veya yasa dışı kullanımını onaylamaz veya desteklemez. Yetkisiz erişim, kontrol veya gizlilik ihlali gibi kötüye kullanımlar kesinlikle yönergelerimize aykırıdır. Yazarlar, uygulamanın herhangi bir yanlış kullanımından sorumlu değildir.
|
||||||
|
|
||||||
Bizimle sohbet edin: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
Bizimle sohbet edin: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||||
|
|
||||||
[](https://rustdesk.com/pricing.html)
|
[](https://rustdesk.com/pricing.html)
|
||||||
|
|
||||||
Başka bir uzak masaüstü yazılımı daha, Rust dilinde yazılmış. Hemen kullanıma hazır, hiçbir yapılandırma gerektirmez. Verilerinizin tam kontrolünü elinizde tutarsınız ve güvenlikle ilgili endişeleriniz olmaz. Kendi buluş/iletme sunucumuzu kullanabilirsiniz, [kendi sunucunuzu kurabilirsiniz](https://rustdesk.com/server) veya [kendi buluş/iletme sunucunuzu yazabilirsiniz](https://github.com/rustdesk/rustdesk-server-demo).
|
Rust dilinde yazılmış, başka bir uzak masaüstü yazılımı daha. Hiçbir yapılandırma gerekmeksizin, hemen kullanıma hazır. Güvenlik konusunda hiçbir endişe duymadan, verileriniz üzerinde tam kontrole sahip olun. Kendi rendezvous/relay sunucumuzu kullanabilirsiniz, [kendi sunucunuzu kurabilirsiniz](https://rustdesk.com/server) veya [kendi rendezvous/relay sunucunuzu yazabilirsiniz](https://github.com/rustdesk/rustdesk-server-demo).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
RustDesk, herkesten katkıyı kabul eder. Başlamak için [CONTRIBUTING.md](CONTRIBUTING-TR.md) belgesine göz atın.
|
RustDesk, herkesin katkısına açıktır. Başlamak için [CONTRIBUTING.md](CONTRIBUTING-TR.md) belgesine göz atın.
|
||||||
|
|
||||||
[**SSS**](https://github.com/rustdesk/rustdesk/wiki/FAQ)
|
[**SSS**](https://github.com/rustdesk/rustdesk/wiki/FAQ)
|
||||||
|
|
||||||
[**BİNARİ İNDİR**](https://github.com/rustdesk/rustdesk/releases)
|
[**BINARY İNDİR**](https://github.com/rustdesk/rustdesk/releases)
|
||||||
|
|
||||||
[**NİGHTLY DERLEME**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
|
[**NIGHTLY DERLEME**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
|
||||||
|
|
||||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
||||||
alt="F-Droid'de Alın"
|
alt="F-Droid'de Alın"
|
||||||
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
|
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
|
||||||
|
|
||||||
## Bağımlılıklar
|
## Gereksinimler
|
||||||
|
|
||||||
Masaüstü sürümleri GUI için
|
Masaüstü sürümleri GUI için; [Sciter](https://sciter.com/)(kaldırılacak) veya Flutter kullanır. Sciter daha kolay ve başlamak için daha dostcanlısı, bundan dolayı bu kılavuz sadece Sciter içindir. Flutter sürümünü derlemek için [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml)'ımıza bakın.
|
||||||
|
|
||||||
[Sciter](https://sciter.com/) veya Flutter kullanır, bu kılavuz sadece Sciter içindir.
|
|
||||||
|
|
||||||
Lütfen Sciter dinamik kütüphanesini kendiniz indirin.
|
Lütfen Sciter dinamik kütüphanesini kendiniz indirin.
|
||||||
|
|
||||||
@@ -46,7 +49,7 @@ Lütfen Sciter dinamik kütüphanesini kendiniz indirin.
|
|||||||
|
|
||||||
- Rust geliştirme ortamınızı ve C++ derleme ortamınızı hazırlayın.
|
- Rust geliştirme ortamınızı ve C++ derleme ortamınızı hazırlayın.
|
||||||
|
|
||||||
- [vcpkg](https://github.com/microsoft/vcpkg) yükleyin ve `VCPKG_ROOT` çevresel değişkenini doğru bir şekilde ayarlayın.
|
- [vcpkg](https://github.com/microsoft/vcpkg) yükleyin ve `VCPKG_ROOT` ortam değişkenini doğru bir şekilde ayarlayın.
|
||||||
|
|
||||||
- Windows: vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static
|
- Windows: vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static
|
||||||
- Linux/macOS: vcpkg install libvpx libyuv opus aom
|
- Linux/macOS: vcpkg install libvpx libyuv opus aom
|
||||||
@@ -123,7 +126,7 @@ VCPKG_ROOT=$HOME/vcpkg cargo run
|
|||||||
|
|
||||||
## Docker ile Derleme Nasıl Yapılır
|
## Docker ile Derleme Nasıl Yapılır
|
||||||
|
|
||||||
Öncelikle deposunu klonlayın ve Docker konteynerini oluşturun:
|
Önce repository'i klonlayın ve Docker container'ını oluşturun.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/rustdesk/rustdesk
|
git clone https://github.com/rustdesk/rustdesk
|
||||||
@@ -131,44 +134,40 @@ cd rustdesk
|
|||||||
docker build -t "rustdesk-builder" .
|
docker build -t "rustdesk-builder" .
|
||||||
```
|
```
|
||||||
|
|
||||||
Ardından, uygulamayı derlemek için her seferinde aşağıdaki komutu çalıştırın:
|
Ardından, uygulamayı her derlemeniz gerektiğinde aşağıdaki komutu çalıştırın:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder
|
docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder
|
||||||
```
|
```
|
||||||
|
|
||||||
İlk derleme, bağımlılıklar önbelleğe alınmadan önce daha uzun sürebilir, sonraki derlemeler daha hızlı olacaktır. Ayrıca, derleme komutuna isteğe bağlı argümanlar belirtmeniz gerekiyorsa, bunu
|
Bilin ki ilk derlemeniz gereksinimlerin önbelleği yüklenmesinden ötürü uzun sürebilir, sonraki derlemeleriniz daha hızlı olacaktır. Ayrıca, derleme komutuna isteğe bağlı argümanlar belirtmeniz gerekiyorsa, bunu komutun sonunda ki `<OPTIONAL-ARGS>` yerine yazabilirsiniz. Örneğin, optimize edilmiş bir sürümü derlemek isterseniz, yukarıdaki komutu çalıştırdıktan sonra `--release` ekleyebilirsiniz. Oluşan çalıştırılabilir dosya sisteminizdeki hedef klasöründe bulunacak ve şu komutla çalıştırılabilir olacaktır:
|
||||||
|
|
||||||
komutun sonunda `<İSTEĞE BAĞLI-ARGÜMANLAR>` pozisyonunda yapabilirsiniz. Örneğin, optimize edilmiş bir sürümü derlemek isterseniz, yukarıdaki komutu çalıştırdıktan sonra `--release` ekleyebilirsiniz. Oluşan yürütülebilir dosya sisteminizdeki hedef klasöründe bulunacak ve şu komutla çalıştırılabilir:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
target/debug/rustdesk
|
target/debug/rustdesk
|
||||||
```
|
```
|
||||||
|
|
||||||
Veya, yayın yürütülebilir dosyası çalıştırılıyorsa:
|
Veya, yayım çalıştırılabilir dosyası için:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
target/release/rustdesk
|
target/release/rustdesk
|
||||||
```
|
```
|
||||||
|
|
||||||
Lütfen bu komutları RustDesk deposunun kökünden çalıştırdığınızdan emin olun, aksi takdirde uygulama gereken kaynakları bulamayabilir. Ayrıca, `install` veya `run` gibi diğer cargo altkomutları şu anda bu yöntem aracılığıyla desteklenmemektedir, çünkü bunlar programı konteyner içinde kurar veya çalıştırır ve ana makinede değil.
|
Lütfen bu komutları RustDesk reposunun root klasöründe çalıştırdığınızdan emin olun, aksi takdirde uygulama gereken kaynakları bulamayabilir. Ayrıca, `install` veya `run` gibi diğer cargo altkomutları şu anda bu yöntem aracılığıyla desteklenmemektedir, çünkü bunlar programı konteyner içinde kurar veya çalıştırır, ana makinede değil.
|
||||||
|
|
||||||
## Dosya Yapısı
|
## Dosya Yapısı
|
||||||
|
|
||||||
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: video kodlayıcı, yapılandırma, tcp/udp sarmalayıcı, protobuf, dosya transferi için fs işlevleri ve diğer bazı yardımcı işlevler
|
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: video codec, config, tcp/udp wrapper, protobuf, dosya transferi için fs fonksiyonları ve diğer bazı yardımcı işlevler
|
||||||
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: ekran yakalama
|
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: ekran yakalama
|
||||||
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: platforma özgü klavye/fare kontrolü
|
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: platforma özgü klavye/fare kontrolü
|
||||||
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI
|
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: platforma özgü kopyala/yapıştır implementasyonları.
|
||||||
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: ses/pasta/klavye/video hizmetleri ve ağ bağlantıları
|
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: Eski Sciter UI (kaldırılacak)
|
||||||
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: bir eş bağlantısı başlatır
|
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: ses/pano/input/video servisleri ve ağ bağlantıları
|
||||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server) ile iletişim kurar, uzak doğrudan (TCP delik vurma) veya iletme bağlantısını bekler
|
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: Eşli bağlantı başlat
|
||||||
|
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server) ile iletişime gir, remote direct(TCP delik açma) yada relay bağlantısı için bekle
|
||||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platforma özgü kod
|
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platforma özgü kod
|
||||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: mobil için Flutter kodu
|
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Masaüstü ve mobil için Flutter kodu
|
||||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter web istemcisi için JavaScript
|
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: Flutter web istemcisi için JavaScript
|
||||||
|
|
||||||
> [!Dikkat]
|
|
||||||
> **Yanlış Kullanım Uyarısı:** <br>
|
|
||||||
> RustDesk geliştiricileri, bu yazılımın etik olmayan veya yasa dışı kullanımını onaylamaz veya desteklemez. Yetkisiz erişim, kontrol veya gizlilik ihlali gibi kötüye kullanımlar kesinlikle yönergelerimize aykırıdır. Yazarlar, uygulamanın herhangi bir yanlış kullanımından sorumlu değildir.
|
|
||||||
|
|
||||||
## Ekran Görüntüleri
|
## Ekran Görüntüleri
|
||||||
|
|
||||||
|
|||||||
16
docs/SECURITY-FR.md
Normal file
16
docs/SECURITY-FR.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
# Politique de sécurité
|
||||||
|
|
||||||
|
## Signaler une vulnérabilité
|
||||||
|
|
||||||
|
Nous accordons une très grande importance à la sécurité du projet. Nous
|
||||||
|
encourageons tous les utilisateurs à nous signaler toute vulnérabilité qu'ils
|
||||||
|
découvrent.
|
||||||
|
|
||||||
|
Si vous trouvez une vulnérabilité de sécurité dans le projet RustDesk, veuillez
|
||||||
|
la signaler de manière responsable en envoyant un e-mail à info@rustdesk.com.
|
||||||
|
|
||||||
|
À ce stade, nous n'avons pas de programme de bug bounty. Nous sommes une petite
|
||||||
|
équipe qui s'attaque à un grand défi. Nous vous encourageons vivement à signaler
|
||||||
|
toute vulnérabilité de manière responsable afin que nous puissions continuer à
|
||||||
|
développer une application sécurisée pour l'ensemble de la communauté.
|
||||||
9
docs/SECURITY-RO.md
Normal file
9
docs/SECURITY-RO.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Politica de Securitate
|
||||||
|
|
||||||
|
## Raportarea unei Vulnerabilități
|
||||||
|
|
||||||
|
Acordăm o mare importanță securității proiectului. Încurajăm toți utilizatorii să ne raporteze orice vulnerabilități pe care le descoperă.
|
||||||
|
Dacă găsești o vulnerabilitate de securitate în proiectul RustDesk, te rugăm să o raportezi responsabil trimițând un e-mail la info@rustdesk.com.
|
||||||
|
|
||||||
|
În acest moment, nu avem un program de recompense pentru descoperirea de bug-uri. Suntem o echipă mică care încearcă să rezolve o problemă mare.
|
||||||
|
Te rugăm să raportezi orice vulnerabilitate în mod responsabil, astfel încât să putem continua să construim o aplicație sigură pentru întreaga comunitate.
|
||||||
@@ -33,4 +33,4 @@ if [ -z $release ]; then
|
|||||||
fi
|
fi
|
||||||
set -f
|
set -f
|
||||||
#shellcheck disable=2086
|
#shellcheck disable=2086
|
||||||
VCPKG_ROOT=/vcpkg cargo build $argv
|
VCPKG_ROOT=/vcpkg cargo build --locked $argv
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<li> Supports VP8 / VP9 / AV1 software codecs, and H264 / H265 hardware codecs. </li>
|
<li> Supports VP8 / VP9 / AV1 software codecs, and H264 / H265 hardware codecs. </li>
|
||||||
<li> Own your data, easily set up self-hosting solution on your infrastructure. </li>
|
<li> Own your data, easily set up self-hosting solution on your infrastructure. </li>
|
||||||
<li> P2P connection with end-to-end encryption based on NaCl. </li>
|
<li> P2P connection with end-to-end encryption based on NaCl. </li>
|
||||||
<li> No administrative privileges or installation needed for Windows, elevate priviledge locally or from remote on demand. </li>
|
<li> No administrative privileges or installation needed for Windows, elevate privilege locally or from remote on demand. </li>
|
||||||
<li> We like to keep things simple and will strive to make simpler where possible. </li>
|
<li> We like to keep things simple and will strive to make simpler where possible. </li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -55,8 +55,8 @@
|
|||||||
],
|
],
|
||||||
"finish-args": [
|
"finish-args": [
|
||||||
"--share=ipc",
|
"--share=ipc",
|
||||||
"--socket=fallback-x11",
|
|
||||||
"--socket=wayland",
|
"--socket=wayland",
|
||||||
|
"--socket=x11",
|
||||||
"--share=network",
|
"--share=network",
|
||||||
"--filesystem=home",
|
"--filesystem=home",
|
||||||
"--device=dri",
|
"--device=dri",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import com.google.protobuf.gradle.*
|
import com.google.protobuf.gradle.*
|
||||||
|
import groovy.json.JsonSlurper
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id "com.google.protobuf" version "0.9.4"
|
id "com.google.protobuf" version "0.9.4"
|
||||||
id "com.android.application"
|
id "com.android.application"
|
||||||
@@ -30,8 +32,37 @@ if (flutterVersionName == null) {
|
|||||||
flutterVersionName = '1.0'
|
flutterVersionName = '1.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
// Add rustls-platform-verifier Android support
|
||||||
implementation 'com.google.protobuf:protobuf-javalite:3.20.1'
|
String findRustlsPlatformVerifierMavenDir() {
|
||||||
|
def dependencyText = providers.exec {
|
||||||
|
it.workingDir = new File("../..")
|
||||||
|
commandLine("cargo", "metadata", "--format-version", "1")
|
||||||
|
}.standardOutput.asText.get()
|
||||||
|
|
||||||
|
def dependencyJson = new JsonSlurper().parseText(dependencyText)
|
||||||
|
def pkg = dependencyJson.packages.find { it.name == "rustls-platform-verifier-android" }
|
||||||
|
|
||||||
|
if (pkg == null) {
|
||||||
|
throw new GradleException("rustls-platform-verifier-android package not found in cargo metadata!")
|
||||||
|
}
|
||||||
|
|
||||||
|
def manifestPath = file(pkg.manifest_path)
|
||||||
|
def mavenDir = new File(manifestPath.parentFile, "maven")
|
||||||
|
|
||||||
|
if (!mavenDir.exists()) {
|
||||||
|
throw new GradleException("Maven directory not found at: ${mavenDir.path}")
|
||||||
|
}
|
||||||
|
|
||||||
|
println("✓ Found rustls-platform-verifier maven repo at: ${mavenDir.path}")
|
||||||
|
return mavenDir.path
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
url = findRustlsPlatformVerifierMavenDir()
|
||||||
|
metadataSources.artifact()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protobuf {
|
protobuf {
|
||||||
@@ -67,7 +98,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId "com.carriez.flutter_hbb"
|
applicationId "com.carriez.flutter_hbb"
|
||||||
minSdkVersion 21
|
minSdkVersion 22
|
||||||
targetSdkVersion 33
|
targetSdkVersion 33
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
@@ -97,8 +128,10 @@ flutter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation 'com.google.protobuf:protobuf-javalite:3.20.1'
|
||||||
implementation "androidx.media:media:1.6.0"
|
implementation "androidx.media:media:1.6.0"
|
||||||
implementation 'com.github.getActivity:XXPermissions:18.5'
|
implementation 'com.github.getActivity:XXPermissions:18.5'
|
||||||
implementation("org.jetbrains.kotlin:kotlin-stdlib") { version { strictly("1.9.10") } }
|
implementation("org.jetbrains.kotlin:kotlin-stdlib") { version { strictly("1.9.10") } }
|
||||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||||
|
implementation "rustls:rustls-platform-verifier:0.1.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,3 +2,6 @@
|
|||||||
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
|
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
|
||||||
<fields>;
|
<fields>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Keep rustls-platform-verifier classes for JNI
|
||||||
|
-keep, includedescriptorclasses class org.rustls.platformverifier.** { *; }
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".MainApplication"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="RustDesk"
|
android:label="RustDesk"
|
||||||
android:requestLegacyExternalStorage="true"
|
android:requestLegacyExternalStorage="true"
|
||||||
|
|||||||
@@ -62,7 +62,13 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
audioRecorder = builder.build()
|
val recorder = try {
|
||||||
|
builder.build()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(logTag, "createAudioRecorder failed", e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
audioRecorder = recorder
|
||||||
Log.d(logTag, "createAudioRecorder done,minBufferSize:$minBufferSize")
|
Log.d(logTag, "createAudioRecorder done,minBufferSize:$minBufferSize")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,7 +311,10 @@ class FloatingWindowService : Service(), View.OnTouchListener {
|
|||||||
popupMenu.menu.add(0, idSyncClipboard, 0, translate("Update client clipboard"))
|
popupMenu.menu.add(0, idSyncClipboard, 0, translate("Update client clipboard"))
|
||||||
}
|
}
|
||||||
val idStopService = 2
|
val idStopService = 2
|
||||||
|
val hideStopService = FFI.getBuildinOption("hide-stop-service") == "Y"
|
||||||
|
if (!hideStopService) {
|
||||||
popupMenu.menu.add(0, idStopService, 0, translate("Stop service"))
|
popupMenu.menu.add(0, idStopService, 0, translate("Stop service"))
|
||||||
|
}
|
||||||
popupMenu.setOnMenuItemClickListener { menuItem ->
|
popupMenu.setOnMenuItemClickListener { menuItem ->
|
||||||
when (menuItem.itemId) {
|
when (menuItem.itemId) {
|
||||||
idShowRustDesk -> {
|
idShowRustDesk -> {
|
||||||
@@ -389,4 +392,3 @@ class FloatingWindowService : Service(), View.OnTouchListener {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ package com.carriez.flutter_hbb
|
|||||||
|
|
||||||
import android.accessibilityservice.AccessibilityService
|
import android.accessibilityservice.AccessibilityService
|
||||||
import android.accessibilityservice.GestureDescription
|
import android.accessibilityservice.GestureDescription
|
||||||
|
import android.content.Intent
|
||||||
import android.graphics.Path
|
import android.graphics.Path
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -68,6 +69,16 @@ class InputService : AccessibilityService() {
|
|||||||
get() = ctx != null
|
get() = ctx != null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun notifyInputState() {
|
||||||
|
val inputState = isOpen.toString()
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
MainActivity.flutterMethodChannel?.invokeMethod(
|
||||||
|
"on_state_changed",
|
||||||
|
mapOf("name" to "input", "value" to inputState)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val logTag = "input service"
|
private val logTag = "input service"
|
||||||
private var leftIsDown = false
|
private var leftIsDown = false
|
||||||
private var touchPath = Path()
|
private var touchPath = Path()
|
||||||
@@ -716,6 +727,7 @@ class InputService : AccessibilityService() {
|
|||||||
override fun onServiceConnected() {
|
override fun onServiceConnected() {
|
||||||
super.onServiceConnected()
|
super.onServiceConnected()
|
||||||
ctx = this
|
ctx = this
|
||||||
|
notifyInputState()
|
||||||
val info = AccessibilityServiceInfo()
|
val info = AccessibilityServiceInfo()
|
||||||
if (Build.VERSION.SDK_INT >= 33) {
|
if (Build.VERSION.SDK_INT >= 33) {
|
||||||
info.flags = FLAG_INPUT_METHOD_EDITOR or FLAG_RETRIEVE_INTERACTIVE_WINDOWS
|
info.flags = FLAG_INPUT_METHOD_EDITOR or FLAG_RETRIEVE_INTERACTIVE_WINDOWS
|
||||||
@@ -734,8 +746,16 @@ class InputService : AccessibilityService() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
ctx = null
|
ctx = null
|
||||||
|
// Keep this fallback even though onUnbind usually notifies first.
|
||||||
|
notifyInputState()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onUnbind(intent: Intent?): Boolean {
|
||||||
|
ctx = null
|
||||||
|
notifyInputState()
|
||||||
|
return super.onUnbind(intent)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onInterrupt() {}
|
override fun onInterrupt() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,13 @@ class MainActivity : FlutterActivity() {
|
|||||||
channelTag
|
channelTag
|
||||||
)
|
)
|
||||||
initFlutterChannel(flutterMethodChannel!!)
|
initFlutterChannel(flutterMethodChannel!!)
|
||||||
thread { setCodecInfo() }
|
thread {
|
||||||
|
try {
|
||||||
|
setCodecInfo()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("MainActivity", "Failed to setCodecInfo: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
@@ -100,6 +106,16 @@ class MainActivity : FlutterActivity() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
Log.e(logTag, "onDestroy")
|
Log.e(logTag, "onDestroy")
|
||||||
|
// The process can outlive the UI whenever something keeps it alive:
|
||||||
|
// MainService, or the accessibility InputService on its own. Only the
|
||||||
|
// former gets onTaskRemoved, so close outgoing sessions here too,
|
||||||
|
// otherwise a session survives with no UI left to close it.
|
||||||
|
// `isFinishing` distinguishes the user really leaving from a destroy
|
||||||
|
// for recreation (configuration change, "don't keep activities"),
|
||||||
|
// which must not tear down a live session.
|
||||||
|
if (isFinishing) {
|
||||||
|
FFI.closeAllSessions()
|
||||||
|
}
|
||||||
mainService?.let {
|
mainService?.let {
|
||||||
unbindService(serviceConnection)
|
unbindService(serviceConnection)
|
||||||
}
|
}
|
||||||
@@ -194,12 +210,13 @@ class MainActivity : FlutterActivity() {
|
|||||||
"stop_input" -> {
|
"stop_input" -> {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
InputService.ctx?.disableSelf()
|
InputService.ctx?.disableSelf()
|
||||||
}
|
} else {
|
||||||
InputService.ctx = null
|
InputService.ctx = null
|
||||||
Companion.flutterMethodChannel?.invokeMethod(
|
Companion.flutterMethodChannel?.invokeMethod(
|
||||||
"on_state_changed",
|
"on_state_changed",
|
||||||
mapOf("name" to "input", "value" to InputService.isOpen.toString())
|
mapOf("name" to "input", "value" to InputService.isOpen.toString())
|
||||||
)
|
)
|
||||||
|
}
|
||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
}
|
||||||
"cancel_notification" -> {
|
"cancel_notification" -> {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.carriez.flutter_hbb
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
import ffi.FFI
|
||||||
|
|
||||||
|
class MainApplication : Application() {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "MainApplication"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
Log.d(TAG, "App start")
|
||||||
|
FFI.onAppStart(applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -254,6 +254,16 @@ class MainService : Service() {
|
|||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Swiping the app away from recents destroys the UI but this service keeps
|
||||||
|
// the process alive, so outgoing sessions would stay connected with no way
|
||||||
|
// to close them. Incoming connections are unaffected: the service keeps
|
||||||
|
// running so the device stays reachable.
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
Log.d(logTag, "onTaskRemoved, closing outgoing sessions")
|
||||||
|
FFI.closeAllSessions()
|
||||||
|
super.onTaskRemoved(rootIntent)
|
||||||
|
}
|
||||||
|
|
||||||
private var isHalfScale: Boolean? = null;
|
private var isHalfScale: Boolean? = null;
|
||||||
private fun updateScreenInfo(orientation: Int) {
|
private fun updateScreenInfo(orientation: Int) {
|
||||||
var w: Int
|
var w: Int
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ object FFI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
external fun init(ctx: Context)
|
external fun init(ctx: Context)
|
||||||
|
external fun onAppStart(ctx: Context)
|
||||||
external fun setClipboardManager(clipboardManager: RdClipboardManager)
|
external fun setClipboardManager(clipboardManager: RdClipboardManager)
|
||||||
external fun startServer(app_dir: String, custom_client_config: String)
|
external fun startServer(app_dir: String, custom_client_config: String)
|
||||||
external fun startService()
|
external fun startService()
|
||||||
@@ -20,9 +21,11 @@ object FFI {
|
|||||||
external fun onAudioFrameUpdate(buf: ByteBuffer)
|
external fun onAudioFrameUpdate(buf: ByteBuffer)
|
||||||
external fun translateLocale(localeName: String, input: String): String
|
external fun translateLocale(localeName: String, input: String): String
|
||||||
external fun refreshScreen()
|
external fun refreshScreen()
|
||||||
|
external fun closeAllSessions()
|
||||||
external fun setFrameRawEnable(name: String, value: Boolean)
|
external fun setFrameRawEnable(name: String, value: Boolean)
|
||||||
external fun setCodecInfo(info: String)
|
external fun setCodecInfo(info: String)
|
||||||
external fun getLocalOption(key: String): String
|
external fun getLocalOption(key: String): String
|
||||||
|
external fun getBuildinOption(key: String): String
|
||||||
external fun onClipboardUpdate(clips: ByteBuffer)
|
external fun onClipboardUpdate(clips: ByteBuffer)
|
||||||
external fun isServiceClipboardEnabled(): Boolean
|
external fun isServiceClipboardEnabled(): Boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="199"><path fill="#0089d6" d="M118.432 187.698c32.89-5.81 60.055-10.618 60.367-10.684l.568-.12-31.052-36.935c-17.078-20.314-31.051-37.014-31.051-37.11 0-.182 32.063-88.477 32.243-88.792.06-.105 21.88 37.567 52.893 91.32 29.035 50.323 52.973 91.815 53.195 92.203l.405.707-98.684-.012-98.684-.013 59.8-10.564zM0 176.435c0-.052 14.631-25.451 32.514-56.442l32.514-56.347 37.891-31.799C123.76 14.358 140.867.027 140.935.001c.069-.026-.205.664-.609 1.534s-18.919 40.582-41.145 88.25l-40.41 86.67-29.386.037c-16.162.02-29.385-.005-29.385-.057z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 604 B |
1
flutter/assets/auth-microsoft.svg
Normal file
1
flutter/assets/auth-microsoft.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><rect x="4" y="4" width="19" height="19" fill="#f25022"/><rect x="25" y="4" width="19" height="19" fill="#7fba00"/><rect x="4" y="25" width="19" height="19" fill="#00a4ef"/><rect x="25" y="25" width="19" height="19" fill="#ffb900"/></svg>
|
||||||
|
After Width: | Height: | Size: 321 B |
7
flutter/assets/display_switcher.svg
Normal file
7
flutter/assets/display_switcher.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||||
|
<g fill="#000000" fill-rule="evenodd">
|
||||||
|
<rect x="4" y="6" width="24" height="16" rx="3"/>
|
||||||
|
<rect x="14.5" y="22" width="3" height="2"/>
|
||||||
|
<rect x="9.5" y="24" width="13" height="2.5" rx="1.25"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 303 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" style="isolation:isolate" viewBox="541.937 521.772 32 32"><path fill="none" d="M541.937 521.772h32v32h-32v-32Z"/><path fill-rule="evenodd" d="M552.145 539.981h11.584c.446 0 .808.362.808.808v.536c0 .786-.639 1.425-1.425 1.425h-10.35a1.426 1.426 0 0 1-1.425-1.425v-.536c0-.446.362-.808.808-.808Zm-1.761-3.511h.899c.536 0 .971.435.971.971v.899a.971.971 0 0 1-.971.971h-.899a.971.971 0 0 1-.971-.971v-.899c0-.536.435-.971.971-.971Zm3.552 0h.899c.536 0 .971.435.971.971v.899a.971.971 0 0 1-.971.971h-.899a.972.972 0 0 1-.972-.971v-.899c0-.536.436-.971.972-.971Zm3.551 0h.9c.536 0 .971.435.971.971v.899a.971.971 0 0 1-.971.971h-.9a.971.971 0 0 1-.971-.971v-.899c0-.536.435-.971.971-.971Zm3.552 0h.899c.536 0 .972.435.972.971v.899a.972.972 0 0 1-.972.971h-.899a.971.971 0 0 1-.971-.971v-.899c0-.536.435-.971.971-.971Zm3.552 0h.899c.536 0 .971.435.971.971v.899a.971.971 0 0 1-.971.971h-.899a.971.971 0 0 1-.971-.971v-.899c0-.536.435-.971.971-.971Zm-14.383-3.512h1.25c.44 0 .796.357.796.796v1.25a.796.796 0 0 1-.796.796h-1.25a.796.796 0 0 1-.795-.796v-1.25c0-.439.356-.796.795-.796Zm3.552 0h1.25c.439 0 .796.357.796.796v1.25a.797.797 0 0 1-.796.796h-1.25a.797.797 0 0 1-.796-.796v-1.25c0-.439.357-.796.796-.796Zm3.552 0h1.25c.439 0 .796.357.796.796v1.25a.797.797 0 0 1-.796.796h-1.25a.797.797 0 0 1-.796-.796v-1.25c0-.439.357-.796.796-.796Zm3.552 0h1.25c.439 0 .796.357.796.796v1.25a.797.797 0 0 1-.796.796h-1.25a.797.797 0 0 1-.796-.796v-1.25c0-.439.357-.796.796-.796Zm-9.553-3.85h13.252c1.407 0 2.755.507 3.748 1.409.993.902 1.552 2.127 1.552 3.404v7.702c0 1.277-.559 2.501-1.552 3.403-.993.902-2.341 1.409-3.748 1.409h-13.252c-1.407 0-2.755-.507-3.748-1.409-.993-.902-1.552-2.126-1.552-3.403v-7.702c0-1.277.559-2.502 1.552-3.404.993-.902 2.341-1.409 3.748-1.409Zm13.105 3.85h1.25c.439 0 .795.357.795.796v1.25a.796.796 0 0 1-.795.796h-1.25a.796.796 0 0 1-.796-.796v-1.25c0-.439.356-.796.796-.796Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB |
1
flutter/assets/keyboard_mouse.svg
Normal file
1
flutter/assets/keyboard_mouse.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.4 KiB |
@@ -6,83 +6,82 @@ ANDROID_ABI=$1
|
|||||||
|
|
||||||
# Build RustDesk dependencies for Android using vcpkg.json
|
# Build RustDesk dependencies for Android using vcpkg.json
|
||||||
# Required:
|
# Required:
|
||||||
# 1. set VCPKG_ROOT / ANDROID_NDK path environment variables
|
# 1. set VCPKG_ROOT / ANDROID_NDK_HOME path environment variables
|
||||||
# 2. vcpkg initialized
|
# 2. vcpkg initialized
|
||||||
# 3. ndk, version: r25c or newer
|
# 3. ndk, version: r25c or newer
|
||||||
|
|
||||||
if [ -z "$ANDROID_NDK_HOME" ]; then
|
if [ -z "${ANDROID_NDK_HOME}" ]; then
|
||||||
echo "Failed! Please set ANDROID_NDK_HOME"
|
echo "ERROR: Please set ANDROID_NDK_HOME environment variable" 1>&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$VCPKG_ROOT" ]; then
|
if [ -z "${VCPKG_ROOT}" ]; then
|
||||||
echo "Failed! Please set VCPKG_ROOT"
|
echo "ERROR: Please set VCPKG_ROOT environment variable" 1>&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
API_LEVEL="21"
|
case "${ANDROID_ABI}" in
|
||||||
|
arm64-v8a)
|
||||||
|
VCPKG_TARGET=arm64-android
|
||||||
|
;;
|
||||||
|
armeabi-v7a)
|
||||||
|
VCPKG_TARGET=arm-neon-android
|
||||||
|
;;
|
||||||
|
x86_64)
|
||||||
|
VCPKG_TARGET=x64-android
|
||||||
|
;;
|
||||||
|
x86)
|
||||||
|
VCPKG_TARGET=x86-android
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: build_android_deps.sh <arm64-v8a|armeabi-v7a|x86_64|x86>" 1>&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
# Get directory of this script
|
# Get directory of this script
|
||||||
|
|
||||||
SCRIPTDIR="$(readlink -f "$0")"
|
SCRIPTDIR="$(readlink -f "$0")"
|
||||||
SCRIPTDIR="$(dirname "$SCRIPTDIR")"
|
SCRIPTDIR="$(dirname "${SCRIPTDIR}")"
|
||||||
|
|
||||||
# Check if vcpkg.json is one level up - in root directory of RD
|
# Check if vcpkg.json is one level up - in root directory of RD
|
||||||
|
|
||||||
if [ ! -f "$SCRIPTDIR/../vcpkg.json" ]; then
|
if [ ! -f "${SCRIPTDIR}/../vcpkg.json" ]; then
|
||||||
echo "Failed! Please check where vcpkg.json is!"
|
echo "ERROR: Can not find vcpkg.json in RustDesk top-level directory" 1>&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# NDK llvm toolchain
|
echo "INFO: Building and install vcpkg dependencies for Android ${ANDROID_ABI} ..."
|
||||||
|
|
||||||
HOST_TAG="linux-x86_64" # current platform, set as `ls $ANDROID_NDK/toolchains/llvm/prebuilt/`
|
pushd "${SCRIPTDIR}/.."
|
||||||
TOOLCHAIN=$ANDROID_NDK/toolchains/llvm/prebuilt/$HOST_TAG
|
|
||||||
|
|
||||||
function build {
|
"${VCPKG_ROOT}/vcpkg" install \
|
||||||
ANDROID_ABI=$1
|
--triplet "${VCPKG_TARGET}" \
|
||||||
|
--x-install-root="${VCPKG_ROOT}/installed"
|
||||||
|
|
||||||
case "$ANDROID_ABI" in
|
|
||||||
arm64-v8a)
|
|
||||||
ABI=aarch64-linux-android$API_LEVEL
|
|
||||||
VCPKG_TARGET=arm64-android
|
|
||||||
;;
|
|
||||||
armeabi-v7a)
|
|
||||||
ABI=armv7a-linux-androideabi$API_LEVEL
|
|
||||||
VCPKG_TARGET=arm-neon-android
|
|
||||||
;;
|
|
||||||
x86_64)
|
|
||||||
ABI=x86_64-linux-android$API_LEVEL
|
|
||||||
VCPKG_TARGET=x64-android
|
|
||||||
;;
|
|
||||||
x86)
|
|
||||||
ABI=i686-linux-android$API_LEVEL
|
|
||||||
VCPKG_TARGET=x86-android
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "ERROR: ANDROID_ABI must be one of: arm64-v8a, armeabi-v7a, x86_64, x86" >&2
|
|
||||||
return 1
|
|
||||||
esac
|
|
||||||
|
|
||||||
echo "*** [$ANDROID_ABI][Start] Build and install vcpkg dependencies"
|
|
||||||
pushd "$SCRIPTDIR/.."
|
|
||||||
$VCPKG_ROOT/vcpkg install --triplet $VCPKG_TARGET --x-install-root="$VCPKG_ROOT/installed"
|
|
||||||
popd
|
popd
|
||||||
head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-$VCPKG_TARGET-rel-out.log" || true
|
|
||||||
echo "*** [$ANDROID_ABI][Finished] Build and install vcpkg dependencies"
|
|
||||||
|
|
||||||
if [ -d "$VCPKG_ROOT/installed/arm-neon-android" ]; then
|
echo "INFO: Completed building vcpkg dependencies for Android ${ANDROID_ABI}"
|
||||||
echo "*** [Start] Move arm-neon-android to arm-android"
|
|
||||||
|
|
||||||
mv "$VCPKG_ROOT/installed/arm-neon-android" "$VCPKG_ROOT/installed/arm-android"
|
if [ "${ANDROID_ABI}" = 'armeabi-v7a' ]; then
|
||||||
|
# Symlink arm-neon-android to arm-android because cargo-ndk does not
|
||||||
|
# understand NEON suffix.
|
||||||
|
|
||||||
echo "*** [Finished] Move arm-neon-android to arm-android"
|
if [ -d "${VCPKG_ROOT}/installed/arm-neon-android" ]; then
|
||||||
fi
|
echo 'INFO: Symlinking arm-neon-android to arm-android'
|
||||||
}
|
|
||||||
|
|
||||||
if [ ! -z "$ANDROID_ABI" ]; then
|
ln -sf \
|
||||||
build "$ANDROID_ABI"
|
"${VCPKG_ROOT}/installed/arm-neon-android" \
|
||||||
|
"${VCPKG_ROOT}/installed/arm-android"
|
||||||
|
|
||||||
|
echo 'INFO: Symlinked arm-neon-android to arm-android'
|
||||||
else
|
else
|
||||||
echo "Usage: build-android-deps.sh <ANDROID-ABI>" >&2
|
cat 0<<.a
|
||||||
|
ERROR: 'vcpkg install' seem to complete successfully but
|
||||||
|
directory '${VCPKG_ROOT}/installed/arm-neon-android' is missing!
|
||||||
|
|
||||||
|
.a
|
||||||
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
# 2024, Vasyl Gello <vasek.gello@gmail.com>
|
# 2024, Vasyl Gello <vasek.gello@gmail.com>
|
||||||
#
|
#
|
||||||
|
|
||||||
# The script is invoked by F-Droid builder system ste-by-step.
|
# The script is invoked by F-Droid builder system step-by-step.
|
||||||
#
|
#
|
||||||
# It accepts the following arguments:
|
# It accepts the following arguments:
|
||||||
#
|
#
|
||||||
@@ -16,7 +16,6 @@
|
|||||||
# - Android architecture to build APK for: armeabi-v7a arm64-v8av x86 x86_64
|
# - Android architecture to build APK for: armeabi-v7a arm64-v8av x86 x86_64
|
||||||
# - The build step to execute:
|
# - The build step to execute:
|
||||||
#
|
#
|
||||||
# + sudo-deps: as root, install needed Debian packages into builder VM
|
|
||||||
# + prebuild: patch sources and do other stuff before the build
|
# + prebuild: patch sources and do other stuff before the build
|
||||||
# + build: perform actual build of APK file
|
# + build: perform actual build of APK file
|
||||||
#
|
#
|
||||||
@@ -184,13 +183,9 @@ prebuild)
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Map NDK version to revision
|
# Map NDK version to revision
|
||||||
|
NDK_VERSION="$(curl https://gitlab.com/fdroid/android-sdk-transparency-log/-/raw/master/signed/checksums.json |
|
||||||
NDK_VERSION="$(wget \
|
jq -r ".\"https://dl.google.com/android/repository/android-ndk-${NDK_VERSION}-linux.zip\"[0].\"source.properties\"" |
|
||||||
-qO- \
|
sed -n -E 's/.*Pkg.Revision = ([0-9.]+).*/\1/p')"
|
||||||
-H "Accept: application/vnd.github+json" \
|
|
||||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
|
||||||
'https://api.github.com/repos/android/ndk/releases' |
|
|
||||||
jq -r ".[] | select(.tag_name == \"${NDK_VERSION}\") | .body | match(\"ndkVersion \\\"(.*)\\\"\").captures[0].string")"
|
|
||||||
|
|
||||||
if [ -z "${NDK_VERSION}" ]; then
|
if [ -z "${NDK_VERSION}" ]; then
|
||||||
echo "ERROR: Can not map Android NDK codename to revision!" >&2
|
echo "ERROR: Can not map Android NDK codename to revision!" >&2
|
||||||
@@ -316,6 +311,18 @@ prebuild)
|
|||||||
# `FLUTTER_BRIDGE_VERSION` an restore the pubspec later
|
# `FLUTTER_BRIDGE_VERSION` an restore the pubspec later
|
||||||
|
|
||||||
if [ "${FLUTTER_VERSION}" != "${FLUTTER_BRIDGE_VERSION}" ]; then
|
if [ "${FLUTTER_VERSION}" != "${FLUTTER_BRIDGE_VERSION}" ]; then
|
||||||
|
# Find first libclang.so and set BRIDGE_LLVM_PATH
|
||||||
|
|
||||||
|
BRIDGE_LLVM_PATH="$(find /usr/lib/ -name libclang.so | head -n1)"
|
||||||
|
|
||||||
|
if [ -z "${BRIDGE_LLVM_PATH}" ]; then
|
||||||
|
echo 'ERROR: Can not find libclang.so for bridge generator!' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BRIDGE_LLVM_PATH="$(dirname "${BRIDGE_LLVM_PATH}")"
|
||||||
|
BRIDGE_LLVM_PATH="$(dirname "${BRIDGE_LLVM_PATH}")"
|
||||||
|
|
||||||
# Install Flutter bridge version
|
# Install Flutter bridge version
|
||||||
|
|
||||||
prepare_flutter "${FLUTTER_BRIDGE_VERSION}" "${HOME}/flutter"
|
prepare_flutter "${FLUTTER_BRIDGE_VERSION}" "${HOME}/flutter"
|
||||||
@@ -344,7 +351,8 @@ prebuild)
|
|||||||
|
|
||||||
flutter_rust_bridge_codegen \
|
flutter_rust_bridge_codegen \
|
||||||
--rust-input ./src/flutter_ffi.rs \
|
--rust-input ./src/flutter_ffi.rs \
|
||||||
--dart-output ./flutter/lib/generated_bridge.dart
|
--dart-output ./flutter/lib/generated_bridge.dart \
|
||||||
|
--llvm-path "${BRIDGE_LLVM_PATH}"
|
||||||
|
|
||||||
# Add bridge files to save-list
|
# Add bridge files to save-list
|
||||||
|
|
||||||
@@ -355,13 +363,15 @@ prebuild)
|
|||||||
git checkout '*'
|
git checkout '*'
|
||||||
git clean -dffx
|
git clean -dffx
|
||||||
git reset
|
git reset
|
||||||
|
|
||||||
|
unset BRIDGE_LLVM_PATH
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Install Flutter version for RustDesk library build
|
# Install Flutter version for RustDesk library build
|
||||||
|
|
||||||
prepare_flutter "${FLUTTER_VERSION}" "${HOME}/flutter"
|
prepare_flutter "${FLUTTER_VERSION}" "${HOME}/flutter"
|
||||||
|
|
||||||
# gms is not in thoes files now, but we still keep the following line for future reference(maybe).
|
# gms is not in these files now, but we still keep the following line for future reference(maybe).
|
||||||
|
|
||||||
sed \
|
sed \
|
||||||
-i \
|
-i \
|
||||||
@@ -414,13 +424,9 @@ build)
|
|||||||
.github/workflows/flutter-build.yml)"
|
.github/workflows/flutter-build.yml)"
|
||||||
|
|
||||||
# Map NDK version to revision
|
# Map NDK version to revision
|
||||||
|
NDK_VERSION="$(curl https://gitlab.com/fdroid/android-sdk-transparency-log/-/raw/master/signed/checksums.json |
|
||||||
NDK_VERSION="$(wget \
|
jq -r ".\"https://dl.google.com/android/repository/android-ndk-${NDK_VERSION}-linux.zip\"[0].\"source.properties\"" |
|
||||||
-qO- \
|
sed -n -E 's/.*Pkg.Revision = ([0-9.]+).*/\1/p')"
|
||||||
-H "Accept: application/vnd.github+json" \
|
|
||||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
|
||||||
'https://api.github.com/repos/android/ndk/releases' |
|
|
||||||
jq -r ".[] | select(.tag_name == \"${NDK_VERSION}\") | .body | match(\"ndkVersion \\\"(.*)\\\"\").captures[0].string")"
|
|
||||||
|
|
||||||
if [ -z "${NDK_VERSION}" ]; then
|
if [ -z "${NDK_VERSION}" ]; then
|
||||||
echo "ERROR: Can not map Android NDK codename to revision!" >&2
|
echo "ERROR: Can not map Android NDK codename to revision!" >&2
|
||||||
@@ -454,6 +460,7 @@ build)
|
|||||||
--target "${RUST_TARGET}" \
|
--target "${RUST_TARGET}" \
|
||||||
--bindgen \
|
--bindgen \
|
||||||
build \
|
build \
|
||||||
|
--locked \
|
||||||
--release \
|
--release \
|
||||||
--features "${RUSTDESK_FEATURES}"
|
--features "${RUSTDESK_FEATURES}"
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>CLIENT_ID</key>
|
|
||||||
<string>768133699366-k1rn3ls1u2n3nklmgd9t4cmpdob0c8bn.apps.googleusercontent.com</string>
|
|
||||||
<key>REVERSED_CLIENT_ID</key>
|
|
||||||
<string>com.googleusercontent.apps.768133699366-k1rn3ls1u2n3nklmgd9t4cmpdob0c8bn</string>
|
|
||||||
<key>API_KEY</key>
|
|
||||||
<string>AIzaSyCf57HjCwSokt91CqFI0Mwf8D--ek0jvfc</string>
|
|
||||||
<key>GCM_SENDER_ID</key>
|
|
||||||
<string>768133699366</string>
|
|
||||||
<key>PLIST_VERSION</key>
|
|
||||||
<string>1</string>
|
|
||||||
<key>BUNDLE_ID</key>
|
|
||||||
<string>com.carriez.flutterHbb</string>
|
|
||||||
<key>PROJECT_ID</key>
|
|
||||||
<string>rustdesk</string>
|
|
||||||
<key>STORAGE_BUCKET</key>
|
|
||||||
<string>rustdesk.appspot.com</string>
|
|
||||||
<key>IS_ADS_ENABLED</key>
|
|
||||||
<false></false>
|
|
||||||
<key>IS_ANALYTICS_ENABLED</key>
|
|
||||||
<false></false>
|
|
||||||
<key>IS_APPINVITE_ENABLED</key>
|
|
||||||
<true></true>
|
|
||||||
<key>IS_GCM_ENABLED</key>
|
|
||||||
<true></true>
|
|
||||||
<key>IS_SIGNIN_ENABLED</key>
|
|
||||||
<true></true>
|
|
||||||
<key>GOOGLE_APP_ID</key>
|
|
||||||
<string>1:768133699366:ios:c33078a6181b9d507993e7</string>
|
|
||||||
<key>DATABASE_URL</key>
|
|
||||||
<string>https://rustdesk.firebaseio.com</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -43,6 +43,8 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>UIFileSharingEnabled</key>
|
||||||
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
@@ -60,6 +62,8 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>UISupportsDocumentBrowser</key>
|
||||||
|
<true/>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
cargo build --features flutter,hwcodec --release --target aarch64-apple-ios --lib
|
cargo build --locked --features flutter,hwcodec --release --target aarch64-apple-ios --lib
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
cargo build --features flutter --release --target x86_64-apple-ios --lib
|
cargo build --locked --features flutter --release --target x86_64-apple-ios --lib
|
||||||
|
|||||||
@@ -13,15 +13,18 @@ import 'package:flutter_hbb/desktop/widgets/refresh_wrapper.dart';
|
|||||||
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||||
import 'package:flutter_hbb/main.dart';
|
import 'package:flutter_hbb/main.dart';
|
||||||
import 'package:flutter_hbb/models/peer_model.dart';
|
import 'package:flutter_hbb/models/peer_model.dart';
|
||||||
|
import 'package:flutter_hbb/models/peer_tab_model.dart';
|
||||||
import 'package:flutter_hbb/models/state_model.dart';
|
import 'package:flutter_hbb/models/state_model.dart';
|
||||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||||
import 'package:flutter_hbb/utils/platform_channel.dart';
|
import 'package:flutter_hbb/utils/platform_channel.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
import 'package:get/get_rx/src/rx_workers/utils/debouncer.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:uni_links/uni_links.dart';
|
import 'package:uni_links/uni_links.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
import 'package:window_size/window_size.dart' as window_size;
|
import 'package:window_size/window_size.dart' as window_size;
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ import 'package:flutter_hbb/native/win32.dart'
|
|||||||
if (dart.library.html) 'package:flutter_hbb/web/win32.dart';
|
if (dart.library.html) 'package:flutter_hbb/web/win32.dart';
|
||||||
import 'package:flutter_hbb/native/common.dart'
|
import 'package:flutter_hbb/native/common.dart'
|
||||||
if (dart.library.html) 'package:flutter_hbb/web/common.dart';
|
if (dart.library.html) 'package:flutter_hbb/web/common.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:flutter_hbb/utils/http_service.dart' as http;
|
||||||
|
|
||||||
final globalKey = GlobalKey<NavigatorState>();
|
final globalKey = GlobalKey<NavigatorState>();
|
||||||
final navigationBarKey = GlobalKey();
|
final navigationBarKey = GlobalKey();
|
||||||
@@ -75,6 +78,9 @@ bool _ignoreDevicePixelRatio = true;
|
|||||||
int windowsBuildNumber = 0;
|
int windowsBuildNumber = 0;
|
||||||
DesktopType? desktopType;
|
DesktopType? desktopType;
|
||||||
|
|
||||||
|
// Tolerance used for floating-point position comparisons to avoid precision errors.
|
||||||
|
const double _kPositionEpsilon = 1e-6;
|
||||||
|
|
||||||
bool get isMainDesktopWindow =>
|
bool get isMainDesktopWindow =>
|
||||||
desktopType == DesktopType.main || desktopType == DesktopType.cm;
|
desktopType == DesktopType.main || desktopType == DesktopType.cm;
|
||||||
|
|
||||||
@@ -106,6 +112,10 @@ enum DesktopType {
|
|||||||
portForward,
|
portForward,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool isDoubleEqual(double a, double b) {
|
||||||
|
return (a - b).abs() < _kPositionEpsilon;
|
||||||
|
}
|
||||||
|
|
||||||
class IconFont {
|
class IconFont {
|
||||||
static const _family1 = 'Tabbar';
|
static const _family1 = 'Tabbar';
|
||||||
static const _family2 = 'PeerSearchbar';
|
static const _family2 = 'PeerSearchbar';
|
||||||
@@ -706,6 +716,17 @@ closeConnection({String? id}) {
|
|||||||
stateGlobal.isInMainPage = true;
|
stateGlobal.isInMainPage = true;
|
||||||
} else {
|
} else {
|
||||||
final controller = Get.find<DesktopTabController>();
|
final controller = Get.find<DesktopTabController>();
|
||||||
|
if (controller.tabType == DesktopTabType.terminal &&
|
||||||
|
controller.onCloseWindow != null) {
|
||||||
|
// Terminal windows are scoped to one peer. The optional id passed to
|
||||||
|
// closeConnection() is that peer id, not a terminal tab key
|
||||||
|
// (${peerId}_${terminalId}). Closing from terminal dialogs should close
|
||||||
|
// the peer's whole terminal window, including all terminal tabs.
|
||||||
|
unawaited(controller.onCloseWindow!().catchError((e, _) {
|
||||||
|
debugPrint('[closeConnection] Failed to close terminal window: $e');
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
controller.closeBy(id);
|
controller.closeBy(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1001,13 +1022,15 @@ makeMobileActionsOverlayEntry(VoidCallback? onHide, {FFI? ffi}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void showToast(String text, {Duration timeout = const Duration(seconds: 3)}) {
|
void showToast(String text,
|
||||||
|
{Duration timeout = const Duration(seconds: 3),
|
||||||
|
Alignment alignment = const Alignment(0.0, 0.8)}) {
|
||||||
final overlayState = globalKey.currentState?.overlay;
|
final overlayState = globalKey.currentState?.overlay;
|
||||||
if (overlayState == null) return;
|
if (overlayState == null) return;
|
||||||
final entry = OverlayEntry(builder: (context) {
|
final entry = OverlayEntry(builder: (context) {
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: const Alignment(0.0, 0.8),
|
alignment: alignment,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: MyTheme.color(context).toastBg,
|
color: MyTheme.color(context).toastBg,
|
||||||
@@ -1112,18 +1135,23 @@ class CustomAlertDialog extends StatelessWidget {
|
|||||||
|
|
||||||
Widget createDialogContent(String text) {
|
Widget createDialogContent(String text) {
|
||||||
final RegExp linkRegExp = RegExp(r'(https?://[^\s]+)');
|
final RegExp linkRegExp = RegExp(r'(https?://[^\s]+)');
|
||||||
|
bool hasLink = linkRegExp.hasMatch(text);
|
||||||
|
|
||||||
|
// Early return: no link, use default theme color
|
||||||
|
if (!hasLink) {
|
||||||
|
return SelectableText(text, style: const TextStyle(fontSize: 15));
|
||||||
|
}
|
||||||
|
|
||||||
final List<TextSpan> spans = [];
|
final List<TextSpan> spans = [];
|
||||||
int start = 0;
|
int start = 0;
|
||||||
bool hasLink = false;
|
|
||||||
|
|
||||||
linkRegExp.allMatches(text).forEach((match) {
|
linkRegExp.allMatches(text).forEach((match) {
|
||||||
hasLink = true;
|
|
||||||
if (match.start > start) {
|
if (match.start > start) {
|
||||||
spans.add(TextSpan(text: text.substring(start, match.start)));
|
spans.add(TextSpan(text: text.substring(start, match.start)));
|
||||||
}
|
}
|
||||||
spans.add(TextSpan(
|
spans.add(TextSpan(
|
||||||
text: match.group(0) ?? '',
|
text: match.group(0) ?? '',
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.blue,
|
color: Colors.blue,
|
||||||
decoration: TextDecoration.underline,
|
decoration: TextDecoration.underline,
|
||||||
),
|
),
|
||||||
@@ -1141,13 +1169,9 @@ Widget createDialogContent(String text) {
|
|||||||
spans.add(TextSpan(text: text.substring(start)));
|
spans.add(TextSpan(text: text.substring(start)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasLink) {
|
|
||||||
return SelectableText(text, style: const TextStyle(fontSize: 15));
|
|
||||||
}
|
|
||||||
|
|
||||||
return SelectableText.rich(
|
return SelectableText.rich(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
style: TextStyle(color: Colors.black, fontSize: 15),
|
style: const TextStyle(fontSize: 15),
|
||||||
children: spans,
|
children: spans,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1161,6 +1185,48 @@ void msgBox(SessionID sessionId, String type, String title, String text,
|
|||||||
VoidCallback? onSubmit,
|
VoidCallback? onSubmit,
|
||||||
int? submitTimeout}) {
|
int? submitTimeout}) {
|
||||||
dialogManager.dismissAll();
|
dialogManager.dismissAll();
|
||||||
|
if (type.contains('insecure-connection')) {
|
||||||
|
Future<void> closeSession() async {
|
||||||
|
await bind.sessionSetCommon(
|
||||||
|
sessionId: sessionId,
|
||||||
|
key: 'continue-insecure-connection',
|
||||||
|
value: 'N',
|
||||||
|
);
|
||||||
|
dialogManager.dismissAll();
|
||||||
|
closeConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
void continueSession() {
|
||||||
|
unawaited(
|
||||||
|
bind.sessionSetCommon(
|
||||||
|
sessionId: sessionId,
|
||||||
|
key: 'continue-insecure-connection',
|
||||||
|
value: 'Y',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
dialogManager.dismissAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogManager.show(
|
||||||
|
(setState, close, context) => CustomAlertDialog(
|
||||||
|
title: null,
|
||||||
|
content: SelectionArea(child: msgboxContent(type, title, text)),
|
||||||
|
actions: [
|
||||||
|
dialogButton(
|
||||||
|
'Continue',
|
||||||
|
onPressed: continueSession,
|
||||||
|
isOutline: true,
|
||||||
|
),
|
||||||
|
dialogButton('Disconnect', onPressed: closeSession),
|
||||||
|
],
|
||||||
|
onSubmit: closeSession,
|
||||||
|
onCancel: closeSession,
|
||||||
|
),
|
||||||
|
tag: '$sessionId-$type-$title-$text-$link',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
List<Widget> buttons = [];
|
List<Widget> buttons = [];
|
||||||
bool hasOk = false;
|
bool hasOk = false;
|
||||||
submit() {
|
submit() {
|
||||||
@@ -1566,7 +1632,7 @@ bool option2bool(String option, String value) {
|
|||||||
option == kOptionForceAlwaysRelay) {
|
option == kOptionForceAlwaysRelay) {
|
||||||
res = value == "Y";
|
res = value == "Y";
|
||||||
} else {
|
} else {
|
||||||
assert(false);
|
// "" is true
|
||||||
res = value != "N";
|
res = value != "N";
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
@@ -1584,9 +1650,6 @@ String bool2option(String option, bool b) {
|
|||||||
option == kOptionForceAlwaysRelay) {
|
option == kOptionForceAlwaysRelay) {
|
||||||
res = b ? 'Y' : defaultOptionNo;
|
res = b ? 'Y' : defaultOptionNo;
|
||||||
} else {
|
} else {
|
||||||
if (option != kOptionEnableUdpPunch && option != kOptionEnableIpv6Punch) {
|
|
||||||
assert(false);
|
|
||||||
}
|
|
||||||
res = b ? 'Y' : 'N';
|
res = b ? 'Y' : 'N';
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
@@ -1622,7 +1685,8 @@ bool mainGetPeerBoolOptionSync(String id, String key) {
|
|||||||
// Use `sessionGetToggleOption()` and `sessionToggleOption()` instead.
|
// Use `sessionGetToggleOption()` and `sessionToggleOption()` instead.
|
||||||
// Because all session options use `Y` and `<Empty>` as values.
|
// Because all session options use `Y` and `<Empty>` as values.
|
||||||
|
|
||||||
Future<bool> matchPeer(String searchText, Peer peer) async {
|
Future<bool> matchPeer(
|
||||||
|
String searchText, Peer peer, PeerTabIndex peerTabIndex) async {
|
||||||
if (searchText.isEmpty) {
|
if (searchText.isEmpty) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1633,11 +1697,14 @@ Future<bool> matchPeer(String searchText, Peer peer) async {
|
|||||||
peer.username.toLowerCase().contains(searchText)) {
|
peer.username.toLowerCase().contains(searchText)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
final alias = peer.alias;
|
if (peer.alias.toLowerCase().contains(searchText)) {
|
||||||
if (alias.isEmpty) {
|
return true;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
return alias.toLowerCase().contains(searchText);
|
if (peerTabShowNote(peerTabIndex) &&
|
||||||
|
peer.note.toLowerCase().contains(searchText)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the image for the current [platform].
|
/// Get the image for the current [platform].
|
||||||
@@ -1667,6 +1734,15 @@ class LastWindowPosition {
|
|||||||
LastWindowPosition(this.width, this.height, this.offsetWidth,
|
LastWindowPosition(this.width, this.height, this.offsetWidth,
|
||||||
this.offsetHeight, this.isMaximized, this.isFullscreen);
|
this.offsetHeight, this.isMaximized, this.isFullscreen);
|
||||||
|
|
||||||
|
bool equals(LastWindowPosition other) {
|
||||||
|
return ((width == other.width) &&
|
||||||
|
(height == other.height) &&
|
||||||
|
(offsetWidth == other.offsetWidth) &&
|
||||||
|
(offsetHeight == other.offsetHeight) &&
|
||||||
|
(isMaximized == other.isMaximized) &&
|
||||||
|
(isFullscreen == other.isFullscreen));
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
"width": width,
|
"width": width,
|
||||||
@@ -1706,24 +1782,36 @@ String get windowFramePrefix =>
|
|||||||
? "incoming_"
|
? "incoming_"
|
||||||
: (bind.isOutgoingOnly() ? "outgoing_" : ""));
|
: (bind.isOutgoingOnly() ? "outgoing_" : ""));
|
||||||
|
|
||||||
|
typedef WindowKey = ({WindowType type, int? windowId});
|
||||||
|
|
||||||
|
LastWindowPosition? _lastWindowPosition = null;
|
||||||
|
final Debouncer _saveWindowDebounce = Debouncer(delay: Duration(seconds: 1));
|
||||||
|
|
||||||
/// Save window position and size on exit
|
/// Save window position and size on exit
|
||||||
/// Note that windowId must be provided if it's subwindow
|
/// Note that windowId must be provided if it's subwindow
|
||||||
Future<void> saveWindowPosition(WindowType type, {int? windowId}) async {
|
Future<void> saveWindowPosition(WindowType type,
|
||||||
|
{int? windowId, bool? flush}) async {
|
||||||
if (type != WindowType.Main && windowId == null) {
|
if (type != WindowType.Main && windowId == null) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
"Error: windowId cannot be null when saving positions for sub window");
|
"Error: windowId cannot be null when saving positions for sub window");
|
||||||
}
|
}
|
||||||
|
|
||||||
late Offset position;
|
Offset? position;
|
||||||
late Size sz;
|
Size? sz;
|
||||||
late bool isMaximized;
|
late bool isMaximized;
|
||||||
bool isFullscreen = stateGlobal.fullscreen.isTrue;
|
bool isFullscreen = stateGlobal.fullscreen.isTrue;
|
||||||
|
|
||||||
setPreFrame() {
|
setPreFrame() {
|
||||||
final pos = bind.getLocalFlutterOption(k: windowFramePrefix + type.name);
|
final pos = bind.getLocalFlutterOption(k: windowFramePrefix + type.name);
|
||||||
var lpos = LastWindowPosition.loadFromString(pos);
|
var lpos = LastWindowPosition.loadFromString(pos);
|
||||||
position = Offset(
|
if (lpos != null) {
|
||||||
lpos?.offsetWidth ?? position.dx, lpos?.offsetHeight ?? position.dy);
|
if (lpos.offsetWidth != null && lpos.offsetHeight != null) {
|
||||||
sz = Size(lpos?.width ?? sz.width, lpos?.height ?? sz.height);
|
position = Offset(lpos.offsetWidth!, lpos.offsetHeight!);
|
||||||
|
}
|
||||||
|
if (lpos.width != null && lpos.height != null) {
|
||||||
|
sz = Size(lpos.width!, lpos.height!);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -1763,30 +1851,56 @@ Future<void> saveWindowPosition(WindowType type, {int? windowId}) async {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (isWindows) {
|
if (isWindows && position != null) {
|
||||||
const kMinOffset = -10000;
|
const kMinOffset = -10000;
|
||||||
const kMaxOffset = 10000;
|
const kMaxOffset = 10000;
|
||||||
if (position.dx < kMinOffset ||
|
if (position!.dx < kMinOffset ||
|
||||||
position.dy < kMinOffset ||
|
position!.dy < kMinOffset ||
|
||||||
position.dx > kMaxOffset ||
|
position!.dx > kMaxOffset ||
|
||||||
position.dy > kMaxOffset) {
|
position!.dy > kMaxOffset) {
|
||||||
debugPrint("Invalid position: $position, ignore saving position");
|
debugPrint("Invalid position: $position, ignore saving position");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final pos = LastWindowPosition(
|
final pos = LastWindowPosition(sz?.width, sz?.height, position?.dx,
|
||||||
sz.width, sz.height, position.dx, position.dy, isMaximized, isFullscreen);
|
position?.dy, isMaximized, isFullscreen);
|
||||||
|
|
||||||
|
final WindowKey key = (type: type, windowId: windowId);
|
||||||
|
|
||||||
|
final bool haveNewWindowPosition =
|
||||||
|
(_lastWindowPosition == null) || !pos.equals(_lastWindowPosition!);
|
||||||
|
final bool isPreviousNewWindowPositionPending = _saveWindowDebounce.isRunning;
|
||||||
|
|
||||||
|
if (haveNewWindowPosition || isPreviousNewWindowPositionPending) {
|
||||||
|
_lastWindowPosition = pos;
|
||||||
|
|
||||||
|
if (flush ?? false) {
|
||||||
|
// If a previous update is pending, replace it.
|
||||||
|
_saveWindowDebounce.cancel();
|
||||||
|
await _saveWindowPositionActual(key);
|
||||||
|
} else if (haveNewWindowPosition) {
|
||||||
|
_saveWindowDebounce.call(() => _saveWindowPositionActual(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveWindowPositionActual(WindowKey key) async {
|
||||||
|
LastWindowPosition? pos = _lastWindowPosition;
|
||||||
|
|
||||||
|
if (pos != null) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
"Saving frame: $windowId: ${pos.width}/${pos.height}, offset:${pos.offsetWidth}/${pos.offsetHeight}, isMaximized:${pos.isMaximized}, isFullscreen:${pos.isFullscreen}");
|
"Saving frame: ${key.windowId}: ${pos.width}/${pos.height}, offset:${pos.offsetWidth}/${pos.offsetHeight}, isMaximized:${pos.isMaximized}, isFullscreen:${pos.isFullscreen}");
|
||||||
|
|
||||||
await bind.setLocalFlutterOption(
|
await bind.setLocalFlutterOption(
|
||||||
k: windowFramePrefix + type.name, v: pos.toString());
|
k: windowFramePrefix + key.type.name, v: pos.toString());
|
||||||
|
|
||||||
if ((type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) &&
|
if ((key.type == WindowType.RemoteDesktop ||
|
||||||
windowId != null) {
|
key.type == WindowType.ViewCamera) &&
|
||||||
await _saveSessionWindowPosition(
|
key.windowId != null) {
|
||||||
type, windowId, isMaximized, isFullscreen, pos);
|
await _saveSessionWindowPosition(key.type, key.windowId!,
|
||||||
|
pos.isMaximized ?? false, pos.isFullscreen ?? false, pos);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1852,6 +1966,8 @@ Future<Size> _adjustRestoreMainWindowSize(double? width, double? height) async {
|
|||||||
return Size(restoreWidth, restoreHeight);
|
return Size(restoreWidth, restoreHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Consider using Rect.contains() instead,
|
||||||
|
// though the implementation is not exactly the same.
|
||||||
bool isPointInRect(Offset point, Rect rect) {
|
bool isPointInRect(Offset point, Rect rect) {
|
||||||
return point.dx >= rect.left &&
|
return point.dx >= rect.left &&
|
||||||
point.dx <= rect.right &&
|
point.dx <= rect.right &&
|
||||||
@@ -1870,44 +1986,41 @@ Future<Offset?> _adjustRestoreMainWindowOffset(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
double? frameLeft;
|
|
||||||
double? frameTop;
|
|
||||||
double? frameRight;
|
|
||||||
double? frameBottom;
|
|
||||||
|
|
||||||
if (isDesktop || isWebDesktop) {
|
if (isDesktop || isWebDesktop) {
|
||||||
for (final screen in await window_size.getScreenList()) {
|
final screens = await window_size.getScreenList();
|
||||||
frameLeft = frameLeft == null
|
if (screens.isNotEmpty) {
|
||||||
? screen.visibleFrame.left
|
final windowRect = Rect.fromLTWH(left, top, width, height);
|
||||||
: min(screen.visibleFrame.left, frameLeft);
|
bool isVisible = false;
|
||||||
frameTop = frameTop == null
|
for (final screen in screens) {
|
||||||
? screen.visibleFrame.top
|
final intersection = windowRect.intersect(screen.visibleFrame);
|
||||||
: min(screen.visibleFrame.top, frameTop);
|
if (intersection.width >= 10.0 && intersection.height >= 10.0) {
|
||||||
frameRight = frameRight == null
|
isVisible = true;
|
||||||
? screen.visibleFrame.right
|
break;
|
||||||
: max(screen.visibleFrame.right, frameRight);
|
|
||||||
frameBottom = frameBottom == null
|
|
||||||
? screen.visibleFrame.bottom
|
|
||||||
: max(screen.visibleFrame.bottom, frameBottom);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (frameLeft == null) {
|
if (!isVisible) {
|
||||||
frameLeft = 0.0;
|
return null;
|
||||||
frameTop = 0.0;
|
}
|
||||||
frameRight = ((isDesktop || isWebDesktop)
|
return Offset(left, top);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double frameLeft = 0.0;
|
||||||
|
double frameTop = 0.0;
|
||||||
|
double frameRight = ((isDesktop || isWebDesktop)
|
||||||
? kDesktopMaxDisplaySize
|
? kDesktopMaxDisplaySize
|
||||||
: kMobileMaxDisplaySize)
|
: kMobileMaxDisplaySize)
|
||||||
.toDouble();
|
.toDouble();
|
||||||
frameBottom = ((isDesktop || isWebDesktop)
|
double frameBottom = ((isDesktop || isWebDesktop)
|
||||||
? kDesktopMaxDisplaySize
|
? kDesktopMaxDisplaySize
|
||||||
: kMobileMaxDisplaySize)
|
: kMobileMaxDisplaySize)
|
||||||
.toDouble();
|
.toDouble();
|
||||||
}
|
|
||||||
final minWidth = 10.0;
|
final minWidth = 10.0;
|
||||||
if ((left + minWidth) > frameRight! ||
|
if ((left + minWidth) > frameRight ||
|
||||||
(top + minWidth) > frameBottom! ||
|
(top + minWidth) > frameBottom ||
|
||||||
(left + width - minWidth) < frameLeft ||
|
(left + width - minWidth) < frameLeft ||
|
||||||
top < frameTop!) {
|
top < frameTop) {
|
||||||
return null;
|
return null;
|
||||||
} else {
|
} else {
|
||||||
return Offset(left, top);
|
return Offset(left, top);
|
||||||
@@ -1949,8 +2062,24 @@ Future<bool> restoreWindowPosition(WindowType type,
|
|||||||
|
|
||||||
var lpos = LastWindowPosition.loadFromString(pos);
|
var lpos = LastWindowPosition.loadFromString(pos);
|
||||||
if (lpos == null) {
|
if (lpos == null) {
|
||||||
debugPrint("no window position saved, ignoring position restoration");
|
debugPrint("No window position saved, trying to center the window.");
|
||||||
return false;
|
switch (type) {
|
||||||
|
case WindowType.Main:
|
||||||
|
// Center the main window only if no position is saved (on first run).
|
||||||
|
if (isWindows || isLinux) {
|
||||||
|
await windowManager.center();
|
||||||
|
}
|
||||||
|
// For MacOS, the window is already centered by default.
|
||||||
|
// See https://github.com/rustdesk/rustdesk/blob/9b9276e7524523d7f667fefcd0694d981443df0e/flutter/macos/Runner/Base.lproj/MainMenu.xib#L333
|
||||||
|
// If `<windowPositionMask>` in `<window>` is not set, the window will be centered.
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// No need to change the position of a sub window if no position is saved,
|
||||||
|
// since the default position is already centered.
|
||||||
|
// https://github.com/rustdesk/rustdesk/blob/317639169359936f7f9f85ef445ec9774218772d/flutter/lib/utils/multi_window_manager.dart#L163
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
if (type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) {
|
if (type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) {
|
||||||
if (!isRemotePeerPos && windowId != null) {
|
if (!isRemotePeerPos && windowId != null) {
|
||||||
@@ -2289,6 +2418,19 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
|
|||||||
id = uri.path.substring("/new/".length);
|
id = uri.path.substring("/new/".length);
|
||||||
} else if (uri.authority == "config") {
|
} else if (uri.authority == "config") {
|
||||||
if (isAndroid || isIOS) {
|
if (isAndroid || isIOS) {
|
||||||
|
final allowDeepLinkServerSettings =
|
||||||
|
bind.mainGetBuildinOption(key: kOptionAllowDeepLinkServerSettings) ==
|
||||||
|
'Y';
|
||||||
|
if (!allowDeepLinkServerSettings) {
|
||||||
|
debugPrint(
|
||||||
|
"Ignore rustdesk://config because $kOptionAllowDeepLinkServerSettings is not enabled.");
|
||||||
|
// Keep the user-facing error generic; detailed rejection reason is in debug logs.
|
||||||
|
// Delay toast to avoid missing overlay during cold-start deeplink handling.
|
||||||
|
Timer(Duration(seconds: 1), () {
|
||||||
|
showToast(translate('Failed'));
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
final config = uri.path.substring("/".length);
|
final config = uri.path.substring("/".length);
|
||||||
// add a timer to make showToast work
|
// add a timer to make showToast work
|
||||||
Timer(Duration(seconds: 1), () {
|
Timer(Duration(seconds: 1), () {
|
||||||
@@ -2298,11 +2440,24 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
|
|||||||
return null;
|
return null;
|
||||||
} else if (uri.authority == "password") {
|
} else if (uri.authority == "password") {
|
||||||
if (isAndroid || isIOS) {
|
if (isAndroid || isIOS) {
|
||||||
|
final allowDeepLinkPassword =
|
||||||
|
bind.mainGetBuildinOption(key: kOptionAllowDeepLinkPassword) == 'Y';
|
||||||
|
if (!allowDeepLinkPassword) {
|
||||||
|
debugPrint(
|
||||||
|
"Ignore rustdesk://password because $kOptionAllowDeepLinkPassword is not enabled.");
|
||||||
|
// Keep the user-facing error generic; detailed rejection reason is in debug logs.
|
||||||
|
// Delay toast to avoid missing overlay during cold-start deeplink handling.
|
||||||
|
Timer(Duration(seconds: 1), () {
|
||||||
|
showToast(translate('Failed'));
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
final password = uri.path.substring("/".length);
|
final password = uri.path.substring("/".length);
|
||||||
if (password.isNotEmpty) {
|
if (password.isNotEmpty) {
|
||||||
Timer(Duration(seconds: 1), () async {
|
Timer(Duration(seconds: 1), () async {
|
||||||
await bind.mainSetPermanentPassword(password: password);
|
final ok =
|
||||||
showToast(translate('Successful'));
|
await bind.mainSetPermanentPasswordWithResult(password: password);
|
||||||
|
showToast(translate(ok ? 'Successful' : 'Failed'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2598,6 +2753,55 @@ class SimpleWrapper<T> {
|
|||||||
SimpleWrapper(this.value);
|
SimpleWrapper(this.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wakelock manager with reference counting for desktop.
|
||||||
|
/// Ensures wakelock is only disabled when all sessions are closed/minimized.
|
||||||
|
///
|
||||||
|
/// Note: Each isolate has its own WakelockPlus instance with independent assertion.
|
||||||
|
/// As long as one isolate has wakelock enabled, the screen stays awake.
|
||||||
|
/// This manager handles multiple tabs within the same isolate.
|
||||||
|
class WakelockManager {
|
||||||
|
static final Set<UniqueKey> _enabledKeys = {};
|
||||||
|
// Don't use WakelockPlus.enabled, it causes error on Android:
|
||||||
|
// Unhandled Exception: FormatException: Message corrupted
|
||||||
|
//
|
||||||
|
// On Linux, multiple enable() calls create only one inhibit, but each disable()
|
||||||
|
// only releases if _cookie != null. So we need our own _enabled state to avoid
|
||||||
|
// calling disable() when not enabled.
|
||||||
|
// See: https://github.com/fluttercommunity/wakelock_plus/blob/0c74e5bbc6aefac57b6c96bb7ef987705ed559ec/wakelock_plus/lib/src/wakelock_plus_linux_plugin.dart#L48
|
||||||
|
static bool _enabled = false;
|
||||||
|
|
||||||
|
static void enable(UniqueKey key, {bool isServer = false}) {
|
||||||
|
// Check if we should keep awake during outgoing sessions
|
||||||
|
if (!isServer) {
|
||||||
|
final keepAwake =
|
||||||
|
mainGetLocalBoolOptionSync(kOptionKeepAwakeDuringOutgoingSessions);
|
||||||
|
if (!keepAwake) {
|
||||||
|
return; // Don't enable wakelock if user disabled keep awake
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isDesktop) {
|
||||||
|
_enabledKeys.add(key);
|
||||||
|
}
|
||||||
|
if (!_enabled) {
|
||||||
|
_enabled = true;
|
||||||
|
WakelockPlus.enable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void disable(UniqueKey key) {
|
||||||
|
if (isDesktop) {
|
||||||
|
_enabledKeys.remove(key);
|
||||||
|
if (_enabledKeys.isNotEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_enabled) {
|
||||||
|
WakelockPlus.disable();
|
||||||
|
_enabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// call this to reload current window.
|
/// call this to reload current window.
|
||||||
///
|
///
|
||||||
/// [Note]
|
/// [Note]
|
||||||
@@ -2871,7 +3075,7 @@ Future<void> updateSystemWindowTheme() async {
|
|||||||
///
|
///
|
||||||
/// Note: not found a general solution for rust based AVFoundation bingding.
|
/// Note: not found a general solution for rust based AVFoundation bingding.
|
||||||
/// [AVFoundation] crate has compile error.
|
/// [AVFoundation] crate has compile error.
|
||||||
const kMacOSPermChannel = MethodChannel("org.rustdesk.rustdesk/macos");
|
const kMacOSPermChannel = MethodChannel("org.rustdesk.rustdesk/host");
|
||||||
|
|
||||||
enum PermissionAuthorizeType {
|
enum PermissionAuthorizeType {
|
||||||
undetermined,
|
undetermined,
|
||||||
@@ -2920,6 +3124,15 @@ void onCopyFingerprint(String value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void onCopyId(String value) {
|
||||||
|
if (value.isNotEmpty) {
|
||||||
|
Clipboard.setData(ClipboardData(text: value));
|
||||||
|
showToast('$value\n${translate("Copied")}');
|
||||||
|
} else {
|
||||||
|
showToast(translate("Invalid ID"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> callMainCheckSuperUserPermission() async {
|
Future<bool> callMainCheckSuperUserPermission() async {
|
||||||
bool checked = await bind.mainCheckSuperUserPermission();
|
bool checked = await bind.mainCheckSuperUserPermission();
|
||||||
if (isMacOS) {
|
if (isMacOS) {
|
||||||
@@ -2938,10 +3151,26 @@ Future<void> start_service(bool is_start) async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> canBeBlocked() async {
|
Future<bool> canBeBlocked() async {
|
||||||
var access_mode = await bind.mainGetOption(key: kOptionAccessMode);
|
if (isWeb) {
|
||||||
|
// Web can only act as a controller, never as a controlled side,
|
||||||
|
// so it should never be blocked by a remote session.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// First check control permission
|
||||||
|
final controlPermission = await bind.mainGetCommon(
|
||||||
|
key: "is-remote-modify-enabled-by-control-permissions");
|
||||||
|
if (controlPermission == "true") {
|
||||||
|
return false;
|
||||||
|
} else if (controlPermission == "false") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check local settings
|
||||||
|
var accessMode = await bind.mainGetOption(key: kOptionAccessMode);
|
||||||
|
var isCustomAccessMode = accessMode != 'full' && accessMode != 'view';
|
||||||
var option = option2bool(kOptionAllowRemoteConfigModification,
|
var option = option2bool(kOptionAllowRemoteConfigModification,
|
||||||
await bind.mainGetOption(key: kOptionAllowRemoteConfigModification));
|
await bind.mainGetOption(key: kOptionAllowRemoteConfigModification));
|
||||||
return access_mode == 'view' || (access_mode.isEmpty && !option);
|
return accessMode == 'view' || (isCustomAccessMode && !option);
|
||||||
}
|
}
|
||||||
|
|
||||||
// to-do: web not implemented
|
// to-do: web not implemented
|
||||||
@@ -3172,7 +3401,12 @@ Future<List<Rect>> getScreenRectList() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openMonitorInTheSameTab(int i, FFI ffi, PeerInfo pi,
|
openMonitorInTheSameTab(int i, FFI ffi, PeerInfo pi,
|
||||||
{bool updateCursorPos = true}) {
|
{bool updateCursorPos = true, bool recordSelection = true}) {
|
||||||
|
if (recordSelection) {
|
||||||
|
ffi.ffiModel.lastUserDisplay = i;
|
||||||
|
ffi.ffiModel.cancelPendingRestoreTimer();
|
||||||
|
ffi.ffiModel.pendingMonitorRestore = null;
|
||||||
|
}
|
||||||
final displays = i == kAllDisplayValue
|
final displays = i == kAllDisplayValue
|
||||||
? List.generate(pi.displays.length, (index) => index)
|
? List.generate(pi.displays.length, (index) => index)
|
||||||
: [i];
|
: [i];
|
||||||
@@ -3535,14 +3769,54 @@ Widget loadPowered(BuildContext context) {
|
|||||||
).marginOnly(top: 6);
|
).marginOnly(top: 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
// max 300 x 60
|
const _kDefaultLogoAsset = 'assets/logo.png';
|
||||||
Widget loadLogo() {
|
const _kLightLogoAsset = 'assets/logo_light.png';
|
||||||
return FutureBuilder<ByteData>(
|
const _kDarkLogoAsset = 'assets/logo_dark.png';
|
||||||
future: rootBundle.load('assets/logo.png'),
|
|
||||||
builder: (BuildContext context, AsyncSnapshot<ByteData> snapshot) {
|
List<String> _logoAssetCandidatesForBrightness(Brightness brightness) {
|
||||||
if (snapshot.hasData) {
|
return brightness == Brightness.dark
|
||||||
|
? [_kDarkLogoAsset, _kDefaultLogoAsset]
|
||||||
|
: [_kLightLogoAsset, _kDefaultLogoAsset];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _resolveLogoAsset(Brightness brightness) async {
|
||||||
|
for (final asset in _logoAssetCandidatesForBrightness(brightness)) {
|
||||||
|
try {
|
||||||
|
await rootBundle.load(asset);
|
||||||
|
return asset;
|
||||||
|
} on FlutterError {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Logo extends StatefulWidget {
|
||||||
|
const _Logo();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_Logo> createState() => _LogoState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LogoState extends State<_Logo> {
|
||||||
|
final Map<Brightness, Future<String?>> _logoFutures = {};
|
||||||
|
|
||||||
|
Future<String?> _logoFutureFor(Brightness brightness) {
|
||||||
|
return _logoFutures.putIfAbsent(
|
||||||
|
brightness,
|
||||||
|
() => _resolveLogoAsset(brightness),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FutureBuilder<String?>(
|
||||||
|
future: _logoFutureFor(Theme.of(context).brightness),
|
||||||
|
builder: (BuildContext context, AsyncSnapshot<String?> snapshot) {
|
||||||
|
final asset = snapshot.data;
|
||||||
|
if (asset != null) {
|
||||||
final image = Image.asset(
|
final image = Image.asset(
|
||||||
'assets/logo.png',
|
asset,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
errorBuilder: (ctx, error, stackTrace) {
|
errorBuilder: (ctx, error, stackTrace) {
|
||||||
return Container();
|
return Container();
|
||||||
@@ -3554,8 +3828,13 @@ Widget loadLogo() {
|
|||||||
).marginOnly(left: 12, right: 12, top: 12);
|
).marginOnly(left: 12, right: 12, top: 12);
|
||||||
}
|
}
|
||||||
return const Offstage();
|
return const Offstage();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// max 300 x 60
|
||||||
|
Widget loadLogo() => const _Logo();
|
||||||
|
|
||||||
Widget loadIcon(double size) {
|
Widget loadIcon(double size) {
|
||||||
return Image.asset('assets/icon.png',
|
return Image.asset('assets/icon.png',
|
||||||
@@ -3704,6 +3983,16 @@ setResizable(bool resizable) {
|
|||||||
|
|
||||||
isOptionFixed(String key) => bind.mainIsOptionFixed(key: key);
|
isOptionFixed(String key) => bind.mainIsOptionFixed(key: key);
|
||||||
|
|
||||||
|
bool isChangePermanentPasswordDisabled() =>
|
||||||
|
bind.mainGetBuildinOption(key: kOptionDisableChangePermanentPassword) ==
|
||||||
|
'Y';
|
||||||
|
|
||||||
|
bool isChangeIdDisabled() =>
|
||||||
|
bind.mainGetBuildinOption(key: kOptionDisableChangeId) == 'Y';
|
||||||
|
|
||||||
|
bool isUnlockPinDisabled() =>
|
||||||
|
bind.mainGetBuildinOption(key: kOptionDisableUnlockPin) == 'Y';
|
||||||
|
|
||||||
bool? _isCustomClient;
|
bool? _isCustomClient;
|
||||||
bool get isCustomClient {
|
bool get isCustomClient {
|
||||||
_isCustomClient ??= bind.isCustomClient();
|
_isCustomClient ??= bind.isCustomClient();
|
||||||
@@ -3724,6 +4013,11 @@ bool whitelistNotEmpty() {
|
|||||||
return v != '' && v != ',';
|
return v != '' && v != ',';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool idWhitelistNotEmpty() {
|
||||||
|
final v = bind.mainGetOptionSync(key: kOptionIdWhitelist);
|
||||||
|
return v != '' && v != ',';
|
||||||
|
}
|
||||||
|
|
||||||
// `setMovable()` is only supported on macOS.
|
// `setMovable()` is only supported on macOS.
|
||||||
//
|
//
|
||||||
// On macOS, the window can be dragged by the tab bar by default.
|
// On macOS, the window can be dragged by the tab bar by default.
|
||||||
@@ -3754,6 +4048,7 @@ Widget netWorkErrorWidget() {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
if (!gFFI.userModel.networkErrorFromServer.value)
|
||||||
Text(translate("network_error_tip")),
|
Text(translate("network_error_tip")),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: gFFI.userModel.refreshCurrentUser,
|
onPressed: gFFI.userModel.refreshCurrentUser,
|
||||||
@@ -3943,3 +4238,66 @@ String decode_http_response(http.Response resp) {
|
|||||||
return resp.body;
|
return resp.body;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool peerTabShowNote(PeerTabIndex peerTabIndex) {
|
||||||
|
return peerTabIndex == PeerTabIndex.ab || peerTabIndex == PeerTabIndex.group;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: We should support individual bits combinations in the future.
|
||||||
|
// But for now, just keep it simple, because the old code only supports single button.
|
||||||
|
// No users have requested multi-button support yet.
|
||||||
|
String mouseButtonsToPeer(int buttons) {
|
||||||
|
switch (buttons) {
|
||||||
|
case kPrimaryMouseButton:
|
||||||
|
return 'left';
|
||||||
|
case kSecondaryMouseButton:
|
||||||
|
return 'right';
|
||||||
|
case kMiddleMouseButton:
|
||||||
|
return 'wheel';
|
||||||
|
case kBackMouseButton:
|
||||||
|
return 'back';
|
||||||
|
case kForwardMouseButton:
|
||||||
|
return 'forward';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an avatar widget from an avatar URL or data URI string.
|
||||||
|
/// Returns [fallback] if avatar is empty or cannot be decoded.
|
||||||
|
/// [borderRadius] defaults to [size]/2 (circle).
|
||||||
|
Widget? buildAvatarWidget({
|
||||||
|
required String avatar,
|
||||||
|
required double size,
|
||||||
|
double? borderRadius,
|
||||||
|
Widget? fallback,
|
||||||
|
}) {
|
||||||
|
final trimmed = avatar.trim();
|
||||||
|
if (trimmed.isEmpty) return fallback;
|
||||||
|
|
||||||
|
ImageProvider? imageProvider;
|
||||||
|
if (trimmed.startsWith('data:image/')) {
|
||||||
|
final comma = trimmed.indexOf(',');
|
||||||
|
if (comma > 0) {
|
||||||
|
try {
|
||||||
|
imageProvider = MemoryImage(base64Decode(trimmed.substring(comma + 1)));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
} else if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
||||||
|
imageProvider = NetworkImage(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageProvider == null) return fallback;
|
||||||
|
|
||||||
|
final radius = borderRadius ?? size / 2;
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(radius),
|
||||||
|
child: Image(
|
||||||
|
image: imageProvider,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => fallback ?? SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ enum UserStatus { kDisabled, kNormal, kUnverified }
|
|||||||
// Is all the fields of the user needed?
|
// Is all the fields of the user needed?
|
||||||
class UserPayload {
|
class UserPayload {
|
||||||
String name = '';
|
String name = '';
|
||||||
|
String displayName = '';
|
||||||
|
String avatar = '';
|
||||||
String email = '';
|
String email = '';
|
||||||
String note = '';
|
String note = '';
|
||||||
String? verifier;
|
String? verifier;
|
||||||
@@ -33,6 +35,8 @@ class UserPayload {
|
|||||||
|
|
||||||
UserPayload.fromJson(Map<String, dynamic> json)
|
UserPayload.fromJson(Map<String, dynamic> json)
|
||||||
: name = json['name'] ?? '',
|
: name = json['name'] ?? '',
|
||||||
|
displayName = json['display_name'] ?? '',
|
||||||
|
avatar = json['avatar'] ?? '',
|
||||||
email = json['email'] ?? '',
|
email = json['email'] ?? '',
|
||||||
note = json['note'] ?? '',
|
note = json['note'] ?? '',
|
||||||
verifier = json['verifier'],
|
verifier = json['verifier'],
|
||||||
@@ -46,6 +50,8 @@ class UserPayload {
|
|||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final Map<String, dynamic> map = {
|
final Map<String, dynamic> map = {
|
||||||
'name': name,
|
'name': name,
|
||||||
|
'display_name': displayName,
|
||||||
|
'avatar': avatar,
|
||||||
'status': status == UserStatus.kDisabled
|
'status': status == UserStatus.kDisabled
|
||||||
? 0
|
? 0
|
||||||
: status == UserStatus.kUnverified
|
: status == UserStatus.kUnverified
|
||||||
@@ -58,9 +64,14 @@ class UserPayload {
|
|||||||
Map<String, dynamic> toGroupCacheJson() {
|
Map<String, dynamic> toGroupCacheJson() {
|
||||||
final Map<String, dynamic> map = {
|
final Map<String, dynamic> map = {
|
||||||
'name': name,
|
'name': name,
|
||||||
|
'display_name': displayName,
|
||||||
};
|
};
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String get displayNameOrName {
|
||||||
|
return displayName.trim().isEmpty ? name : displayName;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class PeerPayload {
|
class PeerPayload {
|
||||||
@@ -89,6 +100,7 @@ class PeerPayload {
|
|||||||
"platform": _platform(p.info['os']),
|
"platform": _platform(p.info['os']),
|
||||||
"hostname": p.info['device_name'],
|
"hostname": p.info['device_name'],
|
||||||
"device_group_name": p.device_group_name,
|
"device_group_name": p.device_group_name,
|
||||||
|
"note": p.note,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ class _AddressBookState extends State<AddressBook> {
|
|||||||
const LinearProgressIndicator(),
|
const LinearProgressIndicator(),
|
||||||
buildErrorBanner(context,
|
buildErrorBanner(context,
|
||||||
loading: gFFI.abModel.currentAbLoading,
|
loading: gFFI.abModel.currentAbLoading,
|
||||||
err: gFFI.abModel.currentAbPullError,
|
err: gFFI.abModel.abPullError,
|
||||||
retry: null,
|
retry: null,
|
||||||
close: () => gFFI.abModel.currentAbPullError.value = ''),
|
close: gFFI.abModel.clearPullErrors),
|
||||||
buildErrorBanner(context,
|
buildErrorBanner(context,
|
||||||
loading: gFFI.abModel.currentAbLoading,
|
loading: gFFI.abModel.currentAbLoading,
|
||||||
err: gFFI.abModel.currentAbPushError,
|
err: gFFI.abModel.currentAbPushError,
|
||||||
@@ -466,6 +466,7 @@ class _AddressBookState extends State<AddressBook> {
|
|||||||
IDTextEditingController idController = IDTextEditingController(text: '');
|
IDTextEditingController idController = IDTextEditingController(text: '');
|
||||||
TextEditingController aliasController = TextEditingController(text: '');
|
TextEditingController aliasController = TextEditingController(text: '');
|
||||||
TextEditingController passwordController = TextEditingController(text: '');
|
TextEditingController passwordController = TextEditingController(text: '');
|
||||||
|
TextEditingController noteController = TextEditingController(text: '');
|
||||||
final tags = List.of(gFFI.abModel.currentAbTags);
|
final tags = List.of(gFFI.abModel.currentAbTags);
|
||||||
var selectedTag = List<dynamic>.empty(growable: true).obs;
|
var selectedTag = List<dynamic>.empty(growable: true).obs;
|
||||||
final style = TextStyle(fontSize: 14.0);
|
final style = TextStyle(fontSize: 14.0);
|
||||||
@@ -494,7 +495,11 @@ class _AddressBookState extends State<AddressBook> {
|
|||||||
password = passwordController.text;
|
password = passwordController.text;
|
||||||
}
|
}
|
||||||
String? errMsg2 = await gFFI.abModel.addIdToCurrent(
|
String? errMsg2 = await gFFI.abModel.addIdToCurrent(
|
||||||
id, aliasController.text.trim(), password, selectedTag);
|
id,
|
||||||
|
aliasController.text.trim(),
|
||||||
|
password,
|
||||||
|
selectedTag,
|
||||||
|
noteController.text);
|
||||||
if (errMsg2 != null) {
|
if (errMsg2 != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
isInProgress = false;
|
isInProgress = false;
|
||||||
@@ -600,6 +605,24 @@ class _AddressBookState extends State<AddressBook> {
|
|||||||
),
|
),
|
||||||
).workaroundFreezeLinuxMint(),
|
).workaroundFreezeLinuxMint(),
|
||||||
)),
|
)),
|
||||||
|
row(
|
||||||
|
label: Text(
|
||||||
|
translate('Note'),
|
||||||
|
style: style,
|
||||||
|
),
|
||||||
|
input: Obx(
|
||||||
|
() => TextField(
|
||||||
|
controller: noteController,
|
||||||
|
maxLines: 3,
|
||||||
|
minLines: 1,
|
||||||
|
maxLength: 300,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: stateGlobal.isPortrait.isFalse
|
||||||
|
? null
|
||||||
|
: translate('Note'),
|
||||||
|
),
|
||||||
|
).workaroundFreezeLinuxMint(),
|
||||||
|
)),
|
||||||
if (gFFI.abModel.currentAbTags.isNotEmpty)
|
if (gFFI.abModel.currentAbTags.isNotEmpty)
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hbb/common/formatter/id_formatter.dart';
|
import 'package:flutter_hbb/common/formatter/id_formatter.dart';
|
||||||
import '../../../models/platform_model.dart';
|
import '../../../models/platform_model.dart';
|
||||||
@@ -5,27 +8,136 @@ import 'package:flutter_hbb/models/peer_model.dart';
|
|||||||
import 'package:flutter_hbb/common.dart';
|
import 'package:flutter_hbb/common.dart';
|
||||||
import 'package:flutter_hbb/common/widgets/peer_card.dart';
|
import 'package:flutter_hbb/common/widgets/peer_card.dart';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
List<Peer> mergeAutocompletePeers({
|
||||||
|
Iterable<Peer> addressBookPeers = const [],
|
||||||
|
Iterable<Peer> groupPeers = const [],
|
||||||
|
Iterable<Peer> lanPeers = const [],
|
||||||
|
Iterable<Peer> recentPeers = const [],
|
||||||
|
Iterable<String> restRecentPeerIds = const [],
|
||||||
|
}) {
|
||||||
|
final combinedPeers = <String, Peer>{};
|
||||||
|
|
||||||
|
void addPeer(Peer peer) {
|
||||||
|
if (peer.id.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final existingPeer = combinedPeers[peer.id];
|
||||||
|
if (existingPeer == null) {
|
||||||
|
combinedPeers[peer.id] = Peer.copy(peer);
|
||||||
|
} else if (peer.online) {
|
||||||
|
existingPeer.online = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final peer in addressBookPeers) {
|
||||||
|
addPeer(peer);
|
||||||
|
}
|
||||||
|
for (final peer in groupPeers) {
|
||||||
|
addPeer(peer);
|
||||||
|
}
|
||||||
|
for (final peer in lanPeers) {
|
||||||
|
addPeer(peer);
|
||||||
|
}
|
||||||
|
for (final peer in recentPeers) {
|
||||||
|
addPeer(peer);
|
||||||
|
}
|
||||||
|
for (final id in restRecentPeerIds) {
|
||||||
|
if (id.isNotEmpty && !combinedPeers.containsKey(id)) {
|
||||||
|
combinedPeers[id] = Peer.fromJson({'id': id});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return combinedPeers.values.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
bool updateAutocompletePeerOnlineStates(
|
||||||
|
List<Peer> peers, {
|
||||||
|
required Set<String> onlines,
|
||||||
|
required Set<String> offlines,
|
||||||
|
}) {
|
||||||
|
var changed = false;
|
||||||
|
for (final peer in peers) {
|
||||||
|
if (onlines.contains(peer.id)) {
|
||||||
|
if (!peer.online) {
|
||||||
|
peer.online = true;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else if (offlines.contains(peer.id)) {
|
||||||
|
if (peer.online) {
|
||||||
|
peer.online = false;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
List<String> autocompleteOnlineQueryIds(
|
||||||
|
Iterable<Peer> options, {
|
||||||
|
required int limit,
|
||||||
|
}) {
|
||||||
|
final ids = <String>[];
|
||||||
|
final seenIds = <String>{};
|
||||||
|
for (final peer in options) {
|
||||||
|
if (peer.id.isEmpty || seenIds.contains(peer.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenIds.add(peer.id);
|
||||||
|
ids.add(peer.id);
|
||||||
|
if (ids.length >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
class AllPeersLoader {
|
class AllPeersLoader {
|
||||||
List<Peer> peers = [];
|
List<Peer> peers = [];
|
||||||
|
|
||||||
bool _isPeersLoading = false;
|
bool _isPeersLoading = false;
|
||||||
bool _isPeersLoaded = false;
|
bool _isPeersLoaded = false;
|
||||||
|
Set<String> _lastQueryOnlineIds = {};
|
||||||
|
DateTime _lastQueryOnlineTime = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
Timer? _queryOnlineTimer;
|
||||||
|
List<Peer> _lastQueryOnlineOptions = const [];
|
||||||
|
Set<String> _lastOnlineIds = {};
|
||||||
|
Set<String> _lastOfflineIds = {};
|
||||||
|
final Future<void> Function(List<String> ids) _queryOnlines;
|
||||||
|
final Duration _queryOnlineDebounce;
|
||||||
|
void Function(VoidCallback)? _setState;
|
||||||
|
bool _isCleared = false;
|
||||||
|
|
||||||
final String _listenerKey = 'AllPeersLoader';
|
final String _listenerKey = 'AllPeersLoader';
|
||||||
|
static const String _cbQueryOnlines = 'callback_query_onlines';
|
||||||
late void Function(VoidCallback) setState;
|
static const Duration _queryOnlineInterval = Duration(seconds: 5);
|
||||||
|
static const Duration _defaultQueryOnlineDebounce =
|
||||||
|
Duration(milliseconds: 300);
|
||||||
|
static const int _maxQueryOnlineOptions = 20;
|
||||||
|
|
||||||
bool get needLoad => !_isPeersLoaded && !_isPeersLoading;
|
bool get needLoad => !_isPeersLoaded && !_isPeersLoading;
|
||||||
bool get isPeersLoaded => _isPeersLoaded;
|
bool get isPeersLoaded => _isPeersLoaded;
|
||||||
|
|
||||||
AllPeersLoader();
|
AllPeersLoader({
|
||||||
|
@visibleForTesting Future<void> Function(List<String> ids)? queryOnlines,
|
||||||
|
@visibleForTesting Duration? queryOnlineDebounce,
|
||||||
|
}) : _queryOnlines = queryOnlines ?? ((ids) => bind.queryOnlines(ids: ids)),
|
||||||
|
_queryOnlineDebounce =
|
||||||
|
queryOnlineDebounce ?? _defaultQueryOnlineDebounce;
|
||||||
|
|
||||||
void init(void Function(VoidCallback) setState) {
|
void init(void Function(VoidCallback) setState) {
|
||||||
this.setState = setState;
|
_setState = setState;
|
||||||
|
_isCleared = false;
|
||||||
gFFI.recentPeersModel.addListener(_mergeAllPeers);
|
gFFI.recentPeersModel.addListener(_mergeAllPeers);
|
||||||
gFFI.lanPeersModel.addListener(_mergeAllPeers);
|
gFFI.lanPeersModel.addListener(_mergeAllPeers);
|
||||||
gFFI.abModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
|
gFFI.abModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
|
||||||
gFFI.groupModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
|
gFFI.groupModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
|
||||||
|
platformFFI.registerEventHandler(_cbQueryOnlines, _listenerKey,
|
||||||
|
(evt) async {
|
||||||
|
_updateOnlineState(evt);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void clear() {
|
void clear() {
|
||||||
@@ -33,6 +145,11 @@ class AllPeersLoader {
|
|||||||
gFFI.lanPeersModel.removeListener(_mergeAllPeers);
|
gFFI.lanPeersModel.removeListener(_mergeAllPeers);
|
||||||
gFFI.abModel.removePeerUpdateListener(_listenerKey);
|
gFFI.abModel.removePeerUpdateListener(_listenerKey);
|
||||||
gFFI.groupModel.removePeerUpdateListener(_listenerKey);
|
gFFI.groupModel.removePeerUpdateListener(_listenerKey);
|
||||||
|
platformFFI.unregisterEventHandler(_cbQueryOnlines, _listenerKey);
|
||||||
|
_queryOnlineTimer?.cancel();
|
||||||
|
_lastQueryOnlineOptions = const [];
|
||||||
|
_setState = null;
|
||||||
|
_isCleared = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> getAllPeers() async {
|
Future<void> getAllPeers() async {
|
||||||
@@ -59,50 +176,106 @@ class AllPeersLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _mergeAllPeers() {
|
void _mergeAllPeers() {
|
||||||
Map<String, dynamic> combinedPeers = {};
|
if (_isCleared) {
|
||||||
for (var p in gFFI.abModel.allPeers()) {
|
return;
|
||||||
if (!combinedPeers.containsKey(p.id)) {
|
|
||||||
combinedPeers[p.id] = p.toJson();
|
|
||||||
}
|
}
|
||||||
}
|
peers = mergeAutocompletePeers(
|
||||||
for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) {
|
addressBookPeers: gFFI.abModel.allPeers(),
|
||||||
if (!combinedPeers.containsKey(p.id)) {
|
groupPeers: gFFI.groupModel.peers,
|
||||||
combinedPeers[p.id] = p.toJson();
|
lanPeers: gFFI.lanPeersModel.peers,
|
||||||
}
|
recentPeers: gFFI.recentPeersModel.peers,
|
||||||
}
|
restRecentPeerIds: gFFI.recentPeersModel.restPeerIds,
|
||||||
|
);
|
||||||
List<Peer> parsedPeers = [];
|
_applyLastOnlineState(peers);
|
||||||
for (var peer in combinedPeers.values) {
|
_scheduleSetState(() {
|
||||||
parsedPeers.add(Peer.fromJson(peer));
|
|
||||||
}
|
|
||||||
|
|
||||||
Set<String> peerIds = combinedPeers.keys.toSet();
|
|
||||||
for (final peer in gFFI.lanPeersModel.peers) {
|
|
||||||
if (!peerIds.contains(peer.id)) {
|
|
||||||
parsedPeers.add(peer);
|
|
||||||
peerIds.add(peer.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final peer in gFFI.recentPeersModel.peers) {
|
|
||||||
if (!peerIds.contains(peer.id)) {
|
|
||||||
parsedPeers.add(peer);
|
|
||||||
peerIds.add(peer.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (final id in gFFI.recentPeersModel.restPeerIds) {
|
|
||||||
if (!peerIds.contains(id)) {
|
|
||||||
parsedPeers.add(Peer.fromJson({'id': id}));
|
|
||||||
peerIds.add(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
peers = parsedPeers;
|
|
||||||
setState(() {
|
|
||||||
_isPeersLoading = false;
|
_isPeersLoading = false;
|
||||||
_isPeersLoaded = true;
|
_isPeersLoaded = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _updateOnlineState(Map<String, dynamic> evt) {
|
||||||
|
if (_isCleared) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastOnlineIds = _splitPeerIds(evt['onlines']);
|
||||||
|
_lastOfflineIds = _splitPeerIds(evt['offlines']);
|
||||||
|
final peersChanged = _applyLastOnlineState(peers);
|
||||||
|
final optionsChanged = _applyLastOnlineState(_lastQueryOnlineOptions);
|
||||||
|
if (peersChanged || optionsChanged) {
|
||||||
|
_scheduleSetState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleSetState(VoidCallback callback) {
|
||||||
|
if (_isCleared) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final setState = _setState;
|
||||||
|
if (setState == null) {
|
||||||
|
callback();
|
||||||
|
} else {
|
||||||
|
setState(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _applyLastOnlineState(List<Peer> peers) {
|
||||||
|
return updateAutocompletePeerOnlineStates(
|
||||||
|
peers,
|
||||||
|
onlines: _lastOnlineIds,
|
||||||
|
offlines: _lastOfflineIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> _splitPeerIds(dynamic ids) {
|
||||||
|
if (ids is! String || ids.isEmpty) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return ids.split(',').where((id) => id.isNotEmpty).toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
void queryOnlines(Iterable<Peer> options) {
|
||||||
|
if (_isCleared) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastQueryOnlineOptions = options.toList(growable: false);
|
||||||
|
final ids = autocompleteOnlineQueryIds(
|
||||||
|
_lastQueryOnlineOptions,
|
||||||
|
limit: _maxQueryOnlineOptions,
|
||||||
|
).toSet();
|
||||||
|
_queryOnlineTimer?.cancel();
|
||||||
|
_queryOnlineTimer = null;
|
||||||
|
if (ids.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (setEquals(ids, _lastQueryOnlineIds) &&
|
||||||
|
now.difference(_lastQueryOnlineTime) < _queryOnlineInterval) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_queryOnlineTimer = Timer(_queryOnlineDebounce, () async {
|
||||||
|
try {
|
||||||
|
await _queryOnlines(ids.toList(growable: false));
|
||||||
|
if (_isCleared) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastQueryOnlineIds = ids;
|
||||||
|
_lastQueryOnlineTime = DateTime.now();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('query autocomplete online state failed: $e');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
void updateOnlineStateForTesting(Map<String, dynamic> evt) {
|
||||||
|
_updateOnlineState(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
bool applyLastOnlineStateForTesting(List<Peer> peers) {
|
||||||
|
return _applyLastOnlineState(peers);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class AutocompletePeerTile extends StatefulWidget {
|
class AutocompletePeerTile extends StatefulWidget {
|
||||||
|
|||||||
156
flutter/lib/common/widgets/custom_scale_base.dart
Normal file
156
flutter/lib/common/widgets/custom_scale_base.dart
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:debounce_throttle/debounce_throttle.dart';
|
||||||
|
import 'package:flutter_hbb/consts.dart';
|
||||||
|
import 'package:flutter_hbb/models/model.dart';
|
||||||
|
import 'package:flutter_hbb/models/platform_model.dart';
|
||||||
|
import 'package:flutter_hbb/utils/scale.dart';
|
||||||
|
import 'package:flutter_hbb/common.dart';
|
||||||
|
|
||||||
|
/// Base class providing shared custom scale control logic for both mobile and desktop widgets.
|
||||||
|
/// Implementations must provide [ffi] and [onScaleChanged] getters.
|
||||||
|
abstract class CustomScaleControls<T extends StatefulWidget> extends State<T> {
|
||||||
|
/// FFI instance for session interaction
|
||||||
|
FFI get ffi;
|
||||||
|
|
||||||
|
/// Callback invoked when scale value changes
|
||||||
|
ValueChanged<int>? get onScaleChanged;
|
||||||
|
|
||||||
|
late int _scaleValue;
|
||||||
|
late final Debouncer<int> _debouncerScale;
|
||||||
|
// Normalized slider position in [0, 1]. We map it nonlinearly to percent.
|
||||||
|
double _scalePos = 0.0;
|
||||||
|
|
||||||
|
int get scaleValue => _scaleValue;
|
||||||
|
double get scalePos => _scalePos;
|
||||||
|
|
||||||
|
int mapPosToPercent(double p) => _mapPosToPercent(p);
|
||||||
|
|
||||||
|
static const int minPercent = kScaleCustomMinPercent;
|
||||||
|
static const int pivotPercent = kScaleCustomPivotPercent; // 100% should be at 1/3 of track
|
||||||
|
static const int maxPercent = kScaleCustomMaxPercent;
|
||||||
|
static const double pivotPos = kScaleCustomPivotPos; // first 1/3 → up to 100%
|
||||||
|
static const double detentEpsilon = kScaleCustomDetentEpsilon; // snap range around pivot (~0.6%)
|
||||||
|
|
||||||
|
// Clamp helper for local use
|
||||||
|
int _clampScale(int v) => clampCustomScalePercent(v);
|
||||||
|
|
||||||
|
// Map normalized position [0,1] → percent [5,1000] with 100 at 1/3 width.
|
||||||
|
int _mapPosToPercent(double p) {
|
||||||
|
if (p <= 0.0) return minPercent;
|
||||||
|
if (p >= 1.0) return maxPercent;
|
||||||
|
if (p <= pivotPos) {
|
||||||
|
final q = p / pivotPos; // 0..1
|
||||||
|
final v = minPercent + q * (pivotPercent - minPercent);
|
||||||
|
return _clampScale(v.round());
|
||||||
|
} else {
|
||||||
|
final q = (p - pivotPos) / (1.0 - pivotPos); // 0..1
|
||||||
|
final v = pivotPercent + q * (maxPercent - pivotPercent);
|
||||||
|
return _clampScale(v.round());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map percent [5,1000] → normalized position [0,1]
|
||||||
|
double _mapPercentToPos(int percent) {
|
||||||
|
final p = _clampScale(percent);
|
||||||
|
if (p <= pivotPercent) {
|
||||||
|
final q = (p - minPercent) / (pivotPercent - minPercent);
|
||||||
|
return q * pivotPos;
|
||||||
|
} else {
|
||||||
|
final q = (p - pivotPercent) / (maxPercent - pivotPercent);
|
||||||
|
return pivotPos + q * (1.0 - pivotPos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snap normalized position to the pivot when close to it
|
||||||
|
double _snapNormalizedPos(double p) {
|
||||||
|
if ((p - pivotPos).abs() <= detentEpsilon) return pivotPos;
|
||||||
|
if (p < 0.0) return 0.0;
|
||||||
|
if (p > 1.0) return 1.0;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scaleValue = 100;
|
||||||
|
_debouncerScale = Debouncer<int>(
|
||||||
|
kDebounceCustomScaleDuration,
|
||||||
|
onChanged: (v) async {
|
||||||
|
await _applyScale(v);
|
||||||
|
},
|
||||||
|
initialValue: _scaleValue,
|
||||||
|
);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
try {
|
||||||
|
final v = await getSessionCustomScalePercent(ffi.sessionId);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_scaleValue = v;
|
||||||
|
_scalePos = _mapPercentToPos(v);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('[CustomScale] Failed to get initial value: $e');
|
||||||
|
debugPrintStack(stackTrace: st);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applyScale(int v) async {
|
||||||
|
v = clampCustomScalePercent(v);
|
||||||
|
setState(() {
|
||||||
|
_scaleValue = v;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await bind.sessionSetFlutterOption(
|
||||||
|
sessionId: ffi.sessionId,
|
||||||
|
k: kCustomScalePercentKey,
|
||||||
|
v: v.toString());
|
||||||
|
final curStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId);
|
||||||
|
if (curStyle != kRemoteViewStyleCustom) {
|
||||||
|
await bind.sessionSetViewStyle(
|
||||||
|
sessionId: ffi.sessionId, value: kRemoteViewStyleCustom);
|
||||||
|
}
|
||||||
|
await ffi.canvasModel.updateViewStyle();
|
||||||
|
if (isMobile) {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
}
|
||||||
|
onScaleChanged?.call(v);
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('[CustomScale] Apply failed: $e');
|
||||||
|
debugPrintStack(stackTrace: st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void nudgeScale(int delta) {
|
||||||
|
final next = _clampScale(_scaleValue + delta);
|
||||||
|
setState(() {
|
||||||
|
_scaleValue = next;
|
||||||
|
_scalePos = _mapPercentToPos(next);
|
||||||
|
});
|
||||||
|
onScaleChanged?.call(next);
|
||||||
|
_debouncerScale.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_debouncerScale.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void onSliderChanged(double v) {
|
||||||
|
final snapped = _snapNormalizedPos(v);
|
||||||
|
final next = _mapPosToPercent(snapped);
|
||||||
|
if (next != _scaleValue || snapped != _scalePos) {
|
||||||
|
setState(() {
|
||||||
|
_scalePos = snapped;
|
||||||
|
_scaleValue = next;
|
||||||
|
});
|
||||||
|
onScaleChanged?.call(next);
|
||||||
|
_debouncerScale.value = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,20 +7,29 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_hbb/common/shared_state.dart';
|
import 'package:flutter_hbb/common/shared_state.dart';
|
||||||
import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
|
import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
|
||||||
import 'package:flutter_hbb/consts.dart';
|
import 'package:flutter_hbb/consts.dart';
|
||||||
|
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||||
import 'package:flutter_hbb/models/peer_model.dart';
|
import 'package:flutter_hbb/models/peer_model.dart';
|
||||||
import 'package:flutter_hbb/models/peer_tab_model.dart';
|
import 'package:flutter_hbb/models/peer_tab_model.dart';
|
||||||
import 'package:flutter_hbb/models/state_model.dart';
|
import 'package:flutter_hbb/models/state_model.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:qr_flutter/qr_flutter.dart';
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
|
import 'package:flutter_hbb/utils/http_service.dart' as http;
|
||||||
|
|
||||||
import '../../common.dart';
|
import '../../common.dart';
|
||||||
import '../../models/model.dart';
|
import '../../models/model.dart';
|
||||||
import '../../models/platform_model.dart';
|
import '../../models/platform_model.dart';
|
||||||
import 'address_book.dart';
|
import 'address_book.dart';
|
||||||
|
|
||||||
void clientClose(SessionID sessionId, OverlayDialogManager dialogManager) {
|
void clientClose(SessionID sessionId, FFI ffi) async {
|
||||||
|
if (allowAskForNoteAtEndOfConnection(ffi, true)) {
|
||||||
|
if (await showConnEndAuditDialogCloseCanceled(ffi: ffi)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeConnection();
|
||||||
|
} else {
|
||||||
msgBox(sessionId, 'info', 'Close', 'Are you sure to close the connection?',
|
msgBox(sessionId, 'info', 'Close', 'Are you sure to close the connection?',
|
||||||
'', dialogManager);
|
'', ffi.dialogManager);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class ValidationRule {
|
abstract class ValidationRule {
|
||||||
@@ -196,6 +205,10 @@ void changeWhiteList({Function()? callback}) async {
|
|||||||
const SizedBox(
|
const SizedBox(
|
||||||
height: 8.0,
|
height: 8.0,
|
||||||
),
|
),
|
||||||
|
Text(translate("whitelist_cidr_tip")),
|
||||||
|
const SizedBox(
|
||||||
|
height: 8.0,
|
||||||
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -273,6 +286,111 @@ void changeWhiteList({Function()? callback}) async {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void changeIdWhiteList({Function()? callback}) async {
|
||||||
|
final curIdWhiteList = await bind.mainGetOption(key: kOptionIdWhitelist);
|
||||||
|
var newIdWhiteListField = curIdWhiteList == defaultOptionWhitelist
|
||||||
|
? ''
|
||||||
|
: curIdWhiteList.split(',').join('\n');
|
||||||
|
var controller = TextEditingController(text: newIdWhiteListField);
|
||||||
|
var msg = "";
|
||||||
|
var isInProgress = false;
|
||||||
|
final isOptFixed = isOptionFixed(kOptionIdWhitelist);
|
||||||
|
gFFI.dialogManager.show((setState, close, context) {
|
||||||
|
return CustomAlertDialog(
|
||||||
|
title: Text(translate("ID whitelisting")),
|
||||||
|
content: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(translate("whitelist_sep")),
|
||||||
|
const SizedBox(
|
||||||
|
height: 8.0,
|
||||||
|
),
|
||||||
|
Text(translate("id_whitelist_wildcard_tip")),
|
||||||
|
const SizedBox(
|
||||||
|
height: 8.0,
|
||||||
|
),
|
||||||
|
Text(translate("id_whitelist_caveat_tip")),
|
||||||
|
const SizedBox(
|
||||||
|
height: 8.0,
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
maxLines: null,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
errorText: msg.isEmpty ? null : translate(msg),
|
||||||
|
),
|
||||||
|
controller: controller,
|
||||||
|
enabled: !isOptFixed,
|
||||||
|
autofocus: true)
|
||||||
|
.workaroundFreezeLinuxMint(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 4.0,
|
||||||
|
),
|
||||||
|
// NOT use Offstage to wrap LinearProgressIndicator
|
||||||
|
if (isInProgress) const LinearProgressIndicator(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
dialogButton("Cancel", onPressed: close, isOutline: true),
|
||||||
|
if (!isOptFixed)
|
||||||
|
dialogButton("Clear", onPressed: () async {
|
||||||
|
await bind.mainSetOption(
|
||||||
|
key: kOptionIdWhitelist, value: defaultOptionWhitelist);
|
||||||
|
callback?.call();
|
||||||
|
close();
|
||||||
|
}, isOutline: true),
|
||||||
|
if (!isOptFixed)
|
||||||
|
dialogButton(
|
||||||
|
"OK",
|
||||||
|
onPressed: () async {
|
||||||
|
setState(() {
|
||||||
|
msg = "";
|
||||||
|
isInProgress = true;
|
||||||
|
});
|
||||||
|
newIdWhiteListField = controller.text.trim();
|
||||||
|
var newIdWhiteList = "";
|
||||||
|
if (newIdWhiteListField.isEmpty) {
|
||||||
|
// pass
|
||||||
|
} else {
|
||||||
|
final ids = newIdWhiteListField
|
||||||
|
.trim()
|
||||||
|
.split(RegExp(r"[\s,;\n]+"))
|
||||||
|
.where((e) => e.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
// Separators are handled above; allow all other Unicode characters.
|
||||||
|
for (final id in ids) {
|
||||||
|
final hasControlCharacters = id.runes.any(
|
||||||
|
(char) => char <= 0x1f || (char >= 0x7f && char <= 0x9f));
|
||||||
|
if (hasControlCharacters) {
|
||||||
|
msg = "${translate("Invalid ID")} $id";
|
||||||
|
setState(() {
|
||||||
|
isInProgress = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newIdWhiteList = ids.join(',');
|
||||||
|
}
|
||||||
|
if (newIdWhiteList.trim().isEmpty) {
|
||||||
|
newIdWhiteList = defaultOptionWhitelist;
|
||||||
|
}
|
||||||
|
await bind.mainSetOption(
|
||||||
|
key: kOptionIdWhitelist, value: newIdWhiteList);
|
||||||
|
callback?.call();
|
||||||
|
close();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onCancel: close,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<String> changeDirectAccessPort(
|
Future<String> changeDirectAccessPort(
|
||||||
String currentIP, String currentPort) async {
|
String currentIP, String currentPort) async {
|
||||||
final controller = TextEditingController(text: currentPort);
|
final controller = TextEditingController(text: currentPort);
|
||||||
@@ -1509,17 +1627,11 @@ showSetOSAccount(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
showAuditDialog(FFI ffi) async {
|
Widget buildNoteTextField({
|
||||||
final controller = TextEditingController(text: ffi.auditNote);
|
required TextEditingController controller,
|
||||||
ffi.dialogManager.show((setState, close, context) {
|
required VoidCallback onEscape,
|
||||||
submit() {
|
}) {
|
||||||
var text = controller.text;
|
final focusNode = FocusNode(
|
||||||
bind.sessionSendNote(sessionId: ffi.sessionId, note: text);
|
|
||||||
ffi.auditNote = text;
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
|
|
||||||
late final focusNode = FocusNode(
|
|
||||||
onKey: (FocusNode node, RawKeyEvent evt) {
|
onKey: (FocusNode node, RawKeyEvent evt) {
|
||||||
if (evt.logicalKey.keyLabel == 'Enter') {
|
if (evt.logicalKey.keyLabel == 'Enter') {
|
||||||
if (evt is RawKeyDownEvent) {
|
if (evt is RawKeyDownEvent) {
|
||||||
@@ -1533,7 +1645,7 @@ showAuditDialog(FFI ffi) async {
|
|||||||
}
|
}
|
||||||
if (evt.logicalKey.keyLabel == 'Esc') {
|
if (evt.logicalKey.keyLabel == 'Esc') {
|
||||||
if (evt is RawKeyDownEvent) {
|
if (evt is RawKeyDownEvent) {
|
||||||
close();
|
onEscape();
|
||||||
}
|
}
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
} else {
|
} else {
|
||||||
@@ -1542,23 +1654,44 @@ showAuditDialog(FFI ffi) async {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return TextField(
|
||||||
|
autofocus: true,
|
||||||
|
keyboardType: TextInputType.multiline,
|
||||||
|
textInputAction: TextInputAction.newline,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: translate('input note here'),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
contentPadding: EdgeInsets.all(12),
|
||||||
|
),
|
||||||
|
minLines: 5,
|
||||||
|
maxLines: null,
|
||||||
|
maxLength: 256,
|
||||||
|
controller: controller,
|
||||||
|
focusNode: focusNode,
|
||||||
|
).workaroundFreezeLinuxMint();
|
||||||
|
}
|
||||||
|
|
||||||
|
showAuditDialog(FFI ffi) async {
|
||||||
|
final controller = TextEditingController(
|
||||||
|
text: bind.sessionGetLastAuditNote(sessionId: ffi.sessionId));
|
||||||
|
ffi.dialogManager.show((setState, close, context) {
|
||||||
|
submit() {
|
||||||
|
var text = controller.text;
|
||||||
|
bind.sessionSendNote(sessionId: ffi.sessionId, note: text);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
return CustomAlertDialog(
|
return CustomAlertDialog(
|
||||||
title: Text(translate('Note')),
|
title: Text(translate('Note')),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: 250,
|
width: 250,
|
||||||
height: 120,
|
height: 120,
|
||||||
child: TextField(
|
child: buildNoteTextField(
|
||||||
autofocus: true,
|
|
||||||
keyboardType: TextInputType.multiline,
|
|
||||||
textInputAction: TextInputAction.newline,
|
|
||||||
decoration: const InputDecoration.collapsed(
|
|
||||||
hintText: 'input note here',
|
|
||||||
),
|
|
||||||
maxLines: null,
|
|
||||||
maxLength: 256,
|
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: focusNode,
|
onEscape: close,
|
||||||
).workaroundFreezeLinuxMint()),
|
)),
|
||||||
actions: [
|
actions: [
|
||||||
dialogButton('Cancel', onPressed: close, isOutline: true),
|
dialogButton('Cancel', onPressed: close, isOutline: true),
|
||||||
dialogButton('OK', onPressed: submit)
|
dialogButton('OK', onPressed: submit)
|
||||||
@@ -1569,6 +1702,223 @@ showAuditDialog(FFI ffi) async {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool allowAskForNoteAtEndOfConnection(FFI? ffi, bool closedByControlling) {
|
||||||
|
if (ffi == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection) &&
|
||||||
|
bind
|
||||||
|
.sessionGetAuditServerSync(sessionId: ffi.sessionId, typ: "conn")
|
||||||
|
.isNotEmpty &&
|
||||||
|
bind.sessionGetAuditGuid(sessionId: ffi.sessionId).isNotEmpty &&
|
||||||
|
bind.sessionGetLastAuditNote(sessionId: ffi.sessionId).isEmpty &&
|
||||||
|
(!closedByControlling ||
|
||||||
|
bind.willSessionCloseCloseSession(sessionId: ffi.sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// return value: close canceled
|
||||||
|
// true: return
|
||||||
|
// false: go on
|
||||||
|
Future<bool> desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
{required String id, required DesktopTabController tabController}) async {
|
||||||
|
try {
|
||||||
|
final page =
|
||||||
|
tabController.state.value.tabs.firstWhere((tab) => tab.key == id).page;
|
||||||
|
final ffi = (page as dynamic).ffi;
|
||||||
|
final res = await showConnEndAuditDialogCloseCanceled(ffi: ffi);
|
||||||
|
return res;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to show audit dialog: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// return value:
|
||||||
|
// true: return
|
||||||
|
// false: go on
|
||||||
|
Future<bool> showConnEndAuditDialogCloseCanceled(
|
||||||
|
{required FFI ffi, String? type, String? title, String? text}) async {
|
||||||
|
final res = await _showConnEndAuditDialogCloseCanceled(
|
||||||
|
ffi: ffi, type: type, title: title, text: text);
|
||||||
|
if (res == true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// return value:
|
||||||
|
// true: return
|
||||||
|
// false / null: go on
|
||||||
|
Future<bool?> _showConnEndAuditDialogCloseCanceled({
|
||||||
|
required FFI ffi,
|
||||||
|
String? type,
|
||||||
|
String? title,
|
||||||
|
String? text,
|
||||||
|
}) async {
|
||||||
|
final closedByControlling = type == null;
|
||||||
|
final showDialog = allowAskForNoteAtEndOfConnection(ffi, closedByControlling);
|
||||||
|
if (!showDialog) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ffi.dialogManager.dismissAll();
|
||||||
|
|
||||||
|
Future<void> updateAuditNoteByGuid(String auditGuid, String note) async {
|
||||||
|
debugPrint('Updating audit note for GUID: $auditGuid, note: $note');
|
||||||
|
try {
|
||||||
|
final apiServer = await bind.mainGetApiServer();
|
||||||
|
if (apiServer.isEmpty) {
|
||||||
|
debugPrint('API server is empty, cannot update audit note');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final url = '$apiServer/api/audit';
|
||||||
|
var headers = getHttpHeaders();
|
||||||
|
headers['Content-Type'] = "application/json";
|
||||||
|
final body = jsonEncode({
|
||||||
|
'guid': auditGuid,
|
||||||
|
'note': note,
|
||||||
|
});
|
||||||
|
|
||||||
|
final response = await http.put(
|
||||||
|
Uri.parse(url),
|
||||||
|
headers: headers,
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
debugPrint('Successfully updated audit note for GUID: $auditGuid');
|
||||||
|
} else {
|
||||||
|
debugPrint(
|
||||||
|
'Failed to update audit note. Status: ${response.statusCode}, Body: ${response.body}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error updating audit note: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final controller = TextEditingController();
|
||||||
|
bool askForNote =
|
||||||
|
mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection);
|
||||||
|
final isOptFixed = isOptionFixed(kOptionAllowAskForNoteAtEndOfConnection);
|
||||||
|
bool isInProgress = false;
|
||||||
|
|
||||||
|
return await ffi.dialogManager.show<bool>((setState, close, context) {
|
||||||
|
cancel() {
|
||||||
|
close(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
set() async {
|
||||||
|
if (isInProgress) return;
|
||||||
|
setState(() {
|
||||||
|
isInProgress = true;
|
||||||
|
});
|
||||||
|
var text = controller.text;
|
||||||
|
if (text.isNotEmpty) {
|
||||||
|
await updateAuditNoteByGuid(
|
||||||
|
bind.sessionGetAuditGuid(sessionId: ffi.sessionId), text)
|
||||||
|
.timeout(const Duration(seconds: 6), onTimeout: () {
|
||||||
|
debugPrint('updateAuditNoteByGuid timeout after 6s');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Save the "ask for note" preference
|
||||||
|
if (!isOptFixed) {
|
||||||
|
await mainSetLocalBoolOption(
|
||||||
|
kOptionAllowAskForNoteAtEndOfConnection, askForNote);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
submit() async {
|
||||||
|
await set();
|
||||||
|
close(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
final buttons = [
|
||||||
|
dialogButton('OK', onPressed: isInProgress ? null : submit)
|
||||||
|
];
|
||||||
|
if (type == 'relay-hint' || type == 'relay-hint2') {
|
||||||
|
buttons.add(dialogButton('Retry', onPressed: () async {
|
||||||
|
await set();
|
||||||
|
close(true);
|
||||||
|
ffi.ffiModel.reconnect(ffi.dialogManager, ffi.sessionId, false);
|
||||||
|
}));
|
||||||
|
if (type == 'relay-hint2') {
|
||||||
|
buttons.add(dialogButton('Connect via relay', onPressed: () async {
|
||||||
|
await set();
|
||||||
|
close(true);
|
||||||
|
ffi.ffiModel.reconnect(ffi.dialogManager, ffi.sessionId, true);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (closedByControlling) {
|
||||||
|
buttons.add(dialogButton('Cancel',
|
||||||
|
onPressed: isInProgress ? null : cancel, isOutline: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget content;
|
||||||
|
if (closedByControlling) {
|
||||||
|
content = SelectionArea(
|
||||||
|
child: msgboxContent(
|
||||||
|
'info', 'Close', 'Are you sure to close the connection?'));
|
||||||
|
} else {
|
||||||
|
content =
|
||||||
|
SelectionArea(child: msgboxContent(type, title ?? '', text ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return CustomAlertDialog(
|
||||||
|
title: null,
|
||||||
|
content: SizedBox(
|
||||||
|
width: 350,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
content,
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
height: 120,
|
||||||
|
child: buildNoteTextField(
|
||||||
|
controller: controller,
|
||||||
|
onEscape: cancel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isOptFixed) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
InkWell(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
askForNote = !askForNote;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: askForNote,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
askForNote = value ?? false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
translate('note-at-conn-end-tip'),
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (isInProgress)
|
||||||
|
const LinearProgressIndicator().marginOnly(top: 4),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
actions: buttons,
|
||||||
|
onSubmit: submit,
|
||||||
|
onCancel: cancel,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void showConfirmSwitchSidesDialog(
|
void showConfirmSwitchSidesDialog(
|
||||||
SessionID sessionId, String id, OverlayDialogManager dialogManager) async {
|
SessionID sessionId, String id, OverlayDialogManager dialogManager) async {
|
||||||
dialogManager.show((setState, close, context) {
|
dialogManager.show((setState, close, context) {
|
||||||
@@ -1783,6 +2133,49 @@ void editAbTagDialog(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void editAbPeerNoteDialog(String id) {
|
||||||
|
var isInProgress = false;
|
||||||
|
final currentNote = gFFI.abModel.getPeerNote(id);
|
||||||
|
var controller = TextEditingController(text: currentNote);
|
||||||
|
|
||||||
|
gFFI.dialogManager.show((setState, close, context) {
|
||||||
|
submit() async {
|
||||||
|
setState(() {
|
||||||
|
isInProgress = true;
|
||||||
|
});
|
||||||
|
await gFFI.abModel.changeNote(id: id, note: controller.text);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return CustomAlertDialog(
|
||||||
|
title: Text(translate("Edit note")),
|
||||||
|
content: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
maxLines: 3,
|
||||||
|
minLines: 1,
|
||||||
|
maxLength: 300,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: translate('Note'),
|
||||||
|
),
|
||||||
|
).workaroundFreezeLinuxMint(),
|
||||||
|
// NOT use Offstage to wrap LinearProgressIndicator
|
||||||
|
if (isInProgress) const LinearProgressIndicator(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
dialogButton("Cancel", onPressed: close, isOutline: true),
|
||||||
|
dialogButton("OK", onPressed: submit),
|
||||||
|
],
|
||||||
|
onSubmit: submit,
|
||||||
|
onCancel: close,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void renameDialog(
|
void renameDialog(
|
||||||
{required String oldName,
|
{required String oldName,
|
||||||
FormFieldValidator<String>? validator,
|
FormFieldValidator<String>? validator,
|
||||||
@@ -2078,8 +2471,10 @@ void showWindowsSessionsDialog(
|
|||||||
|
|
||||||
return CustomAlertDialog(
|
return CustomAlertDialog(
|
||||||
title: null,
|
title: null,
|
||||||
content: msgboxContent(type, title, text),
|
content: Column(
|
||||||
actions: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
msgboxContent(type, title, text).marginOnly(bottom: 12),
|
||||||
ComboBox(
|
ComboBox(
|
||||||
keys: sids,
|
keys: sids,
|
||||||
values: names,
|
values: names,
|
||||||
@@ -2087,6 +2482,9 @@ void showWindowsSessionsDialog(
|
|||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
selectedUserValue = value;
|
selectedUserValue = value;
|
||||||
}),
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
dialogButton('Connect', onPressed: submit, isOutline: false),
|
dialogButton('Connect', onPressed: submit, isOutline: false),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_hbb/common/widgets/remote_input.dart';
|
||||||
|
|
||||||
enum GestureState {
|
enum GestureState {
|
||||||
none,
|
none,
|
||||||
@@ -24,6 +25,7 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
|
|||||||
GestureDragStartCallback? onOneFingerPanStart;
|
GestureDragStartCallback? onOneFingerPanStart;
|
||||||
GestureDragUpdateCallback? onOneFingerPanUpdate;
|
GestureDragUpdateCallback? onOneFingerPanUpdate;
|
||||||
GestureDragEndCallback? onOneFingerPanEnd;
|
GestureDragEndCallback? onOneFingerPanEnd;
|
||||||
|
GestureDragCancelCallback? onOneFingerPanCancel;
|
||||||
|
|
||||||
// twoFingerScale : scale + pan event
|
// twoFingerScale : scale + pan event
|
||||||
GestureScaleStartCallback? onTwoFingerScaleStart;
|
GestureScaleStartCallback? onTwoFingerScaleStart;
|
||||||
@@ -96,6 +98,12 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
|
|||||||
if (onTwoFingerScaleEnd != null) {
|
if (onTwoFingerScaleEnd != null) {
|
||||||
onTwoFingerScaleEnd!(d);
|
onTwoFingerScaleEnd!(d);
|
||||||
}
|
}
|
||||||
|
if (isSpecialHoldDragActive) {
|
||||||
|
// If we are in special drag mode, we need to reset the state.
|
||||||
|
// Otherwise, the next `onTwoFingerScaleUpdate()` will handle a wrong `focalPoint`.
|
||||||
|
_currentState = GestureState.none;
|
||||||
|
return;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case GestureState.threeFingerVerticalDrag:
|
case GestureState.threeFingerVerticalDrag:
|
||||||
debugPrint("ThreeFingerState.vertical onEnd");
|
debugPrint("ThreeFingerState.vertical onEnd");
|
||||||
@@ -162,6 +170,27 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
|
|||||||
|
|
||||||
DragEndDetails _getDragEndDetails(ScaleEndDetails d) =>
|
DragEndDetails _getDragEndDetails(ScaleEndDetails d) =>
|
||||||
DragEndDetails(velocity: d.velocity);
|
DragEndDetails(velocity: d.velocity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void rejectGesture(int pointer) {
|
||||||
|
super.rejectGesture(pointer);
|
||||||
|
switch (_currentState) {
|
||||||
|
case GestureState.oneFingerPan:
|
||||||
|
if (onOneFingerPanCancel != null) {
|
||||||
|
onOneFingerPanCancel!();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case GestureState.twoFingerScale:
|
||||||
|
// Reset scale state if needed, currently self-contained
|
||||||
|
break;
|
||||||
|
case GestureState.threeFingerVerticalDrag:
|
||||||
|
// Reset drag state if needed, currently self-contained
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_currentState = GestureState.none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class HoldTapMoveGestureRecognizer extends GestureRecognizer {
|
class HoldTapMoveGestureRecognizer extends GestureRecognizer {
|
||||||
@@ -710,6 +739,7 @@ RawGestureDetector getMixinGestureDetector({
|
|||||||
GestureDragStartCallback? onOneFingerPanStart,
|
GestureDragStartCallback? onOneFingerPanStart,
|
||||||
GestureDragUpdateCallback? onOneFingerPanUpdate,
|
GestureDragUpdateCallback? onOneFingerPanUpdate,
|
||||||
GestureDragEndCallback? onOneFingerPanEnd,
|
GestureDragEndCallback? onOneFingerPanEnd,
|
||||||
|
GestureDragCancelCallback? onOneFingerPanCancel,
|
||||||
GestureScaleUpdateCallback? onTwoFingerScaleUpdate,
|
GestureScaleUpdateCallback? onTwoFingerScaleUpdate,
|
||||||
GestureScaleEndCallback? onTwoFingerScaleEnd,
|
GestureScaleEndCallback? onTwoFingerScaleEnd,
|
||||||
GestureDragUpdateCallback? onThreeFingerVerticalDragUpdate,
|
GestureDragUpdateCallback? onThreeFingerVerticalDragUpdate,
|
||||||
@@ -758,6 +788,7 @@ RawGestureDetector getMixinGestureDetector({
|
|||||||
..onOneFingerPanStart = onOneFingerPanStart
|
..onOneFingerPanStart = onOneFingerPanStart
|
||||||
..onOneFingerPanUpdate = onOneFingerPanUpdate
|
..onOneFingerPanUpdate = onOneFingerPanUpdate
|
||||||
..onOneFingerPanEnd = onOneFingerPanEnd
|
..onOneFingerPanEnd = onOneFingerPanEnd
|
||||||
|
..onOneFingerPanCancel = onOneFingerPanCancel
|
||||||
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
|
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
|
||||||
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
|
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
|
||||||
..onThreeFingerVerticalDragUpdate = onThreeFingerVerticalDragUpdate;
|
..onThreeFingerVerticalDragUpdate = onThreeFingerVerticalDragUpdate;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
|
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
|
||||||
import 'package:flutter_hbb/models/platform_model.dart';
|
import 'package:flutter_hbb/models/platform_model.dart';
|
||||||
import 'package:flutter_hbb/models/user_model.dart';
|
import 'package:flutter_hbb/models/user_model.dart';
|
||||||
@@ -11,6 +12,7 @@ import 'package:url_launcher/url_launcher.dart';
|
|||||||
|
|
||||||
import '../../common.dart';
|
import '../../common.dart';
|
||||||
import './dialog.dart';
|
import './dialog.dart';
|
||||||
|
import './oidc_auth_status.dart';
|
||||||
|
|
||||||
const kOpSvgList = [
|
const kOpSvgList = [
|
||||||
'github',
|
'github',
|
||||||
@@ -20,8 +22,40 @@ const kOpSvgList = [
|
|||||||
'okta',
|
'okta',
|
||||||
'facebook',
|
'facebook',
|
||||||
'azure',
|
'azure',
|
||||||
'auth0'
|
'auth0',
|
||||||
|
'microsoft'
|
||||||
];
|
];
|
||||||
|
const _requestingAccountAuth = 'Requesting account auth';
|
||||||
|
const _waitingAccountAuth = 'Waiting account auth';
|
||||||
|
|
||||||
|
class _OidcProviderBranding {
|
||||||
|
final String label;
|
||||||
|
final String iconKey;
|
||||||
|
|
||||||
|
const _OidcProviderBranding({
|
||||||
|
required this.label,
|
||||||
|
required this.iconKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_OidcProviderBranding _oidcProviderBranding(String op) {
|
||||||
|
switch (op.toLowerCase()) {
|
||||||
|
case 'azure':
|
||||||
|
return _OidcProviderBranding(
|
||||||
|
label: 'Microsoft',
|
||||||
|
iconKey: 'microsoft',
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return _OidcProviderBranding(
|
||||||
|
label: {
|
||||||
|
'github': 'GitHub',
|
||||||
|
'gitlab': 'GitLab',
|
||||||
|
}[op.toLowerCase()] ??
|
||||||
|
toCapitalized(op),
|
||||||
|
iconKey: op.toLowerCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _IconOP extends StatelessWidget {
|
class _IconOP extends StatelessWidget {
|
||||||
final String op;
|
final String op;
|
||||||
@@ -60,6 +94,7 @@ class ButtonOP extends StatelessWidget {
|
|||||||
final Color primaryColor;
|
final Color primaryColor;
|
||||||
final double height;
|
final double height;
|
||||||
final Function() onTap;
|
final Function() onTap;
|
||||||
|
final bool Function() canStartAuth;
|
||||||
|
|
||||||
const ButtonOP({
|
const ButtonOP({
|
||||||
Key? key,
|
Key? key,
|
||||||
@@ -69,32 +104,29 @@ class ButtonOP extends StatelessWidget {
|
|||||||
required this.primaryColor,
|
required this.primaryColor,
|
||||||
required this.height,
|
required this.height,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
required this.canStartAuth,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final opLabel = {
|
final branding = _oidcProviderBranding(op);
|
||||||
'github': 'GitHub',
|
final buttonLabel = translate("Continue with {${branding.label}}");
|
||||||
'gitlab': 'GitLab'
|
|
||||||
}[op.toLowerCase()] ??
|
|
||||||
toCapitalized(op);
|
|
||||||
return Row(children: [
|
return Row(children: [
|
||||||
Container(
|
Container(
|
||||||
height: height,
|
height: height,
|
||||||
width: 200,
|
width: 200,
|
||||||
child: Obx(() => ElevatedButton(
|
child: Obx(() => ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: curOP.value.isEmpty || curOP.value == op
|
backgroundColor: primaryColor,
|
||||||
? primaryColor
|
|
||||||
: Colors.grey,
|
|
||||||
).copyWith(elevation: ButtonStyleButton.allOrNull(0.0)),
|
).copyWith(elevation: ButtonStyleButton.allOrNull(0.0)),
|
||||||
onPressed: curOP.value.isEmpty || curOP.value == op ? onTap : null,
|
onPressed:
|
||||||
|
curOP.value == 'rustdesk' || !canStartAuth() ? null : onTap,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 30,
|
width: 30,
|
||||||
child: _IconOP(
|
child: _IconOP(
|
||||||
op: op,
|
op: branding.iconKey,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
margin: EdgeInsets.only(right: 5),
|
margin: EdgeInsets.only(right: 5),
|
||||||
),
|
),
|
||||||
@@ -102,8 +134,7 @@ class ButtonOP extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
fit: BoxFit.scaleDown,
|
fit: BoxFit.scaleDown,
|
||||||
child: Center(
|
child: Center(child: Text(buttonLabel)),
|
||||||
child: Text('${translate("Continue with")} $opLabel')),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -119,15 +150,120 @@ class ConfigOP {
|
|||||||
ConfigOP({required this.op, required this.icon});
|
ConfigOP({required this.op, required this.icon});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _OidcAuthController {
|
||||||
|
final RxString curOP = ''.obs;
|
||||||
|
Future<void> _pendingOperation = Future<void>.value();
|
||||||
|
int _authAttempt = 0;
|
||||||
|
bool _closed = false;
|
||||||
|
final _cancelInProgress = false.obs;
|
||||||
|
|
||||||
|
bool _isCurrent(int authAttempt, String op) {
|
||||||
|
return !_closed && authAttempt == _authAttempt && curOP.value == op;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> start(String op) {
|
||||||
|
if (!canStart()) {
|
||||||
|
return Future<bool>.value(false);
|
||||||
|
}
|
||||||
|
final authAttempt = ++_authAttempt;
|
||||||
|
curOP.value = op;
|
||||||
|
// Web auth must start during the original user gesture so popups are allowed.
|
||||||
|
if (isWeb) {
|
||||||
|
return _startWeb(authAttempt, op);
|
||||||
|
}
|
||||||
|
final completer = Completer<bool>();
|
||||||
|
_pendingOperation = _pendingOperation.then((_) async {
|
||||||
|
if (!_isCurrent(authAttempt, op)) {
|
||||||
|
completer.complete(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await bind.mainAccountAuthCancel();
|
||||||
|
if (!_isCurrent(authAttempt, op)) {
|
||||||
|
completer.complete(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await bind.mainAccountAuth(op: op, rememberMe: true);
|
||||||
|
completer.complete(_isCurrent(authAttempt, op));
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
completer.completeError(error, stackTrace);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _startWeb(int authAttempt, String op) async {
|
||||||
|
await bind.mainAccountAuth(op: op, rememberMe: true);
|
||||||
|
return _isCurrent(authAttempt, op);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canStart() {
|
||||||
|
return !_closed && !_cancelInProgress.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> cancelCurrent(String op) {
|
||||||
|
if (!canStart() || curOP.value != op) {
|
||||||
|
return Future<bool>.value(false);
|
||||||
|
}
|
||||||
|
final authAttempt = ++_authAttempt;
|
||||||
|
final completer = Completer<bool>();
|
||||||
|
_cancelInProgress.value = true;
|
||||||
|
_pendingOperation = _pendingOperation.then((_) async {
|
||||||
|
try {
|
||||||
|
await bind.mainAccountAuthCancel();
|
||||||
|
completer.complete(_isCurrent(authAttempt, op));
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
completer.completeError(error, stackTrace);
|
||||||
|
} finally {
|
||||||
|
_cancelInProgress.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cancelBackend() async {
|
||||||
|
try {
|
||||||
|
await bind.mainAccountAuthCancel();
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint('Failed to cancel account authentication $error');
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> close() async {
|
||||||
|
if (_closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final hasActiveOidcAuth =
|
||||||
|
curOP.value.isNotEmpty && curOP.value != 'rustdesk';
|
||||||
|
_closed = true;
|
||||||
|
_authAttempt++;
|
||||||
|
curOP.value = '';
|
||||||
|
if (hasActiveOidcAuth) {
|
||||||
|
await _cancelBackend();
|
||||||
|
}
|
||||||
|
await _pendingOperation;
|
||||||
|
if (hasActiveOidcAuth) {
|
||||||
|
await _cancelBackend();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class WidgetOP extends StatefulWidget {
|
class WidgetOP extends StatefulWidget {
|
||||||
final ConfigOP config;
|
final ConfigOP config;
|
||||||
final RxString curOP;
|
final RxString curOP;
|
||||||
final Function(Map<String, dynamic>) cbLogin;
|
final Function(Map<String, dynamic>) cbLogin;
|
||||||
|
final Future<bool> Function(String) startAuth;
|
||||||
|
final Future<bool> Function(String) cancelAuth;
|
||||||
|
final bool Function() canStartAuth;
|
||||||
const WidgetOP({
|
const WidgetOP({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.config,
|
required this.config,
|
||||||
required this.curOP,
|
required this.curOP,
|
||||||
required this.cbLogin,
|
required this.cbLogin,
|
||||||
|
required this.startAuth,
|
||||||
|
required this.cancelAuth,
|
||||||
|
required this.canStartAuth,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -138,6 +274,8 @@ class WidgetOP extends StatefulWidget {
|
|||||||
|
|
||||||
class _WidgetOPState extends State<WidgetOP> {
|
class _WidgetOPState extends State<WidgetOP> {
|
||||||
Timer? _updateTimer;
|
Timer? _updateTimer;
|
||||||
|
bool _isAuthStatusQueryInFlight = false;
|
||||||
|
int _authAttempt = 0;
|
||||||
String _stateMsg = '';
|
String _stateMsg = '';
|
||||||
String _failedMsg = '';
|
String _failedMsg = '';
|
||||||
String _url = '';
|
String _url = '';
|
||||||
@@ -148,55 +286,180 @@ class _WidgetOPState extends State<WidgetOP> {
|
|||||||
_updateTimer?.cancel();
|
_updateTimer?.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
_beginQueryState() {
|
_beginQueryState(int authAttempt) {
|
||||||
|
_updateTimer?.cancel();
|
||||||
|
unawaited(_runAuthStatusQuery(() => _updateState(authAttempt)));
|
||||||
_updateTimer = Timer.periodic(Duration(seconds: 1), (timer) {
|
_updateTimer = Timer.periodic(Duration(seconds: 1), (timer) {
|
||||||
_updateState();
|
unawaited(_runAuthStatusQuery(() => _updateState(authAttempt)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateState() {
|
Future<void> _runAuthStatusQuery(Future<void> Function() query) async {
|
||||||
bind.mainAccountAuthResult().then((result) {
|
if (_isAuthStatusQueryInFlight) {
|
||||||
if (result.isEmpty) {
|
return;
|
||||||
|
}
|
||||||
|
_isAuthStatusQueryInFlight = true;
|
||||||
|
try {
|
||||||
|
await query();
|
||||||
|
} finally {
|
||||||
|
_isAuthStatusQueryInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _launchAuthUrl(String url) async {
|
||||||
|
try {
|
||||||
|
final launched = await launchUrl(
|
||||||
|
Uri.parse(url),
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
|
if (!launched) {
|
||||||
|
debugPrint('Failed to open OIDC authentication URL');
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint(
|
||||||
|
'Failed to open OIDC authentication URL (${error.runtimeType})');
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _copyAuthUrl(String url) async {
|
||||||
|
try {
|
||||||
|
await Clipboard.setData(ClipboardData(text: url));
|
||||||
|
showToast(
|
||||||
|
translate('Copied'),
|
||||||
|
);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint(
|
||||||
|
'Failed to copy OIDC authentication URL (${error.runtimeType})');
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
showToast(translate('Failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _runCurrentAuthUrlAction(
|
||||||
|
int authAttempt,
|
||||||
|
String authUrl,
|
||||||
|
Future<void> Function(String) action,
|
||||||
|
) {
|
||||||
|
if (!mounted ||
|
||||||
|
authAttempt != _authAttempt ||
|
||||||
|
widget.curOP.value != widget.config.op ||
|
||||||
|
authUrl.isEmpty ||
|
||||||
|
_url != authUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(action(authUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _invalidateAuthAttempt() {
|
||||||
|
_authAttempt++;
|
||||||
|
_url = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isCurrentAuthAttempt(int authAttempt) {
|
||||||
|
return mounted &&
|
||||||
|
authAttempt == _authAttempt &&
|
||||||
|
widget.curOP.value == widget.config.op;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleAuthFailure(
|
||||||
|
int authAttempt,
|
||||||
|
Object error,
|
||||||
|
String operation,
|
||||||
|
) async {
|
||||||
|
debugPrint('Failed to $operation $error');
|
||||||
|
if (!_isCurrentAuthAttempt(authAttempt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_updateTimer?.cancel();
|
||||||
|
setState(() => _failedMsg = 'Failed');
|
||||||
|
try {
|
||||||
|
final canceled = await widget.cancelAuth(widget.config.op);
|
||||||
|
if (!canceled || !_isCurrentAuthAttempt(authAttempt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (cancelError, stackTrace) {
|
||||||
|
debugPrint('Failed to cancel account authentication $cancelError');
|
||||||
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_invalidateAuthAttempt();
|
||||||
|
widget.curOP.value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _updateState(int authAttempt) {
|
||||||
|
if (!mounted ||
|
||||||
|
authAttempt != _authAttempt ||
|
||||||
|
widget.curOP.value != widget.config.op) {
|
||||||
|
_updateTimer?.cancel();
|
||||||
|
return Future<void>.value();
|
||||||
|
}
|
||||||
|
return bind.mainAccountAuthResult().then<void>((result) {
|
||||||
|
if (!mounted ||
|
||||||
|
authAttempt != _authAttempt ||
|
||||||
|
widget.curOP.value != widget.config.op ||
|
||||||
|
result.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final resultMap = jsonDecode(result);
|
final resultMap = jsonDecode(result);
|
||||||
if (resultMap == null) {
|
if (resultMap == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final String stateMsg = resultMap['state_msg'];
|
final String backendStateMsg = resultMap['state_msg'];
|
||||||
String failedMsg = resultMap['failed_msg'];
|
String failedMsg = resultMap['failed_msg'];
|
||||||
final String? url = resultMap['url'];
|
final String? url = resultMap['url'];
|
||||||
|
final stateMsg = backendStateMsg == _requestingAccountAuth &&
|
||||||
|
(url == null || url.isEmpty)
|
||||||
|
? _waitingAccountAuth
|
||||||
|
: backendStateMsg;
|
||||||
final bool urlLaunched = (resultMap['url_launched'] as bool?) ?? false;
|
final bool urlLaunched = (resultMap['url_launched'] as bool?) ?? false;
|
||||||
final authBody = resultMap['auth_body'];
|
final authBody = resultMap['auth_body'];
|
||||||
if (_stateMsg != stateMsg || _failedMsg != failedMsg) {
|
|
||||||
if (_url.isEmpty && url != null && url.isNotEmpty) {
|
|
||||||
if (!urlLaunched) {
|
|
||||||
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
|
||||||
}
|
|
||||||
_url = url;
|
|
||||||
}
|
|
||||||
if (authBody != null) {
|
if (authBody != null) {
|
||||||
_updateTimer?.cancel();
|
_updateTimer?.cancel();
|
||||||
|
_invalidateAuthAttempt();
|
||||||
widget.curOP.value = '';
|
widget.curOP.value = '';
|
||||||
widget.cbLogin(authBody as Map<String, dynamic>);
|
widget.cbLogin(authBody as Map<String, dynamic>);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final stateChanged = _stateMsg != stateMsg || _failedMsg != failedMsg;
|
||||||
|
final newUrl = _url.isEmpty && url != null && url.isNotEmpty ? url : null;
|
||||||
|
if (!stateChanged && newUrl == null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_stateMsg = stateMsg;
|
_stateMsg = stateMsg;
|
||||||
_failedMsg = failedMsg;
|
_failedMsg = failedMsg;
|
||||||
|
if (newUrl != null) {
|
||||||
|
_url = newUrl;
|
||||||
|
}
|
||||||
if (failedMsg.isNotEmpty) {
|
if (failedMsg.isNotEmpty) {
|
||||||
|
_invalidateAuthAttempt();
|
||||||
widget.curOP.value = '';
|
widget.curOP.value = '';
|
||||||
_updateTimer?.cancel();
|
_updateTimer?.cancel();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (newUrl != null && failedMsg.isEmpty && !urlLaunched) {
|
||||||
|
unawaited(_launchAuthUrl(newUrl));
|
||||||
}
|
}
|
||||||
});
|
}).catchError(
|
||||||
|
(e) => _handleAuthFailure(
|
||||||
|
authAttempt,
|
||||||
|
e,
|
||||||
|
'query account authentication',
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_resetState() {
|
int _resetState() {
|
||||||
_stateMsg = '';
|
_updateTimer?.cancel();
|
||||||
|
setState(() {
|
||||||
|
_invalidateAuthAttempt();
|
||||||
|
_stateMsg = _waitingAccountAuth;
|
||||||
_failedMsg = '';
|
_failedMsg = '';
|
||||||
_url = '';
|
});
|
||||||
|
return _authAttempt;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -209,11 +472,31 @@ class _WidgetOPState extends State<WidgetOP> {
|
|||||||
icon: widget.config.icon,
|
icon: widget.config.icon,
|
||||||
primaryColor: str2color(widget.config.op, 0x7f),
|
primaryColor: str2color(widget.config.op, 0x7f),
|
||||||
height: 36,
|
height: 36,
|
||||||
|
canStartAuth: widget.canStartAuth,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
_resetState();
|
if (!widget.canStartAuth()) {
|
||||||
widget.curOP.value = widget.config.op;
|
return;
|
||||||
await bind.mainAccountAuth(op: widget.config.op, rememberMe: true);
|
}
|
||||||
_beginQueryState();
|
final authAttempt = _resetState();
|
||||||
|
try {
|
||||||
|
final started = await widget.startAuth(widget.config.op);
|
||||||
|
if (!started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
await _handleAuthFailure(
|
||||||
|
authAttempt,
|
||||||
|
e,
|
||||||
|
'start account authentication',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mounted ||
|
||||||
|
authAttempt != _authAttempt ||
|
||||||
|
widget.curOP.value != widget.config.op) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_beginQueryState(authAttempt);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Obx(() {
|
Obx(() {
|
||||||
@@ -221,55 +504,74 @@ class _WidgetOPState extends State<WidgetOP> {
|
|||||||
widget.curOP.value != widget.config.op) {
|
widget.curOP.value != widget.config.op) {
|
||||||
_failedMsg = '';
|
_failedMsg = '';
|
||||||
}
|
}
|
||||||
|
final authAttempt = _authAttempt;
|
||||||
|
final authUrl = _url;
|
||||||
return Offstage(
|
return Offstage(
|
||||||
offstage:
|
offstage:
|
||||||
_failedMsg.isEmpty && widget.curOP.value != widget.config.op,
|
_failedMsg.isEmpty && widget.curOP.value != widget.config.op,
|
||||||
child: RichText(
|
child: Column(
|
||||||
text: TextSpan(
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
text: '$_stateMsg ',
|
children: [
|
||||||
|
if (_stateMsg.isNotEmpty && _failedMsg.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: OidcAuthStatus(
|
||||||
|
message: translate(_stateMsg),
|
||||||
|
browserFallbackPrompt: translate(
|
||||||
|
"Browser didn't open? Use the url below to sign in.",
|
||||||
|
),
|
||||||
|
authUrl: authUrl,
|
||||||
|
copyLabel: translate('Copy to clipboard'),
|
||||||
|
onCopy: authUrl.isEmpty
|
||||||
|
? null
|
||||||
|
: () => _runCurrentAuthUrlAction(
|
||||||
|
authAttempt,
|
||||||
|
authUrl,
|
||||||
|
_copyAuthUrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_failedMsg.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Builder(builder: (context) {
|
||||||
|
final errorColor = Theme.of(context).colorScheme.error;
|
||||||
|
final bgColor = Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.errorContainer
|
||||||
|
.withOpacity(0.3);
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8.0, vertical: 6.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bgColor,
|
||||||
|
borderRadius: BorderRadius.circular(4.0),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline,
|
||||||
|
color: errorColor, size: 16),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Flexible(
|
||||||
|
child: SelectableText(
|
||||||
|
translate(_failedMsg),
|
||||||
style:
|
style:
|
||||||
DefaultTextStyle.of(context).style.copyWith(fontSize: 12),
|
DefaultTextStyle.of(context).style.copyWith(
|
||||||
children: <TextSpan>[
|
fontSize: 13,
|
||||||
TextSpan(
|
color: errorColor,
|
||||||
text: _failedMsg,
|
),
|
||||||
style: DefaultTextStyle.of(context).style.copyWith(
|
|
||||||
fontSize: 14,
|
|
||||||
color: Colors.red,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
Obx(
|
|
||||||
() => Offstage(
|
|
||||||
offstage: widget.curOP.value != widget.config.op,
|
|
||||||
child: const SizedBox(
|
|
||||||
height: 5.0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Obx(
|
|
||||||
() => Offstage(
|
|
||||||
offstage: widget.curOP.value != widget.config.op,
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(maxHeight: 20),
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
widget.curOP.value = '';
|
|
||||||
_updateTimer?.cancel();
|
|
||||||
_resetState();
|
|
||||||
bind.mainAccountAuthCancel();
|
|
||||||
},
|
|
||||||
child: Text(
|
|
||||||
translate('Cancel'),
|
|
||||||
style: TextStyle(fontSize: 15),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -279,12 +581,18 @@ class LoginWidgetOP extends StatelessWidget {
|
|||||||
final List<ConfigOP> ops;
|
final List<ConfigOP> ops;
|
||||||
final RxString curOP;
|
final RxString curOP;
|
||||||
final Function(Map<String, dynamic>) cbLogin;
|
final Function(Map<String, dynamic>) cbLogin;
|
||||||
|
final Future<bool> Function(String) startAuth;
|
||||||
|
final Future<bool> Function(String) cancelAuth;
|
||||||
|
final bool Function() canStartAuth;
|
||||||
|
|
||||||
LoginWidgetOP({
|
LoginWidgetOP({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.ops,
|
required this.ops,
|
||||||
required this.curOP,
|
required this.curOP,
|
||||||
required this.cbLogin,
|
required this.cbLogin,
|
||||||
|
required this.startAuth,
|
||||||
|
required this.cancelAuth,
|
||||||
|
required this.canStartAuth,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -295,6 +603,9 @@ class LoginWidgetOP extends StatelessWidget {
|
|||||||
config: op,
|
config: op,
|
||||||
curOP: curOP,
|
curOP: curOP,
|
||||||
cbLogin: cbLogin,
|
cbLogin: cbLogin,
|
||||||
|
startAuth: startAuth,
|
||||||
|
cancelAuth: cancelAuth,
|
||||||
|
canStartAuth: canStartAuth,
|
||||||
),
|
),
|
||||||
const Divider(
|
const Divider(
|
||||||
indent: 5,
|
indent: 5,
|
||||||
@@ -372,8 +683,7 @@ class LoginWidgetUserPass extends StatelessWidget {
|
|||||||
translate('Login'),
|
translate('Login'),
|
||||||
style: TextStyle(fontSize: 16),
|
style: TextStyle(fontSize: 16),
|
||||||
),
|
),
|
||||||
onPressed:
|
onPressed: curOP.value.isEmpty && !isInProgress
|
||||||
curOP.value.isEmpty || curOP.value == 'rustdesk'
|
|
||||||
? () {
|
? () {
|
||||||
onLogin();
|
onLogin();
|
||||||
}
|
}
|
||||||
@@ -388,8 +698,28 @@ class LoginWidgetUserPass extends StatelessWidget {
|
|||||||
|
|
||||||
const kAuthReqTypeOidc = 'oidc/';
|
const kAuthReqTypeOidc = 'oidc/';
|
||||||
|
|
||||||
|
Future<bool?>? _activeLoginDialog;
|
||||||
|
|
||||||
// call this directly
|
// call this directly
|
||||||
Future<bool?> loginDialog() async {
|
Future<bool?> loginDialog() {
|
||||||
|
final activeDialog = _activeLoginDialog;
|
||||||
|
if (activeDialog != null) {
|
||||||
|
return activeDialog;
|
||||||
|
}
|
||||||
|
final dialog = _openLoginDialogOnce();
|
||||||
|
_activeLoginDialog = dialog;
|
||||||
|
return dialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool?> _openLoginDialogOnce() async {
|
||||||
|
try {
|
||||||
|
return await _openLoginDialog();
|
||||||
|
} finally {
|
||||||
|
_activeLoginDialog = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool?> _openLoginDialog() async {
|
||||||
var username =
|
var username =
|
||||||
TextEditingController(text: UserModel.getLocalUserInfo()?['name'] ?? '');
|
TextEditingController(text: UserModel.getLocalUserInfo()?['name'] ?? '');
|
||||||
var password = TextEditingController();
|
var password = TextEditingController();
|
||||||
@@ -399,12 +729,28 @@ Future<bool?> loginDialog() async {
|
|||||||
String? usernameMsg;
|
String? usernameMsg;
|
||||||
String? passwordMsg;
|
String? passwordMsg;
|
||||||
var isInProgress = false;
|
var isInProgress = false;
|
||||||
final RxString curOP = ''.obs;
|
final oidcAuth = _OidcAuthController();
|
||||||
|
final curOP = oidcAuth.curOP;
|
||||||
|
// Track hover state for the close icon
|
||||||
|
bool isCloseHovered = false;
|
||||||
|
|
||||||
final loginOptions = [].obs;
|
final loginOptions = [].obs;
|
||||||
Future.delayed(Duration.zero, () async {
|
final loginOptionsError = Rxn<Object>();
|
||||||
|
final loginOptionsInProgress = false.obs;
|
||||||
|
fetchLoginOptions() async {
|
||||||
|
loginOptionsInProgress.value = true;
|
||||||
|
try {
|
||||||
loginOptions.value = await UserModel.queryOidcLoginOptions();
|
loginOptions.value = await UserModel.queryOidcLoginOptions();
|
||||||
});
|
loginOptionsError.value = null;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("queryOidcLoginOptions failed: $e");
|
||||||
|
loginOptionsError.value = e;
|
||||||
|
} finally {
|
||||||
|
loginOptionsInProgress.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future.delayed(Duration.zero, fetchLoginOptions);
|
||||||
|
|
||||||
final res = await gFFI.dialogManager.show<bool>((setState, close, context) {
|
final res = await gFFI.dialogManager.show<bool>((setState, close, context) {
|
||||||
username.addListener(() {
|
username.addListener(() {
|
||||||
@@ -478,6 +824,9 @@ Future<bool?> loginDialog() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onLogin() async {
|
onLogin() async {
|
||||||
|
if (curOP.value.isNotEmpty || isInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// validate
|
// validate
|
||||||
if (username.text.isEmpty) {
|
if (username.text.isEmpty) {
|
||||||
setState(() => usernameMsg = translate('Username missed'));
|
setState(() => usernameMsg = translate('Username missed'));
|
||||||
@@ -508,6 +857,36 @@ Future<bool?> loginDialog() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
thirdAuthWidget() => Obx(() {
|
thirdAuthWidget() => Obx(() {
|
||||||
|
final error = loginOptionsError.value;
|
||||||
|
final inProgress = loginOptionsInProgress.value;
|
||||||
|
if (error != null) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8.0),
|
||||||
|
// NOT use Offstage to wrap LinearProgressIndicator
|
||||||
|
if (inProgress) const LinearProgressIndicator(),
|
||||||
|
if (!inProgress && error is! RequestException)
|
||||||
|
Text(
|
||||||
|
translate('network_error_tip'),
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
onPressed: inProgress ? null : fetchLoginOptions,
|
||||||
|
child: Text(translate('Retry')),
|
||||||
|
),
|
||||||
|
if (!inProgress)
|
||||||
|
SelectableText(
|
||||||
|
error.toString(),
|
||||||
|
style: const TextStyle(fontSize: 11, color: Colors.red),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
return Offstage(
|
return Offstage(
|
||||||
offstage: loginOptions.isEmpty,
|
offstage: loginOptions.isEmpty,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -528,6 +907,9 @@ Future<bool?> loginDialog() async {
|
|||||||
.map((e) => ConfigOP(op: e['name'], icon: e['icon']))
|
.map((e) => ConfigOP(op: e['name'], icon: e['icon']))
|
||||||
.toList(),
|
.toList(),
|
||||||
curOP: curOP,
|
curOP: curOP,
|
||||||
|
startAuth: oidcAuth.start,
|
||||||
|
cancelAuth: oidcAuth.cancelCurrent,
|
||||||
|
canStartAuth: oidcAuth.canStart,
|
||||||
cbLogin: (Map<String, dynamic> authBody) async {
|
cbLogin: (Map<String, dynamic> authBody) async {
|
||||||
LoginResponse? resp;
|
LoginResponse? resp;
|
||||||
try {
|
try {
|
||||||
@@ -557,13 +939,18 @@ Future<bool?> loginDialog() async {
|
|||||||
Text(
|
Text(
|
||||||
translate('Login'),
|
translate('Login'),
|
||||||
).marginOnly(top: MyTheme.dialogPadding),
|
).marginOnly(top: MyTheme.dialogPadding),
|
||||||
InkWell(
|
MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => isCloseHovered = true),
|
||||||
|
onExit: (_) => setState(() => isCloseHovered = false),
|
||||||
|
child: InkWell(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.close,
|
Icons.close,
|
||||||
size: 25,
|
size: 25,
|
||||||
// No need to handle the branch of null.
|
// No need to handle the branch of null.
|
||||||
// Because we can ensure the color is not null when debug.
|
// Because we can ensure the color is not null when debug.
|
||||||
color: Theme.of(context)
|
color: isCloseHovered
|
||||||
|
? Colors.white
|
||||||
|
: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.titleLarge
|
.titleLarge
|
||||||
?.color
|
?.color
|
||||||
@@ -572,6 +959,7 @@ Future<bool?> loginDialog() async {
|
|||||||
onTap: onDialogCancel,
|
onTap: onDialogCancel,
|
||||||
hoverColor: Colors.red,
|
hoverColor: Colors.red,
|
||||||
borderRadius: BorderRadius.circular(5),
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
).marginOnly(top: 10, right: 15),
|
).marginOnly(top: 10, right: 15),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -603,7 +991,7 @@ Future<bool?> loginDialog() async {
|
|||||||
onCancel: onDialogCancel,
|
onCancel: onDialogCancel,
|
||||||
onSubmit: onLogin,
|
onSubmit: onLogin,
|
||||||
);
|
);
|
||||||
});
|
}).whenComplete(oidcAuth.close);
|
||||||
|
|
||||||
if (res != null) {
|
if (res != null) {
|
||||||
await UserModel.updateOtherModels();
|
await UserModel.updateOtherModels();
|
||||||
|
|||||||
@@ -158,12 +158,18 @@ class _MyGroupState extends State<MyGroup> {
|
|||||||
return Obx(() {
|
return Obx(() {
|
||||||
final userItems = gFFI.groupModel.users.where((p0) {
|
final userItems = gFFI.groupModel.users.where((p0) {
|
||||||
if (searchAccessibleItemNameText.isNotEmpty) {
|
if (searchAccessibleItemNameText.isNotEmpty) {
|
||||||
return p0.name
|
final search = searchAccessibleItemNameText.value.toLowerCase();
|
||||||
.toLowerCase()
|
return p0.name.toLowerCase().contains(search) ||
|
||||||
.contains(searchAccessibleItemNameText.value.toLowerCase());
|
p0.displayNameOrName.toLowerCase().contains(search);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}).toList();
|
}).toList();
|
||||||
|
// Count occurrences of each displayNameOrName to detect duplicates
|
||||||
|
final displayNameCount = <String, int>{};
|
||||||
|
for (final u in userItems) {
|
||||||
|
final dn = u.displayNameOrName;
|
||||||
|
displayNameCount[dn] = (displayNameCount[dn] ?? 0) + 1;
|
||||||
|
}
|
||||||
final deviceGroupItems = gFFI.groupModel.deviceGroups.where((p0) {
|
final deviceGroupItems = gFFI.groupModel.deviceGroups.where((p0) {
|
||||||
if (searchAccessibleItemNameText.isNotEmpty) {
|
if (searchAccessibleItemNameText.isNotEmpty) {
|
||||||
return p0.name
|
return p0.name
|
||||||
@@ -177,7 +183,8 @@ class _MyGroupState extends State<MyGroup> {
|
|||||||
itemCount: deviceGroupItems.length + userItems.length,
|
itemCount: deviceGroupItems.length + userItems.length,
|
||||||
itemBuilder: (context, index) => index < deviceGroupItems.length
|
itemBuilder: (context, index) => index < deviceGroupItems.length
|
||||||
? _buildDeviceGroupItem(deviceGroupItems[index])
|
? _buildDeviceGroupItem(deviceGroupItems[index])
|
||||||
: _buildUserItem(userItems[index - deviceGroupItems.length]));
|
: _buildUserItem(userItems[index - deviceGroupItems.length],
|
||||||
|
displayNameCount));
|
||||||
var maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0);
|
var maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0);
|
||||||
return Obx(() => stateGlobal.isPortrait.isFalse
|
return Obx(() => stateGlobal.isPortrait.isFalse
|
||||||
? listView(false)
|
? listView(false)
|
||||||
@@ -185,8 +192,14 @@ class _MyGroupState extends State<MyGroup> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildUserItem(UserPayload user) {
|
Widget _buildUserItem(UserPayload user, Map<String, int> displayNameCount) {
|
||||||
final username = user.name;
|
final username = user.name;
|
||||||
|
final dn = user.displayNameOrName;
|
||||||
|
final isDuplicate = (displayNameCount[dn] ?? 0) > 1;
|
||||||
|
final displayName =
|
||||||
|
isDuplicate && user.displayName.trim().isNotEmpty
|
||||||
|
? '${user.displayName} (@$username)'
|
||||||
|
: dn;
|
||||||
return InkWell(onTap: () {
|
return InkWell(onTap: () {
|
||||||
isSelectedDeviceGroup.value = false;
|
isSelectedDeviceGroup.value = false;
|
||||||
if (selectedAccessibleItemName.value != username) {
|
if (selectedAccessibleItemName.value != username) {
|
||||||
@@ -222,14 +235,14 @@ class _MyGroupState extends State<MyGroup> {
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
username.characters.first.toUpperCase(),
|
displayName.characters.first.toUpperCase(),
|
||||||
style: TextStyle(color: Colors.white),
|
style: TextStyle(color: Colors.white),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
).marginOnly(right: 4),
|
).marginOnly(right: 4),
|
||||||
if (isMe) Flexible(child: Text(username)),
|
if (isMe) Flexible(child: Text(displayName)),
|
||||||
if (isMe)
|
if (isMe)
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -246,7 +259,7 @@ class _MyGroupState extends State<MyGroup> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!isMe) Expanded(child: Text(username)),
|
if (!isMe) Expanded(child: Text(displayName)),
|
||||||
],
|
],
|
||||||
).paddingSymmetric(vertical: 4),
|
).paddingSymmetric(vertical: 4),
|
||||||
),
|
),
|
||||||
|
|||||||
157
flutter/lib/common/widgets/oidc_auth_status.dart
Normal file
157
flutter/lib/common/widgets/oidc_auth_status.dart
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
const _statusFontSize = 12.0;
|
||||||
|
const _statusSpacing = 4.0;
|
||||||
|
const _messageActionSpacing = 8.0;
|
||||||
|
const _desktopActionSize = 28.0;
|
||||||
|
const _touchPlatforms = <TargetPlatform>{
|
||||||
|
TargetPlatform.android,
|
||||||
|
TargetPlatform.iOS,
|
||||||
|
TargetPlatform.fuchsia,
|
||||||
|
};
|
||||||
|
|
||||||
|
class OidcAuthStatus extends StatelessWidget {
|
||||||
|
final String message;
|
||||||
|
final String browserFallbackPrompt;
|
||||||
|
final String authUrl;
|
||||||
|
final String copyLabel;
|
||||||
|
final VoidCallback? onCopy;
|
||||||
|
|
||||||
|
const OidcAuthStatus({
|
||||||
|
super.key,
|
||||||
|
required this.message,
|
||||||
|
required this.browserFallbackPrompt,
|
||||||
|
required this.authUrl,
|
||||||
|
required this.copyLabel,
|
||||||
|
this.onCopy,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final messageStyle =
|
||||||
|
DefaultTextStyle.of(context).style.copyWith(fontSize: _statusFontSize);
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SelectableText(message, style: messageStyle),
|
||||||
|
if (authUrl.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: _messageActionSpacing),
|
||||||
|
child: _OidcAuthFallback(
|
||||||
|
browserFallbackPrompt: browserFallbackPrompt,
|
||||||
|
authUrl: authUrl,
|
||||||
|
copyLabel: copyLabel,
|
||||||
|
onCopy: onCopy,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OidcAuthFallback extends StatefulWidget {
|
||||||
|
final String browserFallbackPrompt;
|
||||||
|
final String authUrl;
|
||||||
|
final String copyLabel;
|
||||||
|
final VoidCallback? onCopy;
|
||||||
|
|
||||||
|
const _OidcAuthFallback({
|
||||||
|
required this.browserFallbackPrompt,
|
||||||
|
required this.authUrl,
|
||||||
|
required this.copyLabel,
|
||||||
|
required this.onCopy,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_OidcAuthFallback> createState() => _OidcAuthFallbackState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OidcAuthFallbackState extends State<_OidcAuthFallback> {
|
||||||
|
bool _expanded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant _OidcAuthFallback oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.authUrl != widget.authUrl) {
|
||||||
|
_expanded = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final helperStyle = DefaultTextStyle.of(context).style.copyWith(
|
||||||
|
fontSize: _statusFontSize,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
final linkColor = theme.brightness == Brightness.dark
|
||||||
|
? Colors.blue.shade300
|
||||||
|
: Colors.blue.shade800;
|
||||||
|
final isTouchPlatform = _touchPlatforms.contains(theme.platform);
|
||||||
|
final actionSize =
|
||||||
|
isTouchPlatform ? kMinInteractiveDimension : _desktopActionSize;
|
||||||
|
final urlStyle =
|
||||||
|
DefaultTextStyle.of(context).style.copyWith(fontSize: _statusFontSize);
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.browserFallbackPrompt,
|
||||||
|
style: helperStyle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: _statusSpacing),
|
||||||
|
child: _buildUrl(urlStyle, linkColor, actionSize),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _copyAndExpand() {
|
||||||
|
setState(() => _expanded = true);
|
||||||
|
widget.onCopy?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUrl(TextStyle urlStyle, Color linkColor, double actionSize) {
|
||||||
|
final collapsedUrl = SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: TextButton(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: linkColor,
|
||||||
|
minimumSize: Size(0, actionSize),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
visualDensity: VisualDensity.standard,
|
||||||
|
),
|
||||||
|
onPressed: _copyAndExpand,
|
||||||
|
child: Text(
|
||||||
|
widget.authUrl,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
softWrap: false,
|
||||||
|
style: urlStyle.copyWith(
|
||||||
|
color: linkColor,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final collapsedChild = widget.onCopy == null
|
||||||
|
? collapsedUrl
|
||||||
|
: Tooltip(message: widget.copyLabel, child: collapsedUrl);
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
constraints: BoxConstraints(minHeight: actionSize),
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: _messageActionSpacing),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Theme.of(context).dividerColor),
|
||||||
|
borderRadius: BorderRadius.circular(_statusSpacing),
|
||||||
|
),
|
||||||
|
child: _expanded
|
||||||
|
? SelectableText(widget.authUrl, style: urlStyle)
|
||||||
|
: collapsedChild,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ class DraggableChatWindow extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: Draggable(
|
: Draggable(
|
||||||
checkKeyboard: true,
|
checkKeyboard: true,
|
||||||
|
checkScreenSize: true,
|
||||||
position: draggablePositions.chatWindow,
|
position: draggablePositions.chatWindow,
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
@@ -395,7 +396,10 @@ class _DraggableState extends State<Draggable> {
|
|||||||
_chatModel?.setChatWindowPosition(position);
|
_chatModel?.setChatWindowPosition(position);
|
||||||
}
|
}
|
||||||
|
|
||||||
checkScreenSize() {}
|
checkScreenSize() {
|
||||||
|
// Ensure the draggable always stays within current screen bounds
|
||||||
|
widget.position.tryAdjust(widget.width, widget.height, 1);
|
||||||
|
}
|
||||||
|
|
||||||
checkKeyboard() {
|
checkKeyboard() {
|
||||||
final bottomHeight = MediaQuery.of(context).viewInsets.bottom;
|
final bottomHeight = MediaQuery.of(context).viewInsets.bottom;
|
||||||
@@ -517,6 +521,12 @@ class IOSDraggableState extends State<IOSDraggable> {
|
|||||||
_lastBottomHeight = bottomHeight;
|
_lastBottomHeight = bottomHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
position.tryAdjust(_width, _height, 1);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
checkKeyboard();
|
checkKeyboard();
|
||||||
|
|||||||
@@ -127,6 +127,10 @@ class _PeerCardState extends State<_PeerCard>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _showNote(Peer peer) {
|
||||||
|
return peerTabShowNote(widget.tab) && peer.note.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
makeChild(bool isPortrait, Peer peer) {
|
makeChild(bool isPortrait, Peer peer) {
|
||||||
final name = hideUsernameOnCard == true
|
final name = hideUsernameOnCard == true
|
||||||
? peer.hostname
|
? peer.hostname
|
||||||
@@ -134,6 +138,8 @@ class _PeerCardState extends State<_PeerCard>
|
|||||||
final greyStyle = TextStyle(
|
final greyStyle = TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: Theme.of(context).textTheme.titleLarge?.color?.withOpacity(0.6));
|
color: Theme.of(context).textTheme.titleLarge?.color?.withOpacity(0.6));
|
||||||
|
final showNote = _showNote(peer);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.max,
|
mainAxisSize: MainAxisSize.max,
|
||||||
children: [
|
children: [
|
||||||
@@ -185,7 +191,13 @@ class _PeerCardState extends State<_PeerCard>
|
|||||||
style: Theme.of(context).textTheme.titleSmall,
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
)),
|
)),
|
||||||
]).marginOnly(top: isPortrait ? 0 : 2),
|
]).marginOnly(top: isPortrait ? 0 : 2),
|
||||||
Align(
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Tooltip(
|
||||||
|
message: name,
|
||||||
|
waitDuration: const Duration(seconds: 1),
|
||||||
|
child: Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Text(
|
child: Text(
|
||||||
name,
|
name,
|
||||||
@@ -194,6 +206,30 @@ class _PeerCardState extends State<_PeerCard>
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showNote)
|
||||||
|
Expanded(
|
||||||
|
child: Tooltip(
|
||||||
|
message: peer.note,
|
||||||
|
waitDuration: const Duration(seconds: 1),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
peer.note,
|
||||||
|
style: isPortrait ? null : greyStyle,
|
||||||
|
textAlign: TextAlign.start,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
).marginOnly(
|
||||||
|
left: peerCardUiType.value ==
|
||||||
|
PeerUiType.list
|
||||||
|
? 32
|
||||||
|
: 4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
).marginOnly(top: 2),
|
).marginOnly(top: 2),
|
||||||
),
|
),
|
||||||
@@ -278,7 +314,7 @@ class _PeerCardState extends State<_PeerCard>
|
|||||||
padding: const EdgeInsets.all(6),
|
padding: const EdgeInsets.all(6),
|
||||||
child:
|
child:
|
||||||
getPlatformImage(peer.platform, size: 60),
|
getPlatformImage(peer.platform, size: 60),
|
||||||
).marginOnly(top: 4),
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -297,8 +333,26 @@ class _PeerCardState extends State<_PeerCard>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (_showNote(peer))
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Tooltip(
|
||||||
|
message: peer.note,
|
||||||
|
waitDuration: const Duration(seconds: 1),
|
||||||
|
child: Text(
|
||||||
|
peer.note,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white38,
|
||||||
|
fontSize: 10),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
))
|
||||||
],
|
],
|
||||||
).paddingAll(4.0),
|
),
|
||||||
|
],
|
||||||
|
).paddingOnly(top: 4.0, left: 4.0, right: 4.0),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1134,6 +1188,7 @@ class AddressBookPeerCard extends BasePeerCard {
|
|||||||
if (gFFI.abModel.currentAbTags.isNotEmpty) {
|
if (gFFI.abModel.currentAbTags.isNotEmpty) {
|
||||||
menuItems.add(_editTagAction(peer.id));
|
menuItems.add(_editTagAction(peer.id));
|
||||||
}
|
}
|
||||||
|
menuItems.add(_editNoteAction(peer.id));
|
||||||
}
|
}
|
||||||
final addressbooks = gFFI.abModel.addressBooksCanWrite();
|
final addressbooks = gFFI.abModel.addressBooksCanWrite();
|
||||||
if (gFFI.peerTabModel.currentTab == PeerTabIndex.ab.index) {
|
if (gFFI.peerTabModel.currentTab == PeerTabIndex.ab.index) {
|
||||||
@@ -1173,6 +1228,21 @@ class AddressBookPeerCard extends BasePeerCard {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
MenuEntryBase<String> _editNoteAction(String id) {
|
||||||
|
return MenuEntryButton<String>(
|
||||||
|
childBuilder: (TextStyle? style) => Text(
|
||||||
|
translate('Edit note'),
|
||||||
|
style: style,
|
||||||
|
),
|
||||||
|
proc: () {
|
||||||
|
editAbPeerNoteDialog(id);
|
||||||
|
},
|
||||||
|
padding: super.menuPadding,
|
||||||
|
dismissOnClicked: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
@override
|
@override
|
||||||
Future<String> _getAlias(String id) async =>
|
Future<String> _getAlias(String id) async =>
|
||||||
|
|||||||
@@ -71,10 +71,12 @@ class _PeersView extends StatefulWidget {
|
|||||||
final Peers peers;
|
final Peers peers;
|
||||||
final PeerFilter? peerFilter;
|
final PeerFilter? peerFilter;
|
||||||
final PeerCardBuilder peerCardBuilder;
|
final PeerCardBuilder peerCardBuilder;
|
||||||
|
final PeerTabIndex peerTabIndex;
|
||||||
|
|
||||||
const _PeersView(
|
const _PeersView(
|
||||||
{required this.peers,
|
{required this.peers,
|
||||||
required this.peerCardBuilder,
|
required this.peerCardBuilder,
|
||||||
|
required this.peerTabIndex,
|
||||||
this.peerFilter,
|
this.peerFilter,
|
||||||
Key? key})
|
Key? key})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
@@ -395,8 +397,8 @@ class _PeersViewState extends State<_PeersView>
|
|||||||
return peers;
|
return peers;
|
||||||
}
|
}
|
||||||
searchText = searchText.toLowerCase();
|
searchText = searchText.toLowerCase();
|
||||||
final matches =
|
final matches = await Future.wait(
|
||||||
await Future.wait(peers.map((peer) => matchPeer(searchText, peer)));
|
peers.map((peer) => matchPeer(searchText, peer, widget.peerTabIndex)));
|
||||||
final filteredList = List<Peer>.empty(growable: true);
|
final filteredList = List<Peer>.empty(growable: true);
|
||||||
for (var i = 0; i < peers.length; i++) {
|
for (var i = 0; i < peers.length; i++) {
|
||||||
if (matches[i]) {
|
if (matches[i]) {
|
||||||
@@ -441,7 +443,10 @@ abstract class BasePeersView extends StatelessWidget {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return _PeersView(
|
return _PeersView(
|
||||||
peers: peers, peerFilter: peerFilter, peerCardBuilder: peerCardBuilder);
|
peers: peers,
|
||||||
|
peerFilter: peerFilter,
|
||||||
|
peerCardBuilder: peerCardBuilder,
|
||||||
|
peerTabIndex: peerTabIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,10 +570,13 @@ class MyGroupPeerView extends BasePeersView {
|
|||||||
static bool filter(Peer peer) {
|
static bool filter(Peer peer) {
|
||||||
final model = gFFI.groupModel;
|
final model = gFFI.groupModel;
|
||||||
if (model.searchAccessibleItemNameText.isNotEmpty) {
|
if (model.searchAccessibleItemNameText.isNotEmpty) {
|
||||||
final text = model.searchAccessibleItemNameText.value;
|
final text = model.searchAccessibleItemNameText.value.toLowerCase();
|
||||||
final searchPeersOfUser = peer.loginName.contains(text) &&
|
final searchPeersOfUser = model.users.any((user) =>
|
||||||
model.users.any((user) => user.name == peer.loginName);
|
user.name == peer.loginName &&
|
||||||
final searchPeersOfDeviceGroup = peer.device_group_name.contains(text) &&
|
(user.name.toLowerCase().contains(text) ||
|
||||||
|
user.displayNameOrName.toLowerCase().contains(text)));
|
||||||
|
final searchPeersOfDeviceGroup =
|
||||||
|
peer.device_group_name.toLowerCase().contains(text) &&
|
||||||
model.deviceGroups.any((g) => g.name == peer.device_group_name);
|
model.deviceGroups.any((g) => g.name == peer.device_group_name);
|
||||||
if (!searchPeersOfUser && !searchPeersOfDeviceGroup) {
|
if (!searchPeersOfUser && !searchPeersOfDeviceGroup) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class RawKeyFocusScope extends StatelessWidget {
|
|||||||
// https://github.com/flutter/flutter/issues/154053
|
// https://github.com/flutter/flutter/issues/154053
|
||||||
final useRawKeyEvents = isLinux && !isWeb;
|
final useRawKeyEvents = isLinux && !isWeb;
|
||||||
// FIXME: On Windows, `AltGr` will generate `Alt` and `Control` key events,
|
// FIXME: On Windows, `AltGr` will generate `Alt` and `Control` key events,
|
||||||
// while `Alt` and `Control` are seperated key events for en-US input method.
|
// while `Alt` and `Control` are separated key events for en-US input method.
|
||||||
return FocusScope(
|
return FocusScope(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
child: Focus(
|
child: Focus(
|
||||||
@@ -51,6 +51,13 @@ class RawKeyFocusScope extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For virtual mouse when using the mouse mode on mobile.
|
||||||
|
// Special hold-drag mode: one finger holds a button (left/right button), another finger pans.
|
||||||
|
// This flag is to override the scale gesture to a pan gesture.
|
||||||
|
bool isSpecialHoldDragActive = false;
|
||||||
|
// Cache the last focal point to calculate deltas in special hold-drag mode.
|
||||||
|
Offset _lastSpecialHoldDragFocalPoint = Offset.zero;
|
||||||
|
|
||||||
class RawTouchGestureDetectorRegion extends StatefulWidget {
|
class RawTouchGestureDetectorRegion extends StatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final FFI ffi;
|
final FFI ffi;
|
||||||
@@ -97,6 +104,12 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
bool _touchModePanStarted = false;
|
bool _touchModePanStarted = false;
|
||||||
Offset _doubleFinerTapPosition = Offset.zero;
|
Offset _doubleFinerTapPosition = Offset.zero;
|
||||||
|
|
||||||
|
// For mouse mode, we need to block the events when the cursor is in a blocked area.
|
||||||
|
// So we need to cache the last tap down position.
|
||||||
|
Offset? _lastTapDownPositionForMouseMode;
|
||||||
|
// Cache global position for onTap (which lacks position info).
|
||||||
|
Offset? _lastTapDownGlobalPosition;
|
||||||
|
|
||||||
FFI get ffi => widget.ffi;
|
FFI get ffi => widget.ffi;
|
||||||
FfiModel get ffiModel => widget.ffiModel;
|
FfiModel get ffiModel => widget.ffiModel;
|
||||||
InputModel get inputModel => widget.inputModel;
|
InputModel get inputModel => widget.inputModel;
|
||||||
@@ -115,8 +128,17 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
return !kTouchBasedDeviceKinds.contains(lastDeviceKind);
|
return !kTouchBasedDeviceKinds.contains(lastDeviceKind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile, mouse mode.
|
||||||
|
// Check if should block the mouse tap event (`_lastTapDownPositionForMouseMode`).
|
||||||
|
bool shouldBlockMouseModeEvent() {
|
||||||
|
return _lastTapDownPositionForMouseMode != null &&
|
||||||
|
ffi.cursorModel.shouldBlock(_lastTapDownPositionForMouseMode!.dx,
|
||||||
|
_lastTapDownPositionForMouseMode!.dy);
|
||||||
|
}
|
||||||
|
|
||||||
onTapDown(TapDownDetails d) async {
|
onTapDown(TapDownDetails d) async {
|
||||||
lastDeviceKind = d.kind;
|
lastDeviceKind = d.kind;
|
||||||
|
_lastTapDownGlobalPosition = d.globalPosition;
|
||||||
if (isNotTouchBasedDevice()) {
|
if (isNotTouchBasedDevice()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -124,6 +146,8 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
_lastPosOfDoubleTapDown = d.localPosition;
|
_lastPosOfDoubleTapDown = d.localPosition;
|
||||||
// Desktop or mobile "Touch mode"
|
// Desktop or mobile "Touch mode"
|
||||||
_lastTapDownDetails = d;
|
_lastTapDownDetails = d;
|
||||||
|
} else {
|
||||||
|
_lastTapDownPositionForMouseMode = d.localPosition;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,11 +157,16 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
if (isNotTouchBasedDevice()) {
|
if (isNotTouchBasedDevice()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Filter duplicate touch tap events on iOS (Magic Mouse issue).
|
||||||
|
if (inputModel.shouldIgnoreTouchTap(d.globalPosition)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (handleTouch) {
|
if (handleTouch) {
|
||||||
final isMoved =
|
final isMoved =
|
||||||
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
||||||
if (isMoved) {
|
if (isMoved) {
|
||||||
if (lastTapDownDetails != null) {
|
// If pan already handled 'down', don't send it again.
|
||||||
|
if (lastTapDownDetails != null && !_touchModePanStarted) {
|
||||||
await inputModel.tapDown(MouseButtons.left);
|
await inputModel.tapDown(MouseButtons.left);
|
||||||
}
|
}
|
||||||
await inputModel.tapUp(MouseButtons.left);
|
await inputModel.tapUp(MouseButtons.left);
|
||||||
@@ -149,7 +178,17 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
if (isNotTouchBasedDevice()) {
|
if (isNotTouchBasedDevice()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Filter duplicate touch tap events on iOS (Magic Mouse issue).
|
||||||
|
final lastPos = _lastTapDownGlobalPosition;
|
||||||
|
if (lastPos != null && inputModel.shouldIgnoreTouchTap(lastPos)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!handleTouch) {
|
if (!handleTouch) {
|
||||||
|
// Cannot use `_lastTapDownDetails` because Flutter calls `onTapUp` before `onTap`, clearing the cached details.
|
||||||
|
// Using `_lastTapDownPositionForMouseMode` instead.
|
||||||
|
if (shouldBlockMouseModeEvent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Mobile, "Mouse mode"
|
// Mobile, "Mouse mode"
|
||||||
await inputModel.tap(MouseButtons.left);
|
await inputModel.tap(MouseButtons.left);
|
||||||
}
|
}
|
||||||
@@ -163,6 +202,8 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
if (handleTouch) {
|
if (handleTouch) {
|
||||||
_lastPosOfDoubleTapDown = d.localPosition;
|
_lastPosOfDoubleTapDown = d.localPosition;
|
||||||
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
||||||
|
} else {
|
||||||
|
_lastTapDownPositionForMouseMode = d.localPosition;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +218,12 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
!ffi.cursorModel.isInRemoteRect(_lastPosOfDoubleTapDown)) {
|
!ffi.cursorModel.isInRemoteRect(_lastPosOfDoubleTapDown)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Check if the position is in a blocked area when using the mouse mode.
|
||||||
|
if (!handleTouch) {
|
||||||
|
if (shouldBlockMouseModeEvent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
await inputModel.tap(MouseButtons.left);
|
await inputModel.tap(MouseButtons.left);
|
||||||
await inputModel.tap(MouseButtons.left);
|
await inputModel.tap(MouseButtons.left);
|
||||||
}
|
}
|
||||||
@@ -198,6 +245,8 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
|
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
|
||||||
await inputModel.tapDown(MouseButtons.left);
|
await inputModel.tapDown(MouseButtons.left);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
_lastTapDownPositionForMouseMode = d.localPosition;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +271,10 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
if (!isMoved) {
|
if (!isMoved) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (shouldBlockMouseModeEvent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await inputModel.tap(MouseButtons.right);
|
await inputModel.tap(MouseButtons.right);
|
||||||
} else {
|
} else {
|
||||||
@@ -274,6 +327,7 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!handleTouch) {
|
if (!handleTouch) {
|
||||||
|
if (isSpecialHoldDragActive) return;
|
||||||
await inputModel.sendMouse('down', MouseButtons.left);
|
await inputModel.sendMouse('down', MouseButtons.left);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,6 +337,7 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!handleTouch) {
|
if (!handleTouch) {
|
||||||
|
if (isSpecialHoldDragActive) return;
|
||||||
await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
|
await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,7 +385,10 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
await ffi.cursorModel
|
await ffi.cursorModel
|
||||||
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
|
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
|
||||||
}
|
}
|
||||||
|
// In relative mouse mode, skip mouse down - only send movement via sendMobileRelativeMouseMove
|
||||||
|
if (!inputModel.relativeMouseMode.value) {
|
||||||
await inputModel.sendMouse('down', MouseButtons.left);
|
await inputModel.sendMouse('down', MouseButtons.left);
|
||||||
|
}
|
||||||
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
||||||
} else {
|
} else {
|
||||||
final offset = ffi.cursorModel.offset;
|
final offset = ffi.cursorModel.offset;
|
||||||
@@ -355,8 +413,13 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
if (handleTouch && !_touchModePanStarted) {
|
if (handleTouch && !_touchModePanStarted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// In relative mouse mode, send delta directly without position tracking.
|
||||||
|
if (inputModel.relativeMouseMode.value) {
|
||||||
|
await inputModel.sendMobileRelativeMouseMove(d.delta.dx, d.delta.dy);
|
||||||
|
} else {
|
||||||
await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
|
await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onOneFingerPanEnd(DragEndDetails d) async {
|
onOneFingerPanEnd(DragEndDetails d) async {
|
||||||
_touchModePanStarted = false;
|
_touchModePanStarted = false;
|
||||||
@@ -367,9 +430,20 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
ffi.cursorModel.clearRemoteWindowCoords();
|
ffi.cursorModel.clearRemoteWindowCoords();
|
||||||
}
|
}
|
||||||
if (handleTouch) {
|
if (handleTouch) {
|
||||||
|
// In relative mouse mode, skip mouse up - matches the skipped mouse down in onOneFingerPanStart
|
||||||
|
if (!inputModel.relativeMouseMode.value) {
|
||||||
await inputModel.sendMouse('up', MouseButtons.left);
|
await inputModel.sendMouse('up', MouseButtons.left);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset `_touchModePanStarted` if the one-finger pan gesture is cancelled
|
||||||
|
// or rejected by the gesture arena. Without this, the flag can remain
|
||||||
|
// stuck in the "started" state and cause issues such as the Magic Mouse
|
||||||
|
// double-click problem on iPad with magic mouse.
|
||||||
|
onOneFingerPanCancel() {
|
||||||
|
_touchModePanStarted = false;
|
||||||
|
}
|
||||||
|
|
||||||
// scale + pan event
|
// scale + pan event
|
||||||
onTwoFingerScaleStart(ScaleStartDetails d) {
|
onTwoFingerScaleStart(ScaleStartDetails d) {
|
||||||
@@ -377,12 +451,26 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
if (isNotTouchBasedDevice()) {
|
if (isNotTouchBasedDevice()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isSpecialHoldDragActive) {
|
||||||
|
// Initialize the last focal point to calculate deltas manually.
|
||||||
|
_lastSpecialHoldDragFocalPoint = d.focalPoint;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onTwoFingerScaleUpdate(ScaleUpdateDetails d) async {
|
onTwoFingerScaleUpdate(ScaleUpdateDetails d) async {
|
||||||
if (isNotTouchBasedDevice()) {
|
if (isNotTouchBasedDevice()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If in special drag mode, perform a pan instead of a scale.
|
||||||
|
if (isSpecialHoldDragActive) {
|
||||||
|
// Calculate delta manually to avoid the jumpy behavior.
|
||||||
|
final delta = d.focalPoint - _lastSpecialHoldDragFocalPoint;
|
||||||
|
_lastSpecialHoldDragFocalPoint = d.focalPoint;
|
||||||
|
await ffi.cursorModel.updatePan(delta * 2.0, d.focalPoint, handleTouch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ((isDesktop || isWebDesktop)) {
|
if ((isDesktop || isWebDesktop)) {
|
||||||
final scale = ((d.scale - _scale) * 1000).toInt();
|
final scale = ((d.scale - _scale) * 1000).toInt();
|
||||||
_scale = d.scale;
|
_scale = d.scale;
|
||||||
@@ -420,8 +508,10 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
// No idea why we need to set the view style to "" here.
|
// No idea why we need to set the view style to "" here.
|
||||||
// bind.sessionSetViewStyle(sessionId: sessionId, value: "");
|
// bind.sessionSetViewStyle(sessionId: sessionId, value: "");
|
||||||
}
|
}
|
||||||
|
if (!isSpecialHoldDragActive) {
|
||||||
await inputModel.sendMouse('up', MouseButtons.left);
|
await inputModel.sendMouse('up', MouseButtons.left);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get onHoldDragCancel => null;
|
get onHoldDragCancel => null;
|
||||||
get onThreeFingerVerticalDragUpdate => ffi.ffiModel.isPeerAndroid
|
get onThreeFingerVerticalDragUpdate => ffi.ffiModel.isPeerAndroid
|
||||||
@@ -488,6 +578,7 @@ class _RawTouchGestureDetectorRegionState
|
|||||||
instance
|
instance
|
||||||
..onOneFingerPanUpdate = onOneFingerPanUpdate
|
..onOneFingerPanUpdate = onOneFingerPanUpdate
|
||||||
..onOneFingerPanEnd = onOneFingerPanEnd
|
..onOneFingerPanEnd = onOneFingerPanEnd
|
||||||
|
..onOneFingerPanCancel = onOneFingerPanCancel
|
||||||
..onTwoFingerScaleStart = onTwoFingerScaleStart
|
..onTwoFingerScaleStart = onTwoFingerScaleStart
|
||||||
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
|
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
|
||||||
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
|
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
|
||||||
|
|||||||
@@ -230,7 +230,6 @@ List<(String, String)> otherDefaultSettings() {
|
|||||||
('Disable clipboard', kOptionDisableClipboard),
|
('Disable clipboard', kOptionDisableClipboard),
|
||||||
('Lock after session end', kOptionLockAfterSessionEnd),
|
('Lock after session end', kOptionLockAfterSessionEnd),
|
||||||
('Privacy mode', kOptionPrivacyMode),
|
('Privacy mode', kOptionPrivacyMode),
|
||||||
if (isMobile) ('Touch mode', kOptionTouchMode),
|
|
||||||
('True color (4:4:4)', kOptionI444),
|
('True color (4:4:4)', kOptionI444),
|
||||||
('Reverse mouse wheel', kKeyReverseMouseWheel),
|
('Reverse mouse wheel', kKeyReverseMouseWheel),
|
||||||
('swap-left-right-mouse', kOptionSwapLeftRightMouse),
|
('swap-left-right-mouse', kOptionSwapLeftRightMouse),
|
||||||
|
|||||||
@@ -6,13 +6,91 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_hbb/common.dart';
|
import 'package:flutter_hbb/common.dart';
|
||||||
import 'package:flutter_hbb/common/shared_state.dart';
|
import 'package:flutter_hbb/common/shared_state.dart';
|
||||||
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||||
|
import 'package:flutter_hbb/common/widgets/login.dart';
|
||||||
import 'package:flutter_hbb/consts.dart';
|
import 'package:flutter_hbb/consts.dart';
|
||||||
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
||||||
import 'package:flutter_hbb/models/model.dart';
|
import 'package:flutter_hbb/models/model.dart';
|
||||||
import 'package:flutter_hbb/models/platform_model.dart';
|
import 'package:flutter_hbb/models/platform_model.dart';
|
||||||
|
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
bool isEditOsPassword = false;
|
bool isEditOsPassword = false;
|
||||||
|
const String kPeerOptionAllowWaylandKeyboard = 'allow-wayland-keyboard';
|
||||||
|
const String kWaylandKeyboardIssueUrl =
|
||||||
|
'https://github.com/rustdesk/rustdesk/issues/14586';
|
||||||
|
final Set<String> _waylandKeyboardPromptSuppressedConnectionIds = <String>{};
|
||||||
|
|
||||||
|
Future<bool> openWaylandKeyboardIssueUrl() {
|
||||||
|
return launchUrl(
|
||||||
|
Uri.parse(kWaylandKeyboardIssueUrl),
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isWaylandKeyboardPromptSuppressedForConnection(String connectionId) {
|
||||||
|
return _waylandKeyboardPromptSuppressedConnectionIds.contains(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setWaylandKeyboardPromptSuppressedForConnection(
|
||||||
|
String connectionId, bool suppressed) {
|
||||||
|
if (suppressed) {
|
||||||
|
_waylandKeyboardPromptSuppressedConnectionIds.add(connectionId);
|
||||||
|
} else {
|
||||||
|
_waylandKeyboardPromptSuppressedConnectionIds.remove(connectionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearWaylandKeyboardPromptSuppressedForConnection(String connectionId) {
|
||||||
|
_waylandKeyboardPromptSuppressedConnectionIds.remove(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool shouldShowWaylandKeyboardPrompt({
|
||||||
|
required String connectionId,
|
||||||
|
required bool isWaylandPeer,
|
||||||
|
required bool allowWaylandKeyboardRemembered,
|
||||||
|
}) {
|
||||||
|
return isWaylandPeer &&
|
||||||
|
!allowWaylandKeyboardRemembered &&
|
||||||
|
!isWaylandKeyboardPromptSuppressedForConnection(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget waylandKeyboardScopeChip(BuildContext context, String text) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(color: colorScheme.primary.withOpacity(0.35)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isWindowsMode1PrivacyImpl(String privacyModeImpl) {
|
||||||
|
return privacyModeImpl == kPrivacyModeImplMag ||
|
||||||
|
privacyModeImpl == kPrivacyModeImplExcludeFromCapture;
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS privacy mode blacks out all online displays. Windows Mode 1 also
|
||||||
|
// covers every local monitor with privacy overlay windows, so remote display
|
||||||
|
// switching does not weaken local privacy protection.
|
||||||
|
//
|
||||||
|
// Keep this separate from the capture backend capability. The legacy Windows
|
||||||
|
// magnifier capturer is not reliable for multi-monitor capture; WebRTC's
|
||||||
|
// screen_capturer_win_magnifier also disables it when SM_CMONITORS != 1:
|
||||||
|
// https://webrtc.googlesource.com/src/+/1845922d5a1bf9c27deeffb4a8c8daea124434c1/modules/desktop_capture/win/screen_capturer_win_magnifier.cc
|
||||||
|
bool allowDisplaySwitchInPrivacyMode(PeerInfo pi, String privacyModeImpl) {
|
||||||
|
return pi.platform == kPeerPlatformMacOS ||
|
||||||
|
(pi.platform == kPeerPlatformWindows &&
|
||||||
|
_isWindowsMode1PrivacyImpl(privacyModeImpl) &&
|
||||||
|
versionCmp(pi.version, '1.4.8') >= 0);
|
||||||
|
}
|
||||||
|
|
||||||
class TTextMenu {
|
class TTextMenu {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
@@ -85,12 +163,179 @@ handleOsPasswordAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void showWaylandKeyboardInputWarningDialog(
|
||||||
|
{required String id,
|
||||||
|
required String connectionId,
|
||||||
|
required FFI ffi,
|
||||||
|
required Future<void> Function() onEnable}) {
|
||||||
|
bool remember = false;
|
||||||
|
bool consentInProgress = false;
|
||||||
|
bool dialogClosed = false;
|
||||||
|
|
||||||
|
final dialogFuture = ffi.dialogManager.show((setState, close, context) {
|
||||||
|
void safeSetState(VoidCallback fn) {
|
||||||
|
if (dialogClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setState(fn);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Ignore setState after dialog disposal: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void closeDialog() {
|
||||||
|
if (dialogClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dialogClosed = true;
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> enableAndContinue() async {
|
||||||
|
if (consentInProgress || dialogClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
consentInProgress = true;
|
||||||
|
safeSetState(() {});
|
||||||
|
try {
|
||||||
|
await onEnable();
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('Failed to enable Wayland keyboard input consent: $e');
|
||||||
|
debugPrintStack(stackTrace: st);
|
||||||
|
consentInProgress = false;
|
||||||
|
safeSetState(() {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ffi.inputModel.keyboardInputAllowed = true;
|
||||||
|
var rememberPersisted = true;
|
||||||
|
if (remember) {
|
||||||
|
try {
|
||||||
|
await bind.mainSetPeerOption(
|
||||||
|
id: id,
|
||||||
|
key: kPeerOptionAllowWaylandKeyboard,
|
||||||
|
value: bool2option(kPeerOptionAllowWaylandKeyboard, true));
|
||||||
|
} catch (e) {
|
||||||
|
rememberPersisted = false;
|
||||||
|
debugPrint('Failed to persist Wayland keyboard input consent: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Always suppress prompt for current connection after explicit consent.
|
||||||
|
setWaylandKeyboardPromptSuppressedForConnection(connectionId, true);
|
||||||
|
closeDialog();
|
||||||
|
if (remember && !rememberPersisted) {
|
||||||
|
// It's a rare edge case that persisting the user's choice fails.
|
||||||
|
// Failed to persist the user's choice, but still allow keyboard input for current session.
|
||||||
|
showToast(translate('Failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancel() {
|
||||||
|
if (consentInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
return CustomAlertDialog(
|
||||||
|
title: null,
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
msgboxContent(
|
||||||
|
'',
|
||||||
|
'wayland-keyboard-input-disabled-tip',
|
||||||
|
'wayland-keyboard-input-consent-tip',
|
||||||
|
),
|
||||||
|
SizedBox(height: isMobile ? 2 : 6),
|
||||||
|
if (isMobile) ...[
|
||||||
|
Text(
|
||||||
|
translate('wayland-keyboard-input-applies-to-tip'),
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
).marginOnly(bottom: 6),
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: [
|
||||||
|
waylandKeyboardScopeChip(
|
||||||
|
context, translate('Send clipboard keystrokes')),
|
||||||
|
waylandKeyboardScopeChip(
|
||||||
|
context, translate('wayland-soft-keyboard-input-label')),
|
||||||
|
],
|
||||||
|
).marginOnly(bottom: 10),
|
||||||
|
],
|
||||||
|
TextButton(
|
||||||
|
onPressed: consentInProgress
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
try {
|
||||||
|
final opened = await openWaylandKeyboardIssueUrl();
|
||||||
|
if (!opened) {
|
||||||
|
// Opening this optional help link almost never fails in
|
||||||
|
// normal desktop environments. Keep the result handled
|
||||||
|
// for review hygiene, but avoid a low-value user toast.
|
||||||
|
debugPrint('Failed to open Wayland keyboard issue URL');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'Failed to open Wayland keyboard issue URL: $e');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.blue,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
translate('Why this happens'),
|
||||||
|
style: const TextStyle(decoration: TextDecoration.underline),
|
||||||
|
),
|
||||||
|
).marginOnly(bottom: 6),
|
||||||
|
CheckboxListTile(
|
||||||
|
value: remember,
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
title: Text(translate('remember-wayland-keyboard-choice-tip')),
|
||||||
|
onChanged: consentInProgress
|
||||||
|
? null
|
||||||
|
: (v) {
|
||||||
|
safeSetState(() => remember = v == true);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
dialogButton(
|
||||||
|
'Cancel',
|
||||||
|
onPressed: consentInProgress ? null : cancel,
|
||||||
|
isOutline: true,
|
||||||
|
),
|
||||||
|
dialogButton(
|
||||||
|
'OK',
|
||||||
|
onPressed:
|
||||||
|
consentInProgress ? null : () => unawaited(enableAndContinue()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onCancel: consentInProgress ? null : cancel,
|
||||||
|
onSubmit: consentInProgress ? null : () => unawaited(enableAndContinue()),
|
||||||
|
);
|
||||||
|
}, clickMaskDismiss: false, backDismiss: false);
|
||||||
|
unawaited(dialogFuture.whenComplete(() => dialogClosed = true));
|
||||||
|
}
|
||||||
|
|
||||||
List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||||
final ffiModel = ffi.ffiModel;
|
final ffiModel = ffi.ffiModel;
|
||||||
final pi = ffiModel.pi;
|
final pi = ffiModel.pi;
|
||||||
final perms = ffiModel.permissions;
|
final perms = ffiModel.permissions;
|
||||||
final sessionId = ffi.sessionId;
|
final sessionId = ffi.sessionId;
|
||||||
final isDefaultConn = ffi.connType == ConnType.defaultConn;
|
final isDefaultConn = ffi.connType == ConnType.defaultConn;
|
||||||
|
final isWaylandPeer = pi.platform == kPeerPlatformLinux && pi.isWayland;
|
||||||
|
|
||||||
List<TTextMenu> v = [];
|
List<TTextMenu> v = [];
|
||||||
// elevation
|
// elevation
|
||||||
@@ -140,11 +385,60 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||||||
v.add(TTextMenu(
|
v.add(TTextMenu(
|
||||||
child: Text(translate('Send clipboard keystrokes')),
|
child: Text(translate('Send clipboard keystrokes')),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
|
Future<void> sendClipboardKeystrokes() async {
|
||||||
ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
|
ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||||
if (data != null && data.text != null) {
|
if (data != null && data.text != null) {
|
||||||
bind.sessionInputString(
|
bind.sessionInputString(
|
||||||
sessionId: sessionId, value: data.text ?? "");
|
sessionId: sessionId, value: data.text ?? "");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final allowWaylandKeyboard =
|
||||||
|
mainGetPeerBoolOptionSync(id, kPeerOptionAllowWaylandKeyboard);
|
||||||
|
if (shouldShowWaylandKeyboardPrompt(
|
||||||
|
connectionId: sessionId.toString(),
|
||||||
|
isWaylandPeer: isWaylandPeer,
|
||||||
|
allowWaylandKeyboardRemembered: allowWaylandKeyboard,
|
||||||
|
)) {
|
||||||
|
ffi.inputModel.keyboardInputAllowed = false;
|
||||||
|
showWaylandKeyboardInputWarningDialog(
|
||||||
|
id: id,
|
||||||
|
connectionId: sessionId.toString(),
|
||||||
|
ffi: ffi,
|
||||||
|
onEnable: sendClipboardKeystrokes,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sendClipboardKeystrokes();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (isDefaultConn &&
|
||||||
|
isWaylandPeer &&
|
||||||
|
(mainGetPeerBoolOptionSync(id, kPeerOptionAllowWaylandKeyboard) ||
|
||||||
|
isWaylandKeyboardPromptSuppressedForConnection(
|
||||||
|
sessionId.toString()))) {
|
||||||
|
v.add(TTextMenu(
|
||||||
|
child: Text(translate('wayland-keyboard-input-reset-choice-tip')),
|
||||||
|
onPressed: () async {
|
||||||
|
var persistedCleared = false;
|
||||||
|
try {
|
||||||
|
await bind.mainSetPeerOption(
|
||||||
|
id: id,
|
||||||
|
key: kPeerOptionAllowWaylandKeyboard,
|
||||||
|
value: bool2option(kPeerOptionAllowWaylandKeyboard, false));
|
||||||
|
persistedCleared = true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'Failed to clear persisted Wayland keyboard permission: $e');
|
||||||
|
} finally {
|
||||||
|
clearWaylandKeyboardPromptSuppressedForConnection(
|
||||||
|
sessionId.toString());
|
||||||
|
ffi.inputModel.keyboardInputAllowed = false;
|
||||||
|
if (isMobile) {
|
||||||
|
await ffi.invokeMethod("enable_soft_keyboard", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showToast(translate(persistedCleared ? 'Successful' : 'Failed'));
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
// reset canvas
|
// reset canvas
|
||||||
@@ -193,14 +487,26 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// note
|
// note
|
||||||
if (isDefaultConn &&
|
if (isDefaultConn && !bind.isDisableAccount()) {
|
||||||
bind
|
|
||||||
.sessionGetAuditServerSync(sessionId: sessionId, typ: "conn")
|
|
||||||
.isNotEmpty) {
|
|
||||||
v.add(
|
v.add(
|
||||||
TTextMenu(
|
TTextMenu(
|
||||||
child: Text(translate('Note')),
|
child: Text(translate('Note')),
|
||||||
onPressed: () => showAuditDialog(ffi)),
|
onPressed: () async {
|
||||||
|
bool isLogin =
|
||||||
|
bind.mainGetLocalOption(key: 'access_token').isNotEmpty;
|
||||||
|
if (!isLogin) {
|
||||||
|
final res = await loginDialog();
|
||||||
|
if (res != true) return;
|
||||||
|
// Desktop: send message to main window to refresh login status
|
||||||
|
// Web: login is required before connection, so no need to refresh
|
||||||
|
// Mobile: same isolate, no need to send message
|
||||||
|
if (isDesktop) {
|
||||||
|
rustDeskWinManager.call(
|
||||||
|
WindowType.Main, kWindowRefreshCurrentUser, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showAuditDialog(ffi);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// divider
|
// divider
|
||||||
@@ -261,7 +567,6 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||||||
isDesktop &&
|
isDesktop &&
|
||||||
ffiModel.keyboard &&
|
ffiModel.keyboard &&
|
||||||
pi.platform != kPeerPlatformAndroid &&
|
pi.platform != kPeerPlatformAndroid &&
|
||||||
pi.platform != kPeerPlatformMacOS &&
|
|
||||||
versionCmp(pi.version, '1.2.0') >= 0 &&
|
versionCmp(pi.version, '1.2.0') >= 0 &&
|
||||||
bind.peerGetSessionsCount(id: id, connType: ffi.connType.index) == 1) {
|
bind.peerGetSessionsCount(id: id, connType: ffi.connType.index) == 1) {
|
||||||
v.add(TTextMenu(
|
v.add(TTextMenu(
|
||||||
@@ -278,6 +583,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||||||
}
|
}
|
||||||
// record
|
// record
|
||||||
if (!(isDesktop || isWeb) &&
|
if (!(isDesktop || isWeb) &&
|
||||||
|
bind.mainGetLocalOption(key: kOptionHideRecordingButton) != 'Y' &&
|
||||||
(ffi.recordingModel.start || (perms["recording"] != false))) {
|
(ffi.recordingModel.start || (perms["recording"] != false))) {
|
||||||
v.add(TTextMenu(
|
v.add(TTextMenu(
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -301,7 +607,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||||||
// to-do:
|
// to-do:
|
||||||
// 1. Web desktop
|
// 1. Web desktop
|
||||||
// 2. Mobile, copy the image to the clipboard
|
// 2. Mobile, copy the image to the clipboard
|
||||||
if (isDesktop) {
|
if ((isDefaultConn || ffi.connType == ConnType.viewCamera) && isDesktop) {
|
||||||
final isScreenshotSupported = bind.sessionGetCommonSync(
|
final isScreenshotSupported = bind.sessionGetCommonSync(
|
||||||
sessionId: sessionId, key: 'is_screenshot_supported', param: '');
|
sessionId: sessionId, key: 'is_screenshot_supported', param: '');
|
||||||
if ('true' == isScreenshotSupported) {
|
if ('true' == isScreenshotSupported) {
|
||||||
@@ -363,6 +669,11 @@ Future<List<TRadioMenu<String>>> toolbarViewStyle(
|
|||||||
child: Text(translate('Scale adaptive')),
|
child: Text(translate('Scale adaptive')),
|
||||||
value: kRemoteViewStyleAdaptive,
|
value: kRemoteViewStyleAdaptive,
|
||||||
groupValue: groupValue,
|
groupValue: groupValue,
|
||||||
|
onChanged: onChanged),
|
||||||
|
TRadioMenu<String>(
|
||||||
|
child: Text(translate('Scale custom')),
|
||||||
|
value: kRemoteViewStyleCustom,
|
||||||
|
groupValue: groupValue,
|
||||||
onChanged: onChanged)
|
onChanged: onChanged)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -666,8 +977,10 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
|||||||
child: Text(translate('Lock after session end'))));
|
child: Text(translate('Lock after session end'))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final privacyModeState = PrivacyModeState.find(id);
|
||||||
if (pi.isSupportMultiDisplay &&
|
if (pi.isSupportMultiDisplay &&
|
||||||
PrivacyModeState.find(id).isEmpty &&
|
(privacyModeState.isEmpty ||
|
||||||
|
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value)) &&
|
||||||
pi.displaysCount.value > 1 &&
|
pi.displaysCount.value > 1 &&
|
||||||
bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') {
|
bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') {
|
||||||
final value =
|
final value =
|
||||||
@@ -741,23 +1054,38 @@ List<TToggleMenu> toolbarPrivacyMode(
|
|||||||
final ffiModel = ffi.ffiModel;
|
final ffiModel = ffi.ffiModel;
|
||||||
final pi = ffiModel.pi;
|
final pi = ffiModel.pi;
|
||||||
final sessionId = ffi.sessionId;
|
final sessionId = ffi.sessionId;
|
||||||
|
final hasPrivacyModePermission =
|
||||||
|
ffiModel.permissions['privacy_mode'] != false;
|
||||||
|
|
||||||
getDefaultMenu(Future<void> Function(SessionID sid, String opt) toggleFunc) {
|
// Backend revocation already attempts to turn privacy mode off.
|
||||||
final enabled = !ffi.ffiModel.viewOnly;
|
// Still keep this menu when privacy mode is active, so users can turn it off
|
||||||
|
// if there is a sync delay, version mismatch, or off attempt failure.
|
||||||
|
if (!hasPrivacyModePermission && privacyModeState.isEmpty) {
|
||||||
|
return []; // No permission and not active, hide options.
|
||||||
|
}
|
||||||
|
|
||||||
|
bool checkDisplayAllowedForPrivacyMode(String targetImplKey, bool turnOn) {
|
||||||
|
if (!turnOn ||
|
||||||
|
allowDisplaySwitchInPrivacyMode(pi, targetImplKey) ||
|
||||||
|
(ffiModel.pi.currentDisplay == 0 &&
|
||||||
|
!bind.sessionIsMultiUiSession(sessionId: sessionId))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
msgBox(sessionId, 'custom-nook-nocancel-hasclose', 'info',
|
||||||
|
'Please switch to Display 1 first', '', ffi.dialogManager);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultMenu(Future<void> Function(SessionID sid, String opt) toggleFunc,
|
||||||
|
String targetImplKey) {
|
||||||
|
final enabled = !ffiModel.viewOnly &&
|
||||||
|
(hasPrivacyModePermission || privacyModeState.isNotEmpty);
|
||||||
return TToggleMenu(
|
return TToggleMenu(
|
||||||
value: privacyModeState.isNotEmpty,
|
value: privacyModeState.isNotEmpty,
|
||||||
onChanged: enabled
|
onChanged: enabled
|
||||||
? (value) {
|
? (value) {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
if (ffiModel.pi.currentDisplay != 0 &&
|
if (!checkDisplayAllowedForPrivacyMode(targetImplKey, value)) {
|
||||||
ffiModel.pi.currentDisplay != kAllDisplayValue) {
|
|
||||||
msgBox(
|
|
||||||
sessionId,
|
|
||||||
'custom-nook-nocancel-hasclose',
|
|
||||||
'info',
|
|
||||||
'Please switch to Display 1 first',
|
|
||||||
'',
|
|
||||||
ffi.dialogManager);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final option = 'privacy-mode';
|
final option = 'privacy-mode';
|
||||||
@@ -775,7 +1103,7 @@ List<TToggleMenu> toolbarPrivacyMode(
|
|||||||
getDefaultMenu((sid, opt) async {
|
getDefaultMenu((sid, opt) async {
|
||||||
bind.sessionToggleOption(sessionId: sid, value: opt);
|
bind.sessionToggleOption(sessionId: sid, value: opt);
|
||||||
togglePrivacyModeTime = DateTime.now();
|
togglePrivacyModeTime = DateTime.now();
|
||||||
})
|
}, kPrivacyModeImplMag)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (privacyModeImpls.isEmpty) {
|
if (privacyModeImpls.isEmpty) {
|
||||||
@@ -789,21 +1117,35 @@ List<TToggleMenu> toolbarPrivacyMode(
|
|||||||
bind.sessionTogglePrivacyMode(
|
bind.sessionTogglePrivacyMode(
|
||||||
sessionId: sid, implKey: implKey, on: privacyModeState.isEmpty);
|
sessionId: sid, implKey: implKey, on: privacyModeState.isEmpty);
|
||||||
togglePrivacyModeTime = DateTime.now();
|
togglePrivacyModeTime = DateTime.now();
|
||||||
})
|
}, implKey)
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
return privacyModeImpls.map((e) {
|
final visibleImpls = hasPrivacyModePermission
|
||||||
|
? privacyModeImpls
|
||||||
|
: privacyModeImpls.where((e) {
|
||||||
|
final implKey = (e as List<dynamic>)[0] as String;
|
||||||
|
return privacyModeState.value == implKey;
|
||||||
|
}).toList();
|
||||||
|
return visibleImpls.map((e) {
|
||||||
final implKey = (e as List<dynamic>)[0] as String;
|
final implKey = (e as List<dynamic>)[0] as String;
|
||||||
final implName = (e)[1] as String;
|
final implName = (e)[1] as String;
|
||||||
|
final enabled = !ffiModel.viewOnly &&
|
||||||
|
(hasPrivacyModePermission || privacyModeState.value == implKey);
|
||||||
return TToggleMenu(
|
return TToggleMenu(
|
||||||
child: Text(translate(implName)),
|
child: Text(translate(implName)),
|
||||||
value: privacyModeState.value == implKey,
|
value: privacyModeState.value == implKey,
|
||||||
onChanged: (value) {
|
onChanged: enabled
|
||||||
|
? (value) {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
|
if (value && !hasPrivacyModePermission) return;
|
||||||
|
if (!checkDisplayAllowedForPrivacyMode(implKey, value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
togglePrivacyModeTime = DateTime.now();
|
togglePrivacyModeTime = DateTime.now();
|
||||||
bind.sessionTogglePrivacyMode(
|
bind.sessionTogglePrivacyMode(
|
||||||
sessionId: sessionId, implKey: implKey, on: value);
|
sessionId: sessionId, implKey: implKey, on: value);
|
||||||
});
|
}
|
||||||
|
: null);
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -812,6 +1154,7 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
|
|||||||
final ffiModel = ffi.ffiModel;
|
final ffiModel = ffi.ffiModel;
|
||||||
final pi = ffiModel.pi;
|
final pi = ffiModel.pi;
|
||||||
final sessionId = ffi.sessionId;
|
final sessionId = ffi.sessionId;
|
||||||
|
final isDefaultConn = ffi.connType == ConnType.defaultConn;
|
||||||
List<TToggleMenu> v = [];
|
List<TToggleMenu> v = [];
|
||||||
|
|
||||||
// swap key
|
// swap key
|
||||||
@@ -833,6 +1176,34 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
|
|||||||
child: Text(translate('Swap control-command key'))));
|
child: Text(translate('Swap control-command key'))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Relative mouse mode (gaming mode).
|
||||||
|
// Only show when server supports MOUSE_TYPE_MOVE_RELATIVE (version >= 1.4.5)
|
||||||
|
// Note: This feature is only available in Flutter client. Sciter client does not support this.
|
||||||
|
// Web client is not supported yet due to Pointer Lock API integration complexity with Flutter's input system.
|
||||||
|
// Wayland is not supported due to cursor warping limitations.
|
||||||
|
// Mobile: This option is now in GestureHelp widget, shown only when joystick is visible.
|
||||||
|
final isWayland = isDesktop && isLinux && bind.mainCurrentIsWayland();
|
||||||
|
if (isDesktop &&
|
||||||
|
isDefaultConn &&
|
||||||
|
!isWeb &&
|
||||||
|
!isWayland &&
|
||||||
|
ffiModel.keyboard &&
|
||||||
|
!ffiModel.viewOnly &&
|
||||||
|
ffi.inputModel.isRelativeMouseModeSupported) {
|
||||||
|
v.add(TToggleMenu(
|
||||||
|
value: ffi.inputModel.relativeMouseMode.value,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == null) return;
|
||||||
|
final previousValue = ffi.inputModel.relativeMouseMode.value;
|
||||||
|
final success = ffi.inputModel.setRelativeMouseMode(value);
|
||||||
|
if (!success) {
|
||||||
|
// Revert the observable toggle to reflect the actual state
|
||||||
|
ffi.inputModel.relativeMouseMode.value = previousValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(translate('Relative mouse mode'))));
|
||||||
|
}
|
||||||
|
|
||||||
// reverse mouse wheel
|
// reverse mouse wheel
|
||||||
if (ffiModel.keyboard) {
|
if (ffiModel.keyboard) {
|
||||||
var optionValue =
|
var optionValue =
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ const String kPlatformAdditionsHasFileClipboard = "has_file_clipboard";
|
|||||||
const String kPlatformAdditionsSupportedPrivacyModeImpl =
|
const String kPlatformAdditionsSupportedPrivacyModeImpl =
|
||||||
"supported_privacy_mode_impl";
|
"supported_privacy_mode_impl";
|
||||||
|
|
||||||
|
const String kPrivacyModeImplMag = 'privacy_mode_impl_mag';
|
||||||
|
const String kPrivacyModeImplExcludeFromCapture =
|
||||||
|
'privacy_mode_impl_exclude_from_capture';
|
||||||
|
|
||||||
const String kPeerPlatformWindows = "Windows";
|
const String kPeerPlatformWindows = "Windows";
|
||||||
const String kPeerPlatformLinux = "Linux";
|
const String kPeerPlatformLinux = "Linux";
|
||||||
const String kPeerPlatformMacOS = "Mac OS";
|
const String kPeerPlatformMacOS = "Mac OS";
|
||||||
@@ -50,6 +54,7 @@ const String kAppTypeDesktopPortForward = "port forward";
|
|||||||
const String kAppTypeDesktopTerminal = "terminal";
|
const String kAppTypeDesktopTerminal = "terminal";
|
||||||
|
|
||||||
const String kWindowMainWindowOnTop = "main_window_on_top";
|
const String kWindowMainWindowOnTop = "main_window_on_top";
|
||||||
|
const String kWindowRefreshCurrentUser = "refresh_current_user";
|
||||||
const String kWindowGetWindowInfo = "get_window_info";
|
const String kWindowGetWindowInfo = "get_window_info";
|
||||||
const String kWindowGetScreenList = "get_screen_list";
|
const String kWindowGetScreenList = "get_screen_list";
|
||||||
// This method is not used, maybe it can be removed.
|
// This method is not used, maybe it can be removed.
|
||||||
@@ -58,6 +63,7 @@ const String kWindowActionRebuild = "rebuild";
|
|||||||
const String kWindowEventHide = "hide";
|
const String kWindowEventHide = "hide";
|
||||||
const String kWindowEventShow = "show";
|
const String kWindowEventShow = "show";
|
||||||
const String kWindowConnect = "connect";
|
const String kWindowConnect = "connect";
|
||||||
|
const String kWindowBumpMouse = "bump_mouse";
|
||||||
|
|
||||||
const String kWindowEventNewRemoteDesktop = "new_remote_desktop";
|
const String kWindowEventNewRemoteDesktop = "new_remote_desktop";
|
||||||
const String kWindowEventNewFileTransfer = "new_file_transfer";
|
const String kWindowEventNewFileTransfer = "new_file_transfer";
|
||||||
@@ -78,9 +84,11 @@ const String kWindowEventOpenMonitorSession = "open_monitor_session";
|
|||||||
|
|
||||||
const String kOptionViewStyle = "view_style";
|
const String kOptionViewStyle = "view_style";
|
||||||
const String kOptionScrollStyle = "scroll_style";
|
const String kOptionScrollStyle = "scroll_style";
|
||||||
|
const String kOptionEdgeScrollEdgeThickness = "edge-scroll-edge-thickness";
|
||||||
const String kOptionImageQuality = "image_quality";
|
const String kOptionImageQuality = "image_quality";
|
||||||
const String kOptionOpenNewConnInTabs = "enable-open-new-connections-in-tabs";
|
const String kOptionOpenNewConnInTabs = "enable-open-new-connections-in-tabs";
|
||||||
const String kOptionTextureRender = "use-texture-render";
|
const String kOptionTextureRender = "use-texture-render";
|
||||||
|
const String kOptionTextureRenderHealth = "texture-render-health";
|
||||||
const String kOptionD3DRender = "allow-d3d-render";
|
const String kOptionD3DRender = "allow-d3d-render";
|
||||||
const String kOptionOpenInTabs = "allow-open-in-tabs";
|
const String kOptionOpenInTabs = "allow-open-in-tabs";
|
||||||
const String kOptionOpenInWindows = "allow-open-in-windows";
|
const String kOptionOpenInWindows = "allow-open-in-windows";
|
||||||
@@ -88,6 +96,7 @@ const String kOptionForceAlwaysRelay = "force-always-relay";
|
|||||||
const String kOptionViewOnly = "view_only";
|
const String kOptionViewOnly = "view_only";
|
||||||
const String kOptionEnableLanDiscovery = "enable-lan-discovery";
|
const String kOptionEnableLanDiscovery = "enable-lan-discovery";
|
||||||
const String kOptionWhitelist = "whitelist";
|
const String kOptionWhitelist = "whitelist";
|
||||||
|
const String kOptionIdWhitelist = "id-whitelist";
|
||||||
const String kOptionEnableAbr = "enable-abr";
|
const String kOptionEnableAbr = "enable-abr";
|
||||||
const String kOptionEnableRecordSession = "enable-record-session";
|
const String kOptionEnableRecordSession = "enable-record-session";
|
||||||
const String kOptionDirectServer = "direct-server";
|
const String kOptionDirectServer = "direct-server";
|
||||||
@@ -97,6 +106,7 @@ const String kOptionAutoDisconnectTimeout = "auto-disconnect-timeout";
|
|||||||
const String kOptionEnableHwcodec = "enable-hwcodec";
|
const String kOptionEnableHwcodec = "enable-hwcodec";
|
||||||
const String kOptionAllowAutoRecordIncoming = "allow-auto-record-incoming";
|
const String kOptionAllowAutoRecordIncoming = "allow-auto-record-incoming";
|
||||||
const String kOptionAllowAutoRecordOutgoing = "allow-auto-record-outgoing";
|
const String kOptionAllowAutoRecordOutgoing = "allow-auto-record-outgoing";
|
||||||
|
const String kOptionHideRecordingButton = "hide-recording-button";
|
||||||
const String kOptionVideoSaveDirectory = "video-save-directory";
|
const String kOptionVideoSaveDirectory = "video-save-directory";
|
||||||
const String kOptionAccessMode = "access-mode";
|
const String kOptionAccessMode = "access-mode";
|
||||||
const String kOptionEnableKeyboard = "enable-keyboard";
|
const String kOptionEnableKeyboard = "enable-keyboard";
|
||||||
@@ -111,6 +121,9 @@ const String kOptionTerminalPersistent = "terminal-persistent";
|
|||||||
const String kOptionEnableTunnel = "enable-tunnel";
|
const String kOptionEnableTunnel = "enable-tunnel";
|
||||||
const String kOptionEnableRemoteRestart = "enable-remote-restart";
|
const String kOptionEnableRemoteRestart = "enable-remote-restart";
|
||||||
const String kOptionEnableBlockInput = "enable-block-input";
|
const String kOptionEnableBlockInput = "enable-block-input";
|
||||||
|
const String kOptionEnablePrivacyMode = "enable-privacy-mode";
|
||||||
|
const String kOptionEnablePermChangeInAcceptWindow =
|
||||||
|
"enable-perm-change-in-accept-window";
|
||||||
const String kOptionAllowRemoteConfigModification =
|
const String kOptionAllowRemoteConfigModification =
|
||||||
"allow-remote-config-modification";
|
"allow-remote-config-modification";
|
||||||
const String kOptionVerificationMethod = "verification-method";
|
const String kOptionVerificationMethod = "verification-method";
|
||||||
@@ -118,6 +131,7 @@ const String kOptionApproveMode = "approve-mode";
|
|||||||
const String kOptionAllowNumericOneTimePassword =
|
const String kOptionAllowNumericOneTimePassword =
|
||||||
"allow-numeric-one-time-password";
|
"allow-numeric-one-time-password";
|
||||||
const String kOptionCollapseToolbar = "collapse_toolbar";
|
const String kOptionCollapseToolbar = "collapse_toolbar";
|
||||||
|
const String kOptionHideToolbar = "hide-toolbar";
|
||||||
const String kOptionShowRemoteCursor = "show_remote_cursor";
|
const String kOptionShowRemoteCursor = "show_remote_cursor";
|
||||||
const String kOptionFollowRemoteCursor = "follow_remote_cursor";
|
const String kOptionFollowRemoteCursor = "follow_remote_cursor";
|
||||||
const String kOptionFollowRemoteWindow = "follow_remote_window";
|
const String kOptionFollowRemoteWindow = "follow_remote_window";
|
||||||
@@ -135,6 +149,10 @@ const String kOptionSwapLeftRightMouse = "swap-left-right-mouse";
|
|||||||
const String kOptionCodecPreference = "codec-preference";
|
const String kOptionCodecPreference = "codec-preference";
|
||||||
const String kOptionRemoteMenubarDragLeft = "remote-menubar-drag-left";
|
const String kOptionRemoteMenubarDragLeft = "remote-menubar-drag-left";
|
||||||
const String kOptionRemoteMenubarDragRight = "remote-menubar-drag-right";
|
const String kOptionRemoteMenubarDragRight = "remote-menubar-drag-right";
|
||||||
|
const String kOptionRemoteMenubarEdge = "remote-menubar-edge";
|
||||||
|
const String kOptionRemoteMenubarFraction = "remote-menubar-frac";
|
||||||
|
const String kOptionAllowMultiEdgeToolbarDock =
|
||||||
|
"allow-multi-edge-toolbar-dock";
|
||||||
const String kOptionHideAbTagsPanel = "hideAbTagsPanel";
|
const String kOptionHideAbTagsPanel = "hideAbTagsPanel";
|
||||||
const String kOptionRemoteMenubarState = "remoteMenubarState";
|
const String kOptionRemoteMenubarState = "remoteMenubarState";
|
||||||
const String kOptionPeerSorting = "peer-sorting";
|
const String kOptionPeerSorting = "peer-sorting";
|
||||||
@@ -155,21 +173,40 @@ const String kOptionAllowRemoteCmModification = "allow-remote-cm-modification";
|
|||||||
const String kOptionEnableUdpPunch = "enable-udp-punch";
|
const String kOptionEnableUdpPunch = "enable-udp-punch";
|
||||||
const String kOptionEnableIpv6Punch = "enable-ipv6-punch";
|
const String kOptionEnableIpv6Punch = "enable-ipv6-punch";
|
||||||
const String kOptionEnableTrustedDevices = "enable-trusted-devices";
|
const String kOptionEnableTrustedDevices = "enable-trusted-devices";
|
||||||
|
const String kOptionShowVirtualMouse = "show-virtual-mouse";
|
||||||
|
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
|
||||||
|
const String kOptionShowVirtualJoystick = "show-virtual-joystick";
|
||||||
|
const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note";
|
||||||
|
const String kOptionAllowMonitorSwitchMainToolbar = "allow-monitor-switch-main-toolbar";
|
||||||
|
const String kOptionAllowMonitorSwitchMinToolbar = "allow-monitor-switch-min-toolbar";
|
||||||
|
const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys";
|
||||||
|
const String kOptionShowTerminalCtrlKeys = "show-terminal-extra-ctrl-keys";
|
||||||
|
|
||||||
// network options
|
// network options
|
||||||
const String kOptionAllowWebSocket = "allow-websocket";
|
const String kOptionAllowWebSocket = "allow-websocket";
|
||||||
|
const String kOptionAllowInsecureTLSFallback = "allow-insecure-tls-fallback";
|
||||||
|
const String kOptionDisableUdp = "disable-udp";
|
||||||
|
const String kOptionEnableFlutterHttpOnRust = "enable-flutter-http-on-rust";
|
||||||
|
|
||||||
// buildin opitons
|
// builtin options
|
||||||
const String kOptionHideServerSetting = "hide-server-settings";
|
const String kOptionHideServerSetting = "hide-server-settings";
|
||||||
const String kOptionHideProxySetting = "hide-proxy-settings";
|
const String kOptionHideProxySetting = "hide-proxy-settings";
|
||||||
const String kOptionHideWebSocketSetting = "hide-websocket-settings";
|
const String kOptionHideWebSocketSetting = "hide-websocket-settings";
|
||||||
|
const String kOptionHideStopService = "hide-stop-service";
|
||||||
const String kOptionHideRemotePrinterSetting = "hide-remote-printer-settings";
|
const String kOptionHideRemotePrinterSetting = "hide-remote-printer-settings";
|
||||||
const String kOptionHideSecuritySetting = "hide-security-settings";
|
const String kOptionHideSecuritySetting = "hide-security-settings";
|
||||||
const String kOptionHideNetworkSetting = "hide-network-settings";
|
const String kOptionHideNetworkSetting = "hide-network-settings";
|
||||||
const String kOptionRemovePresetPasswordWarning =
|
const String kOptionRemovePresetPasswordWarning =
|
||||||
"remove-preset-password-warning";
|
"remove-preset-password-warning";
|
||||||
|
const String kOptionDisableChangePermanentPassword =
|
||||||
|
"disable-change-permanent-password";
|
||||||
|
const String kOptionDisableChangeId = "disable-change-id";
|
||||||
|
const String kOptionDisableUnlockPin = "disable-unlock-pin";
|
||||||
const kHideUsernameOnCard = "hide-username-on-card";
|
const kHideUsernameOnCard = "hide-username-on-card";
|
||||||
const String kOptionHideHelpCards = "hide-help-cards";
|
const String kOptionHideHelpCards = "hide-help-cards";
|
||||||
|
const String kOptionAllowDeepLinkPassword = "allow-deep-link-password";
|
||||||
|
const String kOptionAllowDeepLinkServerSettings =
|
||||||
|
"allow-deep-link-server-settings";
|
||||||
|
|
||||||
const String kOptionToggleViewOnly = "view-only";
|
const String kOptionToggleViewOnly = "view-only";
|
||||||
const String kOptionToggleShowMyCursor = "show-my-cursor";
|
const String kOptionToggleShowMyCursor = "show-my-cursor";
|
||||||
@@ -178,6 +215,9 @@ const String kOptionDisableFloatingWindow = "disable-floating-window";
|
|||||||
|
|
||||||
const String kOptionKeepScreenOn = "keep-screen-on";
|
const String kOptionKeepScreenOn = "keep-screen-on";
|
||||||
|
|
||||||
|
const String kOptionKeepAwakeDuringIncomingSessions = "keep-awake-during-incoming-sessions";
|
||||||
|
const String kOptionKeepAwakeDuringOutgoingSessions = "keep-awake-during-outgoing-sessions";
|
||||||
|
|
||||||
const String kOptionShowMobileAction = "showMobileActions";
|
const String kOptionShowMobileAction = "showMobileActions";
|
||||||
|
|
||||||
const String kUrlActionClose = "close";
|
const String kUrlActionClose = "close";
|
||||||
@@ -242,6 +282,33 @@ const int kMinTrackpadSpeed = 10;
|
|||||||
const int kDefaultTrackpadSpeed = 100;
|
const int kDefaultTrackpadSpeed = 100;
|
||||||
const int kMaxTrackpadSpeed = 1000;
|
const int kMaxTrackpadSpeed = 1000;
|
||||||
|
|
||||||
|
// relative mouse mode
|
||||||
|
/// Throttle duration (in milliseconds) for updating pointer lock center during
|
||||||
|
/// window move/resize events. Lower values provide more responsive updates but
|
||||||
|
/// may cause performance issues during rapid window operations.
|
||||||
|
const int kDefaultPointerLockCenterThrottleMs = 100;
|
||||||
|
|
||||||
|
/// Minimum server version required for relative mouse mode (MOUSE_TYPE_MOVE_RELATIVE).
|
||||||
|
/// Servers older than this version will ignore relative mouse events.
|
||||||
|
///
|
||||||
|
/// IMPORTANT: This value must be kept in sync with the Rust constant
|
||||||
|
/// `MIN_VERSION_RELATIVE_MOUSE_MODE` in `src/common.rs`.
|
||||||
|
const String kMinVersionForRelativeMouseMode = '1.4.5';
|
||||||
|
|
||||||
|
/// Maximum delta value for relative mouse movement.
|
||||||
|
/// Large values could cause issues with i32 overflow on server side,
|
||||||
|
/// and no reasonable mouse movement should exceed this bound.
|
||||||
|
///
|
||||||
|
/// IMPORTANT: This value must be kept in sync with the Rust constant
|
||||||
|
/// `MAX_RELATIVE_MOUSE_DELTA` in `src/server/input_service.rs`.
|
||||||
|
const int kMaxRelativeMouseDelta = 10000;
|
||||||
|
|
||||||
|
/// Debounce duration (in milliseconds) for relative mouse mode toggle.
|
||||||
|
/// This prevents double-toggle from race condition between Rust rdev grab loop
|
||||||
|
/// and Flutter keyboard handling. Value should be small enough to allow
|
||||||
|
/// intentional quick toggles but large enough to prevent accidental double-triggers.
|
||||||
|
const int kRelativeMouseModeToggleDebounceMs = 150;
|
||||||
|
|
||||||
// incomming (should be incoming) is kept, because change it will break the previous setting.
|
// incomming (should be incoming) is kept, because change it will break the previous setting.
|
||||||
const String kKeyPrinterIncomingJobAction = 'printer-incomming-job-action';
|
const String kKeyPrinterIncomingJobAction = 'printer-incomming-job-action';
|
||||||
const String kValuePrinterIncomingJobDismiss = 'dismiss';
|
const String kValuePrinterIncomingJobDismiss = 'dismiss';
|
||||||
@@ -313,12 +380,18 @@ const kRemoteViewStyleOriginal = 'original';
|
|||||||
/// [kRemoteViewStyleAdaptive] Show remote image scaling by ratio factor.
|
/// [kRemoteViewStyleAdaptive] Show remote image scaling by ratio factor.
|
||||||
const kRemoteViewStyleAdaptive = 'adaptive';
|
const kRemoteViewStyleAdaptive = 'adaptive';
|
||||||
|
|
||||||
|
/// [kRemoteViewStyleCustom] Show remote image at a user-defined scale percent.
|
||||||
|
const kRemoteViewStyleCustom = 'custom';
|
||||||
|
|
||||||
/// [kRemoteScrollStyleAuto] Scroll image auto by position.
|
/// [kRemoteScrollStyleAuto] Scroll image auto by position.
|
||||||
const kRemoteScrollStyleAuto = 'scrollauto';
|
const kRemoteScrollStyleAuto = 'scrollauto';
|
||||||
|
|
||||||
/// [kRemoteScrollStyleBar] Scroll image with scroll bar.
|
/// [kRemoteScrollStyleBar] Scroll image with scroll bar.
|
||||||
const kRemoteScrollStyleBar = 'scrollbar';
|
const kRemoteScrollStyleBar = 'scrollbar';
|
||||||
|
|
||||||
|
/// [kRemoteScrollStyleEdge] Scroll image auto at edges.
|
||||||
|
const kRemoteScrollStyleEdge = 'scrolledge';
|
||||||
|
|
||||||
/// [kScrollModeDefault] Mouse or touchpad, the default scroll mode.
|
/// [kScrollModeDefault] Mouse or touchpad, the default scroll mode.
|
||||||
const kScrollModeDefault = 'default';
|
const kScrollModeDefault = 'default';
|
||||||
|
|
||||||
@@ -345,6 +418,17 @@ const Set<PointerDeviceKind> kTouchBasedDeviceKinds = {
|
|||||||
PointerDeviceKind.invertedStylus,
|
PointerDeviceKind.invertedStylus,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Scale custom related constants
|
||||||
|
const String kCustomScalePercentKey =
|
||||||
|
'custom_scale_percent'; // Flutter option key for storing custom scale percent (integer 5-1000)
|
||||||
|
const int kScaleCustomMinPercent = 5;
|
||||||
|
const int kScaleCustomPivotPercent = 100; // 100% should be at 1/3 of track
|
||||||
|
const int kScaleCustomMaxPercent = 1000;
|
||||||
|
const double kScaleCustomPivotPos = 1.0 / 3.0; // first 1/3 → up to 100%
|
||||||
|
const double kScaleCustomDetentEpsilon =
|
||||||
|
0.006; // snap range around pivot (~0.6%)
|
||||||
|
const Duration kDebounceCustomScaleDuration = Duration(milliseconds: 300);
|
||||||
|
|
||||||
// ================================ mobile ================================
|
// ================================ mobile ================================
|
||||||
|
|
||||||
// Magic numbers, maybe need to avoid it or use a better way to get them.
|
// Magic numbers, maybe need to avoid it or use a better way to get them.
|
||||||
|
|||||||
@@ -374,6 +374,7 @@ class _ConnectionPageState extends State<ConnectionPage>
|
|||||||
rdpUsername: '',
|
rdpUsername: '',
|
||||||
loginName: '',
|
loginName: '',
|
||||||
device_group_name: '',
|
device_group_name: '',
|
||||||
|
note: '',
|
||||||
);
|
);
|
||||||
_autocompleteOpts = [emptyPeer];
|
_autocompleteOpts = [emptyPeer];
|
||||||
} else {
|
} else {
|
||||||
@@ -397,6 +398,7 @@ class _ConnectionPageState extends State<ConnectionPage>
|
|||||||
.contains(textToFind) ||
|
.contains(textToFind) ||
|
||||||
peer.alias.toLowerCase().contains(textToFind))
|
peer.alias.toLowerCase().contains(textToFind))
|
||||||
.toList();
|
.toList();
|
||||||
|
_allPeersLoader.queryOnlines(_autocompleteOpts);
|
||||||
}
|
}
|
||||||
return _autocompleteOpts;
|
return _autocompleteOpts;
|
||||||
},
|
},
|
||||||
@@ -568,22 +570,26 @@ class _ConnectionPageState extends State<ConnectionPage>
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
.map((e) => MenuEntryButton<String>(
|
.map((e) => MenuEntryButton<String>(
|
||||||
childBuilder: (TextStyle? style) => Text(
|
childBuilder: (TextStyle? style) =>
|
||||||
|
Text(
|
||||||
translate(e.$1),
|
translate(e.$1),
|
||||||
style: style,
|
style: style,
|
||||||
),
|
),
|
||||||
proc: () => e.$2(),
|
proc: () => e.$2(),
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
horizontal: kDesktopMenuPadding.left),
|
horizontal:
|
||||||
|
kDesktopMenuPadding.left),
|
||||||
dismissOnClicked: true,
|
dismissOnClicked: true,
|
||||||
))
|
))
|
||||||
.map((e) => e.build(
|
.map((e) => e.build(
|
||||||
context,
|
context,
|
||||||
const MenuConfig(
|
const MenuConfig(
|
||||||
commonColor:
|
commonColor: CustomPopupMenuTheme
|
||||||
CustomPopupMenuTheme.commonColor,
|
.commonColor,
|
||||||
height: CustomPopupMenuTheme.height,
|
height:
|
||||||
dividerHeight: CustomPopupMenuTheme
|
CustomPopupMenuTheme.height,
|
||||||
|
dividerHeight:
|
||||||
|
CustomPopupMenuTheme
|
||||||
.dividerHeight)))
|
.dividerHeight)))
|
||||||
.expand((i) => i)
|
.expand((i) => i)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ import 'package:flutter_hbb/models/server_model.dart';
|
|||||||
import 'package:flutter_hbb/models/state_model.dart';
|
import 'package:flutter_hbb/models/state_model.dart';
|
||||||
import 'package:flutter_hbb/plugin/ui_manager.dart';
|
import 'package:flutter_hbb/plugin/ui_manager.dart';
|
||||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||||
|
import 'package:flutter_hbb/utils/platform_channel.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
import 'package:window_size/window_size.dart' as window_size;
|
import 'package:window_size/window_size.dart' as window_size;
|
||||||
import '../widgets/button.dart';
|
import '../widgets/button.dart';
|
||||||
|
import '../widgets/texture_render_probe.dart';
|
||||||
|
|
||||||
class DesktopHomePage extends StatefulWidget {
|
class DesktopHomePage extends StatefulWidget {
|
||||||
const DesktopHomePage({Key? key}) : super(key: key);
|
const DesktopHomePage({Key? key}) : super(key: key);
|
||||||
@@ -59,7 +61,9 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
final isIncomingOnly = bind.isIncomingOnly();
|
final isIncomingOnly = bind.isIncomingOnly();
|
||||||
return _buildBlock(
|
return Stack(
|
||||||
|
children: [
|
||||||
|
_buildBlock(
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -67,7 +71,10 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
|||||||
if (!isIncomingOnly) const VerticalDivider(width: 1),
|
if (!isIncomingOnly) const VerticalDivider(width: 1),
|
||||||
if (!isIncomingOnly) Expanded(child: buildRightPane(context)),
|
if (!isIncomingOnly) Expanded(child: buildRightPane(context)),
|
||||||
],
|
],
|
||||||
));
|
)),
|
||||||
|
const Positioned(left: 0, top: 0, child: TextureRenderProbe()),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBlock({required Widget child}) {
|
Widget _buildBlock({required Widget child}) {
|
||||||
@@ -449,7 +456,11 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
|||||||
"${translate("new-version-of-{${bind.mainGetAppNameSync()}}-tip")} (${bind.mainGetNewVersion()}).",
|
"${translate("new-version-of-{${bind.mainGetAppNameSync()}}-tip")} (${bind.mainGetNewVersion()}).",
|
||||||
btnText,
|
btnText,
|
||||||
onPressed,
|
onPressed,
|
||||||
closeButton: true);
|
closeButton: true,
|
||||||
|
help: isToUpdate ? 'Changelog' : null,
|
||||||
|
link: isToUpdate
|
||||||
|
? 'https://github.com/rustdesk/rustdesk/releases/tag/${bind.mainGetNewVersion()}'
|
||||||
|
: null);
|
||||||
}
|
}
|
||||||
if (systemError.isNotEmpty) {
|
if (systemError.isNotEmpty) {
|
||||||
return buildInstallCard("", systemError, "", () {});
|
return buildInstallCard("", systemError, "", () {});
|
||||||
@@ -760,11 +771,23 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
|||||||
'scaleFactor': screen.scaleFactor,
|
'scaleFactor': screen.scaleFactor,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
bool isChattyMethod(String methodName) {
|
||||||
|
switch (methodName) {
|
||||||
|
case kWindowBumpMouse: return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
rustDeskWinManager.setMethodHandler((call, fromWindowId) async {
|
rustDeskWinManager.setMethodHandler((call, fromWindowId) async {
|
||||||
|
if (!isChattyMethod(call.method)) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
"[Main] call ${call.method} with args ${call.arguments} from window $fromWindowId");
|
"[Main] call ${call.method} with args ${call.arguments} from window $fromWindowId");
|
||||||
|
}
|
||||||
if (call.method == kWindowMainWindowOnTop) {
|
if (call.method == kWindowMainWindowOnTop) {
|
||||||
windowOnTop(null);
|
windowOnTop(null);
|
||||||
|
} else if (call.method == kWindowRefreshCurrentUser) {
|
||||||
|
gFFI.userModel.refreshCurrentUser();
|
||||||
} else if (call.method == kWindowGetWindowInfo) {
|
} else if (call.method == kWindowGetWindowInfo) {
|
||||||
final screen = (await window_size.getWindowInfo()).screen;
|
final screen = (await window_size.getWindowInfo()).screen;
|
||||||
if (screen == null) {
|
if (screen == null) {
|
||||||
@@ -793,6 +816,10 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
|||||||
forceRelay: call.arguments['forceRelay'],
|
forceRelay: call.arguments['forceRelay'],
|
||||||
connToken: call.arguments['connToken'],
|
connToken: call.arguments['connToken'],
|
||||||
);
|
);
|
||||||
|
} else if (call.method == kWindowBumpMouse) {
|
||||||
|
return RdPlatformChannel.instance.bumpMouse(
|
||||||
|
dx: call.arguments['dx'],
|
||||||
|
dy: call.arguments['dy']);
|
||||||
} else if (call.method == kWindowEventMoveTabToNewWindow) {
|
} else if (call.method == kWindowEventMoveTabToNewWindow) {
|
||||||
final args = call.arguments.split(',');
|
final args = call.arguments.split(',');
|
||||||
int? windowId;
|
int? windowId;
|
||||||
@@ -887,12 +914,17 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
||||||
final pw = await bind.mainGetPermanentPassword();
|
final p0 = TextEditingController(text: "");
|
||||||
final p0 = TextEditingController(text: pw);
|
final p1 = TextEditingController(text: "");
|
||||||
final p1 = TextEditingController(text: pw);
|
|
||||||
var errMsg0 = "";
|
var errMsg0 = "";
|
||||||
var errMsg1 = "";
|
var errMsg1 = "";
|
||||||
final RxString rxPass = pw.trim().obs;
|
final localPasswordSet =
|
||||||
|
(await bind.mainGetCommon(key: "local-permanent-password-set")) == "true";
|
||||||
|
final permanentPasswordSet =
|
||||||
|
(await bind.mainGetCommon(key: "permanent-password-set")) == "true";
|
||||||
|
final presetPassword = permanentPasswordSet && !localPasswordSet;
|
||||||
|
var canSubmit = false;
|
||||||
|
final RxString rxPass = "".obs;
|
||||||
final rules = [
|
final rules = [
|
||||||
DigitValidationRule(),
|
DigitValidationRule(),
|
||||||
UppercaseValidationRule(),
|
UppercaseValidationRule(),
|
||||||
@@ -901,9 +933,21 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
|||||||
MinCharactersValidationRule(8),
|
MinCharactersValidationRule(8),
|
||||||
];
|
];
|
||||||
final maxLength = bind.mainMaxEncryptLen();
|
final maxLength = bind.mainMaxEncryptLen();
|
||||||
|
final statusTip = localPasswordSet
|
||||||
|
? translate('password-hidden-tip')
|
||||||
|
: (presetPassword ? translate('preset-password-in-use-tip') : '');
|
||||||
|
final showStatusTipOnMobile =
|
||||||
|
statusTip.isNotEmpty && !isDesktop && !isWebDesktop;
|
||||||
|
|
||||||
gFFI.dialogManager.show((setState, close, context) {
|
gFFI.dialogManager.show((setState, close, context) {
|
||||||
submit() {
|
updateCanSubmit() {
|
||||||
|
canSubmit = p0.text.trim().isNotEmpty || p1.text.trim().isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
submit() async {
|
||||||
|
if (!canSubmit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
errMsg0 = "";
|
errMsg0 = "";
|
||||||
errMsg1 = "";
|
errMsg1 = "";
|
||||||
@@ -926,7 +970,13 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bind.mainSetPermanentPassword(password: pass);
|
final ok = await bind.mainSetPermanentPasswordWithResult(password: pass);
|
||||||
|
if (!ok) {
|
||||||
|
setState(() {
|
||||||
|
errMsg0 = '${translate('Prompt')}: ${translate("Failed")}';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (pass.isNotEmpty) {
|
if (pass.isNotEmpty) {
|
||||||
notEmptyCallback?.call();
|
notEmptyCallback?.call();
|
||||||
}
|
}
|
||||||
@@ -934,14 +984,20 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return CustomAlertDialog(
|
return CustomAlertDialog(
|
||||||
title: Text(translate("Set Password")),
|
title: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.key, color: MyTheme.accent),
|
||||||
|
Text(translate("Set Password")).paddingOnly(left: 10),
|
||||||
|
],
|
||||||
|
),
|
||||||
content: ConstrainedBox(
|
content: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(minWidth: 500),
|
constraints: const BoxConstraints(minWidth: 500),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(
|
SizedBox(
|
||||||
height: 8.0,
|
height: showStatusTipOnMobile ? 0.0 : 6.0,
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -957,6 +1013,7 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
|||||||
rxPass.value = value.trim();
|
rxPass.value = value.trim();
|
||||||
setState(() {
|
setState(() {
|
||||||
errMsg0 = '';
|
errMsg0 = '';
|
||||||
|
updateCanSubmit();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
maxLength: maxLength,
|
maxLength: maxLength,
|
||||||
@@ -968,9 +1025,9 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(child: PasswordStrengthIndicator(password: rxPass)),
|
Expanded(child: PasswordStrengthIndicator(password: rxPass)),
|
||||||
],
|
],
|
||||||
).marginSymmetric(vertical: 8),
|
).marginOnly(top: 2, bottom: showStatusTipOnMobile ? 2 : 8),
|
||||||
const SizedBox(
|
SizedBox(
|
||||||
height: 8.0,
|
height: showStatusTipOnMobile ? 0.0 : 8.0,
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -984,6 +1041,7 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
|||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
errMsg1 = '';
|
errMsg1 = '';
|
||||||
|
updateCanSubmit();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
maxLength: maxLength,
|
maxLength: maxLength,
|
||||||
@@ -991,11 +1049,23 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(
|
if (statusTip.isNotEmpty)
|
||||||
height: 8.0,
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info, color: Colors.amber, size: 18)
|
||||||
|
.marginOnly(right: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
statusTip,
|
||||||
|
style: const TextStyle(fontSize: 13, height: 1.1),
|
||||||
|
))
|
||||||
|
],
|
||||||
|
).marginOnly(top: 6, bottom: 2),
|
||||||
|
SizedBox(
|
||||||
|
height: showStatusTipOnMobile ? 0.0 : 8.0,
|
||||||
),
|
),
|
||||||
Obx(() => Wrap(
|
Obx(() => Wrap(
|
||||||
runSpacing: 8,
|
runSpacing: showStatusTipOnMobile ? 2.0 : 8.0,
|
||||||
spacing: 4,
|
spacing: 4,
|
||||||
children: rules.map((e) {
|
children: rules.map((e) {
|
||||||
var checked = e.validate(rxPass.value.trim());
|
var checked = e.validate(rxPass.value.trim());
|
||||||
@@ -1015,11 +1085,67 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: (() {
|
||||||
dialogButton("Cancel", onPressed: close, isOutline: true),
|
final cancelButton = dialogButton(
|
||||||
dialogButton("OK", onPressed: submit),
|
"Cancel",
|
||||||
|
icon: Icon(Icons.close_rounded),
|
||||||
|
onPressed: close,
|
||||||
|
isOutline: true,
|
||||||
|
);
|
||||||
|
final removeButton = dialogButton(
|
||||||
|
"Remove",
|
||||||
|
icon: Icon(Icons.delete_outline_rounded),
|
||||||
|
onPressed: () async {
|
||||||
|
setState(() {
|
||||||
|
errMsg0 = "";
|
||||||
|
errMsg1 = "";
|
||||||
|
});
|
||||||
|
final ok =
|
||||||
|
await bind.mainSetPermanentPasswordWithResult(password: "");
|
||||||
|
if (!ok) {
|
||||||
|
setState(() {
|
||||||
|
errMsg0 = '${translate('Prompt')}: ${translate("Failed")}';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
},
|
||||||
|
buttonStyle: ButtonStyle(
|
||||||
|
backgroundColor: MaterialStatePropertyAll(Colors.red)),
|
||||||
|
);
|
||||||
|
final okButton = dialogButton(
|
||||||
|
"OK",
|
||||||
|
icon: Icon(Icons.done_rounded),
|
||||||
|
onPressed: canSubmit ? submit : null,
|
||||||
|
);
|
||||||
|
if (!isDesktop && !isWebDesktop && localPasswordSet) {
|
||||||
|
return [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
cancelButton,
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
removeButton,
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
okButton,
|
||||||
],
|
],
|
||||||
onSubmit: submit,
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
cancelButton,
|
||||||
|
if (localPasswordSet) removeButton,
|
||||||
|
okButton,
|
||||||
|
];
|
||||||
|
})(),
|
||||||
|
onSubmit: canSubmit ? submit : null,
|
||||||
onCancel: close,
|
onCancel: close,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
|
|||||||
import 'package:flutter_hbb/consts.dart';
|
import 'package:flutter_hbb/consts.dart';
|
||||||
import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
|
import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
|
||||||
import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
|
import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
|
||||||
|
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
||||||
import 'package:flutter_hbb/mobile/widgets/dialog.dart';
|
import 'package:flutter_hbb/mobile/widgets/dialog.dart';
|
||||||
import 'package:flutter_hbb/models/platform_model.dart';
|
import 'package:flutter_hbb/models/platform_model.dart';
|
||||||
import 'package:flutter_hbb/models/printer_model.dart';
|
import 'package:flutter_hbb/models/printer_model.dart';
|
||||||
@@ -406,6 +407,7 @@ class _GeneralState extends State<_General> {
|
|||||||
final RxBool serviceStop =
|
final RxBool serviceStop =
|
||||||
isWeb ? RxBool(false) : Get.find<RxBool>(tag: 'stop-service');
|
isWeb ? RxBool(false) : Get.find<RxBool>(tag: 'stop-service');
|
||||||
RxBool serviceBtnEnabled = true.obs;
|
RxBool serviceBtnEnabled = true.obs;
|
||||||
|
final GlobalKey _minToolbarOptionKey = GlobalKey();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -457,8 +459,16 @@ class _GeneralState extends State<_General> {
|
|||||||
return const Offstage();
|
return const Offstage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final hideStopService =
|
||||||
|
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
|
||||||
|
|
||||||
|
return Obx(() {
|
||||||
|
if (hideStopService && !serviceStop.value) {
|
||||||
|
return const Offstage();
|
||||||
|
}
|
||||||
|
|
||||||
return _Card(title: 'Service', children: [
|
return _Card(title: 'Service', children: [
|
||||||
Obx(() => _Button(serviceStop.value ? 'Start' : 'Stop', () {
|
_Button(serviceStop.value ? 'Start' : 'Stop', () {
|
||||||
() async {
|
() async {
|
||||||
serviceBtnEnabled.value = false;
|
serviceBtnEnabled.value = false;
|
||||||
await start_service(serviceStop.value);
|
await start_service(serviceStop.value);
|
||||||
@@ -467,21 +477,35 @@ class _GeneralState extends State<_General> {
|
|||||||
serviceBtnEnabled.value = true;
|
serviceBtnEnabled.value = true;
|
||||||
});
|
});
|
||||||
}();
|
}();
|
||||||
}, enabled: serviceBtnEnabled.value))
|
}, enabled: serviceBtnEnabled.value)
|
||||||
]);
|
]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget other() {
|
Widget other() {
|
||||||
final showAutoUpdate =
|
final incomingOnly = bind.isIncomingOnly();
|
||||||
isWindows && bind.mainIsInstalled() && !bind.isCustomClient();
|
final outgoingOnly = bind.isOutgoingOnly();
|
||||||
|
final showAutoUpdate = (isWindows && bind.mainIsInstalled()) ||
|
||||||
|
(isMacOS && bind.mainIsInstalled() && bind.mainIsInstalledDaemon(prompt: false) && !bind.isCustomClient());
|
||||||
final children = <Widget>[
|
final children = <Widget>[
|
||||||
if (!isWeb && !bind.isIncomingOnly())
|
if (!isWeb && !incomingOnly)
|
||||||
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
|
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
|
||||||
kOptionEnableConfirmClosingTabs,
|
kOptionEnableConfirmClosingTabs,
|
||||||
isServer: false),
|
isServer: false),
|
||||||
|
if (!incomingOnly)
|
||||||
|
_OptionCheckBox(
|
||||||
|
context,
|
||||||
|
'allow-remote-toolbar-docking-any-edge',
|
||||||
|
kOptionAllowMultiEdgeToolbarDock,
|
||||||
|
isServer: false,
|
||||||
|
update: (_) {
|
||||||
|
reloadAllWindows();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!isWeb && !outgoingOnly)
|
||||||
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
|
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
|
||||||
if (!isWeb) wallpaper(),
|
if (!isWeb) wallpaper(),
|
||||||
if (!isWeb && !bind.isIncomingOnly()) ...[
|
if (!isWeb && !incomingOnly) ...[
|
||||||
_OptionCheckBox(
|
_OptionCheckBox(
|
||||||
context,
|
context,
|
||||||
'Open connection in new tab',
|
'Open connection in new tab',
|
||||||
@@ -520,6 +544,7 @@ class _GeneralState extends State<_General> {
|
|||||||
isServer: false,
|
isServer: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
if (!isWeb && !bind.isCustomClient())
|
if (!isWeb && !bind.isCustomClient())
|
||||||
_OptionCheckBox(
|
_OptionCheckBox(
|
||||||
context,
|
context,
|
||||||
@@ -534,13 +559,13 @@ class _GeneralState extends State<_General> {
|
|||||||
kOptionAllowAutoUpdate,
|
kOptionAllowAutoUpdate,
|
||||||
isServer: true,
|
isServer: true,
|
||||||
),
|
),
|
||||||
if (isWindows && !bind.isOutgoingOnly())
|
if (isWindows && !outgoingOnly)
|
||||||
_OptionCheckBox(
|
_OptionCheckBox(
|
||||||
context,
|
context,
|
||||||
'Capture screen using DirectX',
|
'Capture screen using DirectX',
|
||||||
kOptionDirectxCapture,
|
kOptionDirectxCapture,
|
||||||
),
|
),
|
||||||
if (!bind.isIncomingOnly()) ...[
|
if (!isWeb && !incomingOnly) ...[
|
||||||
_OptionCheckBox(
|
_OptionCheckBox(
|
||||||
context,
|
context,
|
||||||
'Enable UDP hole punching',
|
'Enable UDP hole punching',
|
||||||
@@ -554,12 +579,78 @@ class _GeneralState extends State<_General> {
|
|||||||
isServer: false,
|
isServer: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Add client-side wakelock option for desktop platforms
|
||||||
|
if (!bind.isIncomingOnly()) {
|
||||||
|
children.add(_OptionCheckBox(
|
||||||
|
context,
|
||||||
|
'keep-awake-during-outgoing-sessions-label',
|
||||||
|
kOptionKeepAwakeDuringOutgoingSessions,
|
||||||
|
isServer: false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
if (!isWeb && bind.mainShowOption(key: kOptionAllowLinuxHeadless)) {
|
if (!isWeb && bind.mainShowOption(key: kOptionAllowLinuxHeadless)) {
|
||||||
children.add(_OptionCheckBox(
|
children.add(_OptionCheckBox(
|
||||||
context, 'Allow linux headless', kOptionAllowLinuxHeadless));
|
context, 'Allow linux headless', kOptionAllowLinuxHeadless));
|
||||||
}
|
}
|
||||||
|
if (!bind.isDisableAccount()) {
|
||||||
|
children.add(_OptionCheckBox(
|
||||||
|
context,
|
||||||
|
'note-at-conn-end-tip',
|
||||||
|
kOptionAllowAskForNoteAtEndOfConnection,
|
||||||
|
isServer: false,
|
||||||
|
optSetter: (key, value) async {
|
||||||
|
if (value && !gFFI.userModel.isLogin) {
|
||||||
|
final res = await loginDialog();
|
||||||
|
if (res != true) return;
|
||||||
|
}
|
||||||
|
await mainSetLocalBoolOption(key, value);
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
children.add(_OptionCheckBox(
|
||||||
|
context,
|
||||||
|
'Show monitor switch button on the main toolbar',
|
||||||
|
kOptionAllowMonitorSwitchMainToolbar,
|
||||||
|
isServer: false,
|
||||||
|
update: (enabled) async {
|
||||||
|
if (!enabled) {
|
||||||
|
await mainSetLocalBoolOption(
|
||||||
|
kOptionAllowMonitorSwitchMinToolbar, false);
|
||||||
|
}
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
reloadAllWindows();
|
||||||
|
if (enabled) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final ctx = _minToolbarOptionKey.currentContext;
|
||||||
|
if (ctx != null) {
|
||||||
|
Scrollable.ensureVisible(
|
||||||
|
ctx,
|
||||||
|
alignment: 0.5,
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
));
|
||||||
|
if (mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar)) {
|
||||||
|
children.add(KeyedSubtree(
|
||||||
|
key: _minToolbarOptionKey,
|
||||||
|
child: _OptionCheckBox(
|
||||||
|
context,
|
||||||
|
'Show on the minimized toolbar',
|
||||||
|
kOptionAllowMonitorSwitchMinToolbar,
|
||||||
|
isServer: false,
|
||||||
|
update: (_) {
|
||||||
|
reloadAllWindows();
|
||||||
|
},
|
||||||
|
).marginOnly(left: _kCheckBoxLeftMargin * 3),
|
||||||
|
));
|
||||||
|
}
|
||||||
return _Card(title: 'Other', children: children);
|
return _Card(title: 'Other', children: children);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,6 +900,7 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
|||||||
permissions(context),
|
permissions(context),
|
||||||
password(context),
|
password(context),
|
||||||
_Card(title: '2FA', children: [tfa()]),
|
_Card(title: '2FA', children: [tfa()]),
|
||||||
|
if (!isChangeIdDisabled())
|
||||||
_Card(title: 'ID', children: [changeId()]),
|
_Card(title: 'ID', children: [changeId()]),
|
||||||
more(context),
|
more(context),
|
||||||
]),
|
]),
|
||||||
@@ -1026,6 +1118,10 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
|||||||
_OptionCheckBox(context, 'Enable blocking user input',
|
_OptionCheckBox(context, 'Enable blocking user input',
|
||||||
kOptionEnableBlockInput,
|
kOptionEnableBlockInput,
|
||||||
enabled: enabled, fakeValue: fakeValue),
|
enabled: enabled, fakeValue: fakeValue),
|
||||||
|
if (bind.mainSupportedPrivacyModeImpls() != '[]')
|
||||||
|
_OptionCheckBox(
|
||||||
|
context, 'Enable privacy mode', kOptionEnablePrivacyMode,
|
||||||
|
enabled: enabled, fakeValue: fakeValue),
|
||||||
_OptionCheckBox(context, 'Enable remote configuration modification',
|
_OptionCheckBox(context, 'Enable remote configuration modification',
|
||||||
kOptionAllowRemoteConfigModification,
|
kOptionAllowRemoteConfigModification,
|
||||||
enabled: enabled, fakeValue: fakeValue),
|
enabled: enabled, fakeValue: fakeValue),
|
||||||
@@ -1073,8 +1169,13 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
|||||||
if (value ==
|
if (value ==
|
||||||
passwordValues[passwordKeys
|
passwordValues[passwordKeys
|
||||||
.indexOf(kUsePermanentPassword)] &&
|
.indexOf(kUsePermanentPassword)] &&
|
||||||
(await bind.mainGetPermanentPassword())
|
(await bind.mainGetCommon(
|
||||||
.isEmpty) {
|
key: "permanent-password-set")) !=
|
||||||
|
"true") {
|
||||||
|
if (isChangePermanentPasswordDisabled()) {
|
||||||
|
await callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setPasswordDialog(notEmptyCallback: callback);
|
setPasswordDialog(notEmptyCallback: callback);
|
||||||
} else {
|
} else {
|
||||||
await callback();
|
await callback();
|
||||||
@@ -1177,9 +1278,9 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
enabled: tmpEnabled && !locked),
|
enabled: tmpEnabled && !locked),
|
||||||
numericOneTimePassword,
|
if (usePassword) numericOneTimePassword,
|
||||||
if (usePassword) radios[1],
|
if (usePassword) radios[1],
|
||||||
if (usePassword)
|
if (usePassword && !isChangePermanentPasswordDisabled())
|
||||||
_SubButton('Set permanent password', setPasswordDialog,
|
_SubButton('Set permanent password', setPasswordDialog,
|
||||||
permEnabled && !locked),
|
permEnabled && !locked),
|
||||||
// if (usePassword)
|
// if (usePassword)
|
||||||
@@ -1197,12 +1298,16 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
|||||||
reverse: true, enabled: enabled),
|
reverse: true, enabled: enabled),
|
||||||
...directIp(context),
|
...directIp(context),
|
||||||
whitelist(),
|
whitelist(),
|
||||||
|
idWhitelist(),
|
||||||
...autoDisconnect(context),
|
...autoDisconnect(context),
|
||||||
|
_OptionCheckBox(context, 'keep-awake-during-incoming-sessions-label',
|
||||||
|
kOptionKeepAwakeDuringIncomingSessions,
|
||||||
|
reverse: false, enabled: enabled),
|
||||||
if (bind.mainIsInstalled())
|
if (bind.mainIsInstalled())
|
||||||
_OptionCheckBox(context, 'allow-only-conn-window-open-tip',
|
_OptionCheckBox(context, 'allow-only-conn-window-open-tip',
|
||||||
'allow-only-conn-window-open',
|
'allow-only-conn-window-open',
|
||||||
reverse: false, enabled: enabled),
|
reverse: false, enabled: enabled),
|
||||||
if (bind.mainIsInstalled()) unlockPin()
|
if (bind.mainIsInstalled() && !isUnlockPinDisabled()) unlockPin()
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1351,6 +1456,52 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
|||||||
return tmpWrapper();
|
return tmpWrapper();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget idWhitelist() {
|
||||||
|
bool enabled = !locked;
|
||||||
|
RxBool hasIdWhitelist = idWhitelistNotEmpty().obs;
|
||||||
|
update() async {
|
||||||
|
hasIdWhitelist.value = idWhitelistNotEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
onChanged(bool? checked) async {
|
||||||
|
changeIdWhiteList(callback: update);
|
||||||
|
}
|
||||||
|
|
||||||
|
final isOptFixed = isOptionFixed(kOptionIdWhitelist);
|
||||||
|
return GestureDetector(
|
||||||
|
child: Tooltip(
|
||||||
|
message: translate('id_whitelist_tip'),
|
||||||
|
child: Obx(() => Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: hasIdWhitelist.value,
|
||||||
|
onChanged: enabled && !isOptFixed ? onChanged : null)
|
||||||
|
.marginOnly(right: 5),
|
||||||
|
Offstage(
|
||||||
|
offstage: !hasIdWhitelist.value,
|
||||||
|
child: MouseRegion(
|
||||||
|
child: const Icon(Icons.warning_amber_rounded,
|
||||||
|
color: Color.fromARGB(255, 255, 204, 0))
|
||||||
|
.marginOnly(right: 5),
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
translate('Use ID whitelisting'),
|
||||||
|
style: TextStyle(color: disabledTextColor(context, enabled)),
|
||||||
|
))
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
onTap: enabled
|
||||||
|
? () {
|
||||||
|
onChanged(!hasIdWhitelist.value);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
).marginOnly(left: _kCheckBoxLeftMargin);
|
||||||
|
}
|
||||||
|
|
||||||
Widget hide_cm(bool enabled) {
|
Widget hide_cm(bool enabled) {
|
||||||
return ChangeNotifierProvider.value(
|
return ChangeNotifierProvider.value(
|
||||||
value: gFFI.serverModel,
|
value: gFFI.serverModel,
|
||||||
@@ -1585,6 +1736,27 @@ class _NetworkState extends State<_Network> with AutomaticKeepAliveClientMixin {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget switchWidget(IconData icon, String title, String tooltipMessage,
|
||||||
|
String optionKey) =>
|
||||||
|
listTile(
|
||||||
|
icon: icon,
|
||||||
|
title: title,
|
||||||
|
showTooltip: true,
|
||||||
|
tooltipMessage: tooltipMessage,
|
||||||
|
trailing: Switch(
|
||||||
|
value: mainGetBoolOptionSync(optionKey),
|
||||||
|
onChanged: locked || isOptionFixed(optionKey)
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
mainSetBoolOption(optionKey, value);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final outgoingOnly = bind.isOutgoingOnly();
|
||||||
|
|
||||||
|
final divider = const Divider(height: 1, indent: 16, endIndent: 16);
|
||||||
return _Card(
|
return _Card(
|
||||||
title: 'Network',
|
title: 'Network',
|
||||||
children: [
|
children: [
|
||||||
@@ -1596,35 +1768,67 @@ class _NetworkState extends State<_Network> with AutomaticKeepAliveClientMixin {
|
|||||||
listTile(
|
listTile(
|
||||||
icon: Icons.dns_outlined,
|
icon: Icons.dns_outlined,
|
||||||
title: 'ID/Relay Server',
|
title: 'ID/Relay Server',
|
||||||
onTap: () => showServerSettings(gFFI.dialogManager),
|
onTap: () => showServerSettings(gFFI.dialogManager, setState),
|
||||||
),
|
),
|
||||||
if (!hideServer && (!hideProxy || !hideWebSocket))
|
if (!hideProxy && !hideServer) divider,
|
||||||
Divider(height: 1, indent: 16, endIndent: 16),
|
|
||||||
if (!hideProxy)
|
if (!hideProxy)
|
||||||
listTile(
|
listTile(
|
||||||
icon: Icons.network_ping_outlined,
|
icon: Icons.network_ping_outlined,
|
||||||
title: 'Socks5/Http(s) Proxy',
|
title: 'Socks5/Http(s) Proxy',
|
||||||
onTap: changeSocks5Proxy,
|
onTap: changeSocks5Proxy,
|
||||||
),
|
),
|
||||||
if (!hideProxy && !hideWebSocket)
|
if (!hideWebSocket && (!hideServer || !hideProxy)) divider,
|
||||||
Divider(height: 1, indent: 16, endIndent: 16),
|
|
||||||
if (!hideWebSocket)
|
if (!hideWebSocket)
|
||||||
|
switchWidget(
|
||||||
|
Icons.web_asset_outlined,
|
||||||
|
'Use WebSocket',
|
||||||
|
'${translate('websocket_tip')}\n\n${translate('server-oss-not-support-tip')}',
|
||||||
|
kOptionAllowWebSocket),
|
||||||
|
if (!isWeb)
|
||||||
|
futureBuilder(
|
||||||
|
future: bind.mainIsUsingPublicServer(),
|
||||||
|
hasData: (isUsingPublicServer) {
|
||||||
|
if (isUsingPublicServer) {
|
||||||
|
return Offstage();
|
||||||
|
} else {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
if (!hideServer || !hideProxy || !hideWebSocket)
|
||||||
|
divider,
|
||||||
|
switchWidget(
|
||||||
|
Icons.no_encryption_outlined,
|
||||||
|
'Allow insecure TLS fallback',
|
||||||
|
'allow-insecure-tls-fallback-tip',
|
||||||
|
kOptionAllowInsecureTLSFallback),
|
||||||
|
if (!outgoingOnly) divider,
|
||||||
|
if (!outgoingOnly)
|
||||||
listTile(
|
listTile(
|
||||||
icon: Icons.web_asset_outlined,
|
icon: Icons.lan_outlined,
|
||||||
title: 'Use WebSocket',
|
title: 'Disable UDP',
|
||||||
showTooltip: true,
|
showTooltip: true,
|
||||||
tooltipMessage: 'websocket_tip',
|
tooltipMessage:
|
||||||
|
'${translate('disable-udp-tip')}\n\n${translate('server-oss-not-support-tip')}',
|
||||||
trailing: Switch(
|
trailing: Switch(
|
||||||
value: mainGetBoolOptionSync(kOptionAllowWebSocket),
|
value: bind.mainGetOptionSync(
|
||||||
onChanged: locked
|
key: kOptionDisableUdp) ==
|
||||||
|
'Y',
|
||||||
|
onChanged:
|
||||||
|
locked || isOptionFixed(kOptionDisableUdp)
|
||||||
? null
|
? null
|
||||||
: (value) {
|
: (value) async {
|
||||||
mainSetBoolOption(kOptionAllowWebSocket, value);
|
await bind.mainSetOption(
|
||||||
|
key: kOptionDisableUdp,
|
||||||
|
value: value ? 'Y' : 'N');
|
||||||
setState(() {});
|
setState(() {});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1685,6 +1889,13 @@ class _DisplayState extends State<_Display> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final groupValue = bind.mainGetUserDefaultOption(key: kOptionScrollStyle);
|
final groupValue = bind.mainGetUserDefaultOption(key: kOptionScrollStyle);
|
||||||
|
|
||||||
|
onEdgeScrollEdgeThicknessChanged(double value) async {
|
||||||
|
await bind.mainSetUserDefaultOption(
|
||||||
|
key: kOptionEdgeScrollEdgeThickness, value: value.round().toString());
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
return _Card(title: 'Default Scroll Style', children: [
|
return _Card(title: 'Default Scroll Style', children: [
|
||||||
_Radio(context,
|
_Radio(context,
|
||||||
value: kRemoteScrollStyleAuto,
|
value: kRemoteScrollStyleAuto,
|
||||||
@@ -1696,6 +1907,23 @@ class _DisplayState extends State<_Display> {
|
|||||||
groupValue: groupValue,
|
groupValue: groupValue,
|
||||||
label: 'Scrollbar',
|
label: 'Scrollbar',
|
||||||
onChanged: isOptFixed ? null : onChanged),
|
onChanged: isOptFixed ? null : onChanged),
|
||||||
|
if (!isWeb) ...[
|
||||||
|
_Radio(context,
|
||||||
|
value: kRemoteScrollStyleEdge,
|
||||||
|
groupValue: groupValue,
|
||||||
|
label: 'ScrollEdge',
|
||||||
|
onChanged: isOptFixed ? null : onChanged),
|
||||||
|
Offstage(
|
||||||
|
offstage: groupValue != kRemoteScrollStyleEdge,
|
||||||
|
child: EdgeThicknessControl(
|
||||||
|
value: double.tryParse(bind.mainGetUserDefaultOption(
|
||||||
|
key: kOptionEdgeScrollEdgeThickness)) ??
|
||||||
|
100.0,
|
||||||
|
onChanged: isOptionFixed(kOptionEdgeScrollEdgeThickness)
|
||||||
|
? null
|
||||||
|
: onEdgeScrollEdgeThicknessChanged,
|
||||||
|
)),
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1737,8 +1965,8 @@ class _DisplayState extends State<_Display> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget trackpadSpeed(BuildContext context) {
|
Widget trackpadSpeed(BuildContext context) {
|
||||||
final initSpeed = (int.tryParse(
|
final initSpeed =
|
||||||
bind.mainGetUserDefaultOption(key: kKeyTrackpadSpeed)) ??
|
(int.tryParse(bind.mainGetUserDefaultOption(key: kKeyTrackpadSpeed)) ??
|
||||||
kDefaultTrackpadSpeed);
|
kDefaultTrackpadSpeed);
|
||||||
final curSpeed = SimpleWrapper(initSpeed);
|
final curSpeed = SimpleWrapper(initSpeed);
|
||||||
void onDebouncer(int v) {
|
void onDebouncer(int v) {
|
||||||
@@ -1904,7 +2132,9 @@ class _AccountState extends State<_Account> {
|
|||||||
|
|
||||||
Widget accountAction() {
|
Widget accountAction() {
|
||||||
return Obx(() => _Button(
|
return Obx(() => _Button(
|
||||||
gFFI.userModel.userName.value.isEmpty ? 'Login' : 'Logout',
|
gFFI.userModel.userName.value.isEmpty
|
||||||
|
? 'Login'
|
||||||
|
: '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})',
|
||||||
() => {
|
() => {
|
||||||
gFFI.userModel.userName.value.isEmpty
|
gFFI.userModel.userName.value.isEmpty
|
||||||
? loginDialog()
|
? loginDialog()
|
||||||
@@ -1913,24 +2143,65 @@ class _AccountState extends State<_Account> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget useInfo() {
|
Widget useInfo() {
|
||||||
text(String key, String value) {
|
|
||||||
return Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: SelectionArea(child: Text('${translate(key)}: $value'))
|
|
||||||
.marginSymmetric(vertical: 4),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Obx(() => Offstage(
|
return Obx(() => Offstage(
|
||||||
offstage: gFFI.userModel.userName.value.isEmpty,
|
offstage: gFFI.userModel.userName.value.isEmpty,
|
||||||
child: Column(
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Builder(builder: (context) {
|
||||||
|
final avatarWidget = _buildUserAvatar();
|
||||||
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
text('Username', gFFI.userModel.userName.value),
|
if (avatarWidget != null) avatarWidget,
|
||||||
// text('Group', gFFI.groupModel.groupName.value),
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
gFFI.userModel.displayNameOrUserName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
SelectionArea(
|
||||||
|
child: Text(
|
||||||
|
'@${gFFI.userModel.userName.value}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color:
|
||||||
|
Theme.of(context).textTheme.bodySmall?.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
)).marginOnly(left: 18, top: 16);
|
)).marginOnly(left: 18, top: 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget? _buildUserAvatar() {
|
||||||
|
// Resolve relative avatar path at display time
|
||||||
|
final avatar =
|
||||||
|
bind.mainResolveAvatarUrl(avatar: gFFI.userModel.avatar.value);
|
||||||
|
return buildAvatarWidget(
|
||||||
|
avatar: avatar,
|
||||||
|
size: 44,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Checkbox extends StatefulWidget {
|
class _Checkbox extends StatefulWidget {
|
||||||
@@ -2018,7 +2289,9 @@ class _PluginState extends State<_Plugin> {
|
|||||||
|
|
||||||
Widget accountAction() {
|
Widget accountAction() {
|
||||||
return Obx(() => _Button(
|
return Obx(() => _Button(
|
||||||
gFFI.userModel.userName.value.isEmpty ? 'Login' : 'Logout',
|
gFFI.userModel.userName.value.isEmpty
|
||||||
|
? 'Login'
|
||||||
|
: '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})',
|
||||||
() => {
|
() => {
|
||||||
gFFI.userModel.userName.value.isEmpty
|
gFFI.userModel.userName.value.isEmpty
|
||||||
? loginDialog()
|
? loginDialog()
|
||||||
@@ -2189,17 +2462,20 @@ class _AboutState extends State<_About> {
|
|||||||
final version = await bind.mainGetVersion();
|
final version = await bind.mainGetVersion();
|
||||||
final buildDate = await bind.mainGetBuildDate();
|
final buildDate = await bind.mainGetBuildDate();
|
||||||
final fingerprint = await bind.mainGetFingerprint();
|
final fingerprint = await bind.mainGetFingerprint();
|
||||||
|
final myId = await bind.mainGetMyId();
|
||||||
return {
|
return {
|
||||||
'license': license,
|
'license': license,
|
||||||
'version': version,
|
'version': version,
|
||||||
'buildDate': buildDate,
|
'buildDate': buildDate,
|
||||||
'fingerprint': fingerprint
|
'fingerprint': fingerprint,
|
||||||
|
'myId': myId
|
||||||
};
|
};
|
||||||
}(), hasData: (data) {
|
}(), hasData: (data) {
|
||||||
final license = data['license'].toString();
|
final license = data['license'].toString();
|
||||||
final version = data['version'].toString();
|
final version = data['version'].toString();
|
||||||
final buildDate = data['buildDate'].toString();
|
final buildDate = data['buildDate'].toString();
|
||||||
final fingerprint = data['fingerprint'].toString();
|
final fingerprint = data['fingerprint'].toString();
|
||||||
|
final myId = data['myId'].toString();
|
||||||
const linkStyle = TextStyle(decoration: TextDecoration.underline);
|
const linkStyle = TextStyle(decoration: TextDecoration.underline);
|
||||||
final scrollController = ScrollController();
|
final scrollController = ScrollController();
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
@@ -2221,6 +2497,9 @@ class _AboutState extends State<_About> {
|
|||||||
SelectionArea(
|
SelectionArea(
|
||||||
child: Text('${translate('Fingerprint')}: $fingerprint')
|
child: Text('${translate('Fingerprint')}: $fingerprint')
|
||||||
.marginSymmetric(vertical: 4.0)),
|
.marginSymmetric(vertical: 4.0)),
|
||||||
|
SelectionArea(
|
||||||
|
child: Text('${translate('ID')}: $myId')
|
||||||
|
.marginSymmetric(vertical: 4.0)),
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
launchUrlString('https://rustdesk.com/privacy.html');
|
launchUrlString('https://rustdesk.com/privacy.html');
|
||||||
@@ -2249,7 +2528,7 @@ class _AboutState extends State<_About> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Copyright © ${DateTime.now().toString().substring(0, 4)} Purslane Ltd.\n$license',
|
'Copyright © ${DateTime.now().toString().substring(0, 4)} Purslane Tech Pte. Ltd.\n$license',
|
||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
@@ -2426,6 +2705,49 @@ class WaylandCard extends StatefulWidget {
|
|||||||
|
|
||||||
class _WaylandCardState extends State<WaylandCard> {
|
class _WaylandCardState extends State<WaylandCard> {
|
||||||
final restoreTokenKey = 'wayland-restore-token';
|
final restoreTokenKey = 'wayland-restore-token';
|
||||||
|
static const _kClearShortcutsInhibitorEventKey =
|
||||||
|
'clear-gnome-shortcuts-inhibitor-permission-res';
|
||||||
|
final _clearShortcutsInhibitorFailedMsg = ''.obs;
|
||||||
|
// Don't show the shortcuts permission reset button for now.
|
||||||
|
// Users can change it manually:
|
||||||
|
// "Settings" -> "Apps" -> "RustDesk" -> "Permissions" -> "Inhibit Shortcuts".
|
||||||
|
// For resetting(clearing) the permission from the portal permission store, you can
|
||||||
|
// use (replace <desktop-id> with the RustDesk desktop file ID):
|
||||||
|
// busctl --user call org.freedesktop.impl.portal.PermissionStore \
|
||||||
|
// /org/freedesktop/impl/portal/PermissionStore org.freedesktop.impl.portal.PermissionStore \
|
||||||
|
// DeletePermission sss "gnome" "shortcuts-inhibitor" "<desktop-id>"
|
||||||
|
// On a native install this is typically "rustdesk.desktop"; on Flatpak it is usually
|
||||||
|
// the exported desktop ID derived from the Flatpak app-id (e.g. "com.rustdesk.RustDesk.desktop").
|
||||||
|
//
|
||||||
|
// We may add it back in the future if needed.
|
||||||
|
final showResetInhibitorPermission = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (showResetInhibitorPermission) {
|
||||||
|
platformFFI.registerEventHandler(
|
||||||
|
_kClearShortcutsInhibitorEventKey, _kClearShortcutsInhibitorEventKey,
|
||||||
|
(evt) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (evt['success'] == true) {
|
||||||
|
setState(() {});
|
||||||
|
} else {
|
||||||
|
_clearShortcutsInhibitorFailedMsg.value =
|
||||||
|
evt['msg'] as String? ?? 'Unknown error';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
if (showResetInhibitorPermission) {
|
||||||
|
platformFFI.unregisterEventHandler(
|
||||||
|
_kClearShortcutsInhibitorEventKey, _kClearShortcutsInhibitorEventKey);
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -2433,9 +2755,16 @@ class _WaylandCardState extends State<WaylandCard> {
|
|||||||
future: bind.mainHandleWaylandScreencastRestoreToken(
|
future: bind.mainHandleWaylandScreencastRestoreToken(
|
||||||
key: restoreTokenKey, value: "get"),
|
key: restoreTokenKey, value: "get"),
|
||||||
hasData: (restoreToken) {
|
hasData: (restoreToken) {
|
||||||
|
final hasShortcutsPermission = showResetInhibitorPermission &&
|
||||||
|
bind.mainGetCommonSync(
|
||||||
|
key: "has-gnome-shortcuts-inhibitor-permission") ==
|
||||||
|
"true";
|
||||||
|
|
||||||
final children = [
|
final children = [
|
||||||
if (restoreToken.isNotEmpty)
|
if (restoreToken.isNotEmpty)
|
||||||
_buildClearScreenSelection(context, restoreToken),
|
_buildClearScreenSelection(context, restoreToken),
|
||||||
|
if (hasShortcutsPermission)
|
||||||
|
_buildClearShortcutsInhibitorPermission(context),
|
||||||
];
|
];
|
||||||
return Offstage(
|
return Offstage(
|
||||||
offstage: children.isEmpty,
|
offstage: children.isEmpty,
|
||||||
@@ -2480,6 +2809,50 @@ class _WaylandCardState extends State<WaylandCard> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildClearShortcutsInhibitorPermission(BuildContext context) {
|
||||||
|
onConfirm() {
|
||||||
|
_clearShortcutsInhibitorFailedMsg.value = '';
|
||||||
|
bind.mainSetCommon(
|
||||||
|
key: "clear-gnome-shortcuts-inhibitor-permission", value: "");
|
||||||
|
gFFI.dialogManager.dismissAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
showConfirmMsgBox() => msgBoxCommon(
|
||||||
|
gFFI.dialogManager,
|
||||||
|
'Confirmation',
|
||||||
|
Text(
|
||||||
|
translate('confirm-clear-shortcuts-inhibitor-permission-tip'),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
dialogButton('OK', onPressed: onConfirm),
|
||||||
|
dialogButton('Cancel',
|
||||||
|
onPressed: () => gFFI.dialogManager.dismissAll())
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Column(children: [
|
||||||
|
Obx(
|
||||||
|
() => _clearShortcutsInhibitorFailedMsg.value.isEmpty
|
||||||
|
? Offstage()
|
||||||
|
: Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: Text(_clearShortcutsInhibitorFailedMsg.value,
|
||||||
|
style: DefaultTextStyle.of(context)
|
||||||
|
.style
|
||||||
|
.copyWith(color: Colors.red))
|
||||||
|
.marginOnly(bottom: 10.0)),
|
||||||
|
),
|
||||||
|
_Button(
|
||||||
|
'Reset keyboard shortcuts permission',
|
||||||
|
showConfirmMsgBox,
|
||||||
|
tip: 'clear-shortcuts-inhibitor-permission-tip',
|
||||||
|
style: ButtonStyle(
|
||||||
|
backgroundColor: MaterialStateProperty.all<Color>(
|
||||||
|
Theme.of(context).colorScheme.error.withOpacity(0.75)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ignore: non_constant_identifier_names
|
// ignore: non_constant_identifier_names
|
||||||
@@ -2561,7 +2934,7 @@ Widget _lock(
|
|||||||
]).marginSymmetric(vertical: 2)),
|
]).marginSymmetric(vertical: 2)),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final unlockPin = bind.mainGetUnlockPin();
|
final unlockPin = bind.mainGetUnlockPin();
|
||||||
if (unlockPin.isEmpty) {
|
if (unlockPin.isEmpty || isUnlockPinDisabled()) {
|
||||||
bool checked = await callMainCheckSuperUserPermission();
|
bool checked = await callMainCheckSuperUserPermission();
|
||||||
if (checked) {
|
if (checked) {
|
||||||
onUnlock();
|
onUnlock();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:extended_text/extended_text.dart';
|
import 'package:extended_text/extended_text.dart';
|
||||||
|
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||||
import 'package:flutter_hbb/desktop/widgets/dragable_divider.dart';
|
import 'package:flutter_hbb/desktop/widgets/dragable_divider.dart';
|
||||||
import 'package:percent_indicator/percent_indicator.dart';
|
import 'package:percent_indicator/percent_indicator.dart';
|
||||||
import 'package:desktop_drop/desktop_drop.dart';
|
import 'package:desktop_drop/desktop_drop.dart';
|
||||||
@@ -16,7 +17,6 @@ import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
|||||||
import 'package:flutter_hbb/models/file_model.dart';
|
import 'package:flutter_hbb/models/file_model.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
||||||
import 'package:flutter_hbb/web/dummy.dart'
|
import 'package:flutter_hbb/web/dummy.dart'
|
||||||
if (dart.library.html) 'package:flutter_hbb/web/web_unique.dart';
|
if (dart.library.html) 'package:flutter_hbb/web/web_unique.dart';
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ enum MouseFocusScope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class FileManagerPage extends StatefulWidget {
|
class FileManagerPage extends StatefulWidget {
|
||||||
const FileManagerPage(
|
FileManagerPage(
|
||||||
{Key? key,
|
{Key? key,
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.password,
|
required this.password,
|
||||||
@@ -67,9 +67,16 @@ class FileManagerPage extends StatefulWidget {
|
|||||||
final bool? forceRelay;
|
final bool? forceRelay;
|
||||||
final String? connToken;
|
final String? connToken;
|
||||||
final DesktopTabController? tabController;
|
final DesktopTabController? tabController;
|
||||||
|
final SimpleWrapper<State<FileManagerPage>?> _lastState = SimpleWrapper(null);
|
||||||
|
|
||||||
|
FFI get ffi => (_lastState.value! as _FileManagerPageState)._ffi;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<StatefulWidget> createState() => _FileManagerPageState();
|
State<StatefulWidget> createState() {
|
||||||
|
final state = _FileManagerPageState();
|
||||||
|
_lastState.value = state;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FileManagerPageState extends State<FileManagerPage>
|
class _FileManagerPageState extends State<FileManagerPage>
|
||||||
@@ -78,6 +85,7 @@ class _FileManagerPageState extends State<FileManagerPage>
|
|||||||
|
|
||||||
final _dropMaskVisible = false.obs; // TODO impl drop mask
|
final _dropMaskVisible = false.obs; // TODO impl drop mask
|
||||||
final _overlayKeyState = OverlayKeyState();
|
final _overlayKeyState = OverlayKeyState();
|
||||||
|
final _uniqueKey = UniqueKey();
|
||||||
|
|
||||||
late FFI _ffi;
|
late FFI _ffi;
|
||||||
|
|
||||||
@@ -99,9 +107,7 @@ class _FileManagerPageState extends State<FileManagerPage>
|
|||||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||||
});
|
});
|
||||||
Get.put<FFI>(_ffi, tag: 'ft_${widget.id}');
|
Get.put<FFI>(_ffi, tag: 'ft_${widget.id}');
|
||||||
if (!isLinux) {
|
WakelockManager.enable(_uniqueKey);
|
||||||
WakelockPlus.enable();
|
|
||||||
}
|
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
|
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
|
||||||
}
|
}
|
||||||
@@ -119,9 +125,7 @@ class _FileManagerPageState extends State<FileManagerPage>
|
|||||||
model.close().whenComplete(() {
|
model.close().whenComplete(() {
|
||||||
_ffi.close();
|
_ffi.close();
|
||||||
_ffi.dialogManager.dismissAll();
|
_ffi.dialogManager.dismissAll();
|
||||||
if (!isLinux) {
|
WakelockManager.disable(_uniqueKey);
|
||||||
WakelockPlus.disable();
|
|
||||||
}
|
|
||||||
Get.delete<FFI>(tag: 'ft_${widget.id}');
|
Get.delete<FFI>(tag: 'ft_${widget.id}');
|
||||||
});
|
});
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
@@ -139,12 +143,26 @@ class _FileManagerPageState extends State<FileManagerPage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget willPopScope(Widget child) {
|
||||||
|
if (isWeb) {
|
||||||
|
return WillPopScope(
|
||||||
|
onWillPop: () async {
|
||||||
|
clientClose(_ffi.sessionId, _ffi);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
return Overlay(key: _overlayKeyState.key, initialEntries: [
|
return Overlay(key: _overlayKeyState.key, initialEntries: [
|
||||||
OverlayEntry(builder: (_) {
|
OverlayEntry(builder: (_) {
|
||||||
return Scaffold(
|
return willPopScope(Scaffold(
|
||||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
body: Row(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -160,7 +178,7 @@ class _FileManagerPageState extends State<FileManagerPage>
|
|||||||
Flexible(flex: 2, child: statusList())
|
Flexible(flex: 2, child: statusList())
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
));
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -260,11 +278,9 @@ class _FileManagerPageState extends State<FileManagerPage>
|
|||||||
item.state != JobState.inProgress,
|
item.state != JobState.inProgress,
|
||||||
child: LinearPercentIndicator(
|
child: LinearPercentIndicator(
|
||||||
animateFromLastPercent: true,
|
animateFromLastPercent: true,
|
||||||
center: Text(
|
center: Text(item.percentText),
|
||||||
'${(item.finishedSize / item.totalSize * 100).toStringAsFixed(0)}%',
|
|
||||||
),
|
|
||||||
barRadius: Radius.circular(15),
|
barRadius: Radius.circular(15),
|
||||||
percent: item.finishedSize / item.totalSize,
|
percent: item.percent,
|
||||||
progressColor: MyTheme.accent,
|
progressColor: MyTheme.accent,
|
||||||
backgroundColor: Theme.of(context).hoverColor,
|
backgroundColor: Theme.of(context).hoverColor,
|
||||||
lineHeight: kDesktopFileTransferRowHeight,
|
lineHeight: kDesktopFileTransferRowHeight,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
|||||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hbb/common.dart';
|
import 'package:flutter_hbb/common.dart';
|
||||||
|
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||||
import 'package:flutter_hbb/consts.dart';
|
import 'package:flutter_hbb/consts.dart';
|
||||||
import 'package:flutter_hbb/models/state_model.dart';
|
import 'package:flutter_hbb/models/state_model.dart';
|
||||||
import 'package:flutter_hbb/desktop/pages/file_manager_page.dart';
|
import 'package:flutter_hbb/desktop/pages/file_manager_page.dart';
|
||||||
@@ -40,7 +41,15 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
|
|||||||
label: params['id'],
|
label: params['id'],
|
||||||
selectedIcon: selectedIcon,
|
selectedIcon: selectedIcon,
|
||||||
unselectedIcon: unselectedIcon,
|
unselectedIcon: unselectedIcon,
|
||||||
onTabCloseButton: () => tabController.closeBy(params['id']),
|
onTabCloseButton: () async {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: params['id'],
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabController.closeBy(params['id']);
|
||||||
|
},
|
||||||
page: FileManagerPage(
|
page: FileManagerPage(
|
||||||
key: ValueKey(params['id']),
|
key: ValueKey(params['id']),
|
||||||
id: params['id'],
|
id: params['id'],
|
||||||
@@ -69,7 +78,15 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
|
|||||||
label: id,
|
label: id,
|
||||||
selectedIcon: selectedIcon,
|
selectedIcon: selectedIcon,
|
||||||
unselectedIcon: unselectedIcon,
|
unselectedIcon: unselectedIcon,
|
||||||
onTabCloseButton: () => tabController.closeBy(id),
|
onTabCloseButton: () async {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: id,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabController.closeBy(id);
|
||||||
|
},
|
||||||
page: FileManagerPage(
|
page: FileManagerPage(
|
||||||
key: ValueKey(id),
|
key: ValueKey(id),
|
||||||
id: id,
|
id: id,
|
||||||
@@ -132,6 +149,14 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
|
|||||||
|
|
||||||
Future<bool> handleWindowCloseButton() async {
|
Future<bool> handleWindowCloseButton() async {
|
||||||
final connLength = tabController.state.value.tabs.length;
|
final connLength = tabController.state.value.tabs.length;
|
||||||
|
if (connLength == 1) {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: tabController.state.value.tabs[0].key,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (connLength <= 1) {
|
if (connLength <= 1) {
|
||||||
tabController.clear();
|
tabController.clear();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class _InstallPageBodyState extends State<_InstallPageBody>
|
|||||||
late final TextEditingController controller;
|
late final TextEditingController controller;
|
||||||
final RxBool startmenu = true.obs;
|
final RxBool startmenu = true.obs;
|
||||||
final RxBool desktopicon = true.obs;
|
final RxBool desktopicon = true.obs;
|
||||||
final RxBool printer = true.obs;
|
final RxBool printer = false.obs;
|
||||||
final RxBool showProgress = false.obs;
|
final RxBool showProgress = false.obs;
|
||||||
final RxBool btnEnabled = true.obs;
|
final RxBool btnEnabled = true.obs;
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ class _InstallPageBodyState extends State<_InstallPageBody>
|
|||||||
final installOptions = jsonDecode(bind.installInstallOptions());
|
final installOptions = jsonDecode(bind.installInstallOptions());
|
||||||
startmenu.value = installOptions['STARTMENUSHORTCUTS'] != '0';
|
startmenu.value = installOptions['STARTMENUSHORTCUTS'] != '0';
|
||||||
desktopicon.value = installOptions['DESKTOPSHORTCUTS'] != '0';
|
desktopicon.value = installOptions['DESKTOPSHORTCUTS'] != '0';
|
||||||
printer.value = installOptions['PRINTER'] != '0';
|
printer.value = installOptions['PRINTER'] == '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
class MacOSFullScreenFocusRecovery {
|
||||||
|
int _generation = 0;
|
||||||
|
int? _pendingGeneration;
|
||||||
|
|
||||||
|
int? get pendingGeneration => _pendingGeneration;
|
||||||
|
|
||||||
|
int queue() {
|
||||||
|
_generation += 1;
|
||||||
|
_pendingGeneration = _generation;
|
||||||
|
return _generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancel() {
|
||||||
|
_pendingGeneration = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isCurrent(int generation) => _pendingGeneration == generation;
|
||||||
|
|
||||||
|
bool consume(int generation) {
|
||||||
|
if (!isCurrent(generation)) return false;
|
||||||
|
_pendingGeneration = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ class _PortForward {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class PortForwardPage extends StatefulWidget {
|
class PortForwardPage extends StatefulWidget {
|
||||||
const PortForwardPage({
|
PortForwardPage({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.password,
|
required this.password,
|
||||||
@@ -42,9 +42,16 @@ class PortForwardPage extends StatefulWidget {
|
|||||||
final bool? forceRelay;
|
final bool? forceRelay;
|
||||||
final bool? isSharedPassword;
|
final bool? isSharedPassword;
|
||||||
final String? connToken;
|
final String? connToken;
|
||||||
|
final SimpleWrapper<State<PortForwardPage>?> _lastState = SimpleWrapper(null);
|
||||||
|
|
||||||
|
FFI get ffi => (_lastState.value! as _PortForwardPageState)._ffi;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PortForwardPage> createState() => _PortForwardPageState();
|
State<PortForwardPage> createState() {
|
||||||
|
final state = _PortForwardPageState();
|
||||||
|
_lastState.value = state;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PortForwardPageState extends State<PortForwardPage>
|
class _PortForwardPageState extends State<PortForwardPage>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import 'dart:async';
|
|||||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
||||||
import 'package:flutter_hbb/models/state_model.dart';
|
import 'package:flutter_hbb/models/state_model.dart';
|
||||||
|
|
||||||
import '../../consts.dart';
|
import '../../consts.dart';
|
||||||
@@ -15,12 +15,15 @@ import '../../common.dart';
|
|||||||
import '../../common/widgets/dialog.dart';
|
import '../../common/widgets/dialog.dart';
|
||||||
import '../../common/widgets/toolbar.dart';
|
import '../../common/widgets/toolbar.dart';
|
||||||
import '../../models/model.dart';
|
import '../../models/model.dart';
|
||||||
|
import '../../models/input_model.dart';
|
||||||
import '../../models/platform_model.dart';
|
import '../../models/platform_model.dart';
|
||||||
import '../../common/shared_state.dart';
|
import '../../common/shared_state.dart';
|
||||||
import '../../utils/image.dart';
|
import '../../utils/image.dart';
|
||||||
import '../widgets/remote_toolbar.dart';
|
import '../widgets/remote_toolbar.dart';
|
||||||
import '../widgets/kb_layout_type_chooser.dart';
|
import '../widgets/kb_layout_type_chooser.dart';
|
||||||
|
import '../widgets/raster_stall_monitor.dart';
|
||||||
import '../widgets/tabbar_widget.dart';
|
import '../widgets/tabbar_widget.dart';
|
||||||
|
import 'macos_full_screen_focus_recovery.dart';
|
||||||
|
|
||||||
import 'package:flutter_hbb/native/custom_cursor.dart'
|
import 'package:flutter_hbb/native/custom_cursor.dart'
|
||||||
if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart';
|
if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart';
|
||||||
@@ -63,6 +66,13 @@ class RemotePage extends StatefulWidget {
|
|||||||
|
|
||||||
FFI get ffi => (_lastState.value! as _RemotePageState)._ffi;
|
FFI get ffi => (_lastState.value! as _RemotePageState)._ffi;
|
||||||
|
|
||||||
|
void releaseMacOSInputForTabTransfer() {
|
||||||
|
if (!isMacOS) return;
|
||||||
|
// Release before removing the source tab. Its delayed disposal must not
|
||||||
|
// disable a native keyboard hook already acquired by the destination page.
|
||||||
|
(_lastState.value! as _RemotePageState)._releaseMacOSRemoteInput();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<RemotePage> createState() {
|
State<RemotePage> createState() {
|
||||||
final state = _RemotePageState(id);
|
final state = _RemotePageState(id);
|
||||||
@@ -72,26 +82,55 @@ class RemotePage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _RemotePageState extends State<RemotePage>
|
class _RemotePageState extends State<RemotePage>
|
||||||
with AutomaticKeepAliveClientMixin, MultiWindowListener {
|
with
|
||||||
|
AutomaticKeepAliveClientMixin,
|
||||||
|
MultiWindowListener,
|
||||||
|
WidgetsBindingObserver,
|
||||||
|
TickerProviderStateMixin {
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
String keyboardMode = "legacy";
|
String keyboardMode = "legacy";
|
||||||
bool _isWindowBlur = false;
|
bool _isWindowBlur = false;
|
||||||
|
// Known macOS remote-input trade-offs (kept simple intentionally):
|
||||||
|
// 1. Dialogs rely on FocusNode loss plus middleBlocked, not mirrored dialog
|
||||||
|
// state. Reproduce: activate remote input, open a dialog, then type.
|
||||||
|
// 2. Delayed fullscreen recovery can race a local-control focus change; no
|
||||||
|
// owner state is added. Reproduce: focus the toolbar during a Space switch.
|
||||||
|
// 3. Input-source switching releases native input without updating this
|
||||||
|
// page's cache. Reproduce: switch sources, then type before and after
|
||||||
|
// clicking the remote image; the click reasserts input.
|
||||||
|
// These latches compensate for out-of-order macOS focus events. Treat them
|
||||||
|
// as coupled when changing a transition or _syncMacOSKeyboardGrab().
|
||||||
|
AppLifecycleState? _macOSLifecycleState;
|
||||||
|
bool _macOSLocalFocusLost = false;
|
||||||
|
bool _macOSInputActive = false;
|
||||||
|
bool _macOSInputSuppressed = false;
|
||||||
|
final _macOSFullScreenFocusRecovery = MacOSFullScreenFocusRecovery();
|
||||||
|
bool _macOSExplicitFocusRequestPending = false;
|
||||||
|
StreamSubscription<DesktopTabState>? _tabStateSubscription;
|
||||||
final _cursorOverImage = false.obs;
|
final _cursorOverImage = false.obs;
|
||||||
late RxBool _showRemoteCursor;
|
late RxBool _showRemoteCursor;
|
||||||
late RxBool _zoomCursor;
|
late RxBool _zoomCursor;
|
||||||
late RxBool _remoteCursorMoved;
|
late RxBool _remoteCursorMoved;
|
||||||
late RxBool _keyboardEnabled;
|
late RxBool _keyboardEnabled;
|
||||||
|
final _uniqueKey = UniqueKey();
|
||||||
|
|
||||||
var _blockableOverlayState = BlockableOverlayState();
|
var _blockableOverlayState = BlockableOverlayState();
|
||||||
|
|
||||||
final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode");
|
final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode");
|
||||||
|
|
||||||
|
// Debounce timer for pointer lock center updates during window events.
|
||||||
|
// Uses kDefaultPointerLockCenterThrottleMs from consts.dart for the duration.
|
||||||
|
Timer? _pointerLockCenterDebounceTimer;
|
||||||
|
|
||||||
// We need `_instanceIdOnEnterOrLeaveImage4Toolbar` together with `_onEnterOrLeaveImage4Toolbar`
|
// We need `_instanceIdOnEnterOrLeaveImage4Toolbar` together with `_onEnterOrLeaveImage4Toolbar`
|
||||||
// to identify the toolbar instance and its callback function.
|
// to identify the toolbar instance and its callback function.
|
||||||
int? _instanceIdOnEnterOrLeaveImage4Toolbar;
|
int? _instanceIdOnEnterOrLeaveImage4Toolbar;
|
||||||
Function(bool)? _onEnterOrLeaveImage4Toolbar;
|
Function(bool)? _onEnterOrLeaveImage4Toolbar;
|
||||||
|
|
||||||
late FFI _ffi;
|
late FFI _ffi;
|
||||||
|
Worker? _waylandKeyboardModeWorker;
|
||||||
|
bool _waylandKeyboardModeNormalized = false;
|
||||||
|
bool _waylandKeyboardModeNormalizing = false;
|
||||||
|
|
||||||
SessionID get sessionId => _ffi.sessionId;
|
SessionID get sessionId => _ffi.sessionId;
|
||||||
|
|
||||||
@@ -110,13 +149,23 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_ffi = FFI(widget.sessionId);
|
_ffi = FFI(widget.sessionId);
|
||||||
|
if (isMacOS) {
|
||||||
|
// SchedulerBinding.instance.lifecycleState is null in the first connection in a new window.
|
||||||
|
_macOSLifecycleState = SchedulerBinding.instance.lifecycleState;
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
_tabStateSubscription =
|
||||||
|
widget.tabController?.state.listen(_onMacOSTabStateChanged);
|
||||||
|
}
|
||||||
Get.put<FFI>(_ffi, tag: widget.id);
|
Get.put<FFI>(_ffi, tag: widget.id);
|
||||||
|
RasterStallMonitor.start();
|
||||||
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
|
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
|
||||||
|
_ffi.canvasModel.activateLocalCursor();
|
||||||
showKBLayoutTypeChooserIfNeeded(
|
showKBLayoutTypeChooserIfNeeded(
|
||||||
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
|
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
|
||||||
_ffi.recordingModel
|
_ffi.recordingModel
|
||||||
.updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId));
|
.updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId));
|
||||||
});
|
});
|
||||||
|
_ffi.canvasModel.initializeEdgeScrollFallback(this);
|
||||||
_ffi.start(
|
_ffi.start(
|
||||||
widget.id,
|
widget.id,
|
||||||
password: widget.password,
|
password: widget.password,
|
||||||
@@ -132,9 +181,7 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
_ffi.dialogManager
|
_ffi.dialogManager
|
||||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||||
});
|
});
|
||||||
if (!isLinux) {
|
WakelockManager.enable(_uniqueKey);
|
||||||
WakelockPlus.enable();
|
|
||||||
}
|
|
||||||
|
|
||||||
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
|
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
|
||||||
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
|
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
|
||||||
@@ -165,6 +212,254 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
widget.tabController?.onSelected?.call(widget.id);
|
widget.tabController?.onSelected?.call(widget.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register callback to cancel debounce timer when relative mouse mode is disabled
|
||||||
|
_ffi.inputModel.onRelativeMouseModeDisabled =
|
||||||
|
_cancelPointerLockCenterDebounceTimer;
|
||||||
|
|
||||||
|
_waylandKeyboardModeWorker = ever(_ffi.ffiModel.pi.isSet, (bool isSet) {
|
||||||
|
if (isSet) {
|
||||||
|
unawaited(_normalizeWaylandKeyboardModeIfNeeded());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (_ffi.ffiModel.pi.isSet.value) {
|
||||||
|
unawaited(_normalizeWaylandKeyboardModeIfNeeded());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _normalizeWaylandKeyboardModeIfNeeded() async {
|
||||||
|
if (!mounted ||
|
||||||
|
_waylandKeyboardModeNormalized ||
|
||||||
|
_waylandKeyboardModeNormalizing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_waylandKeyboardModeNormalizing = true;
|
||||||
|
try {
|
||||||
|
final pi = _ffi.ffiModel.pi;
|
||||||
|
if (pi.platform != kPeerPlatformLinux || !pi.isWayland) return;
|
||||||
|
final mapSupported = bind.sessionIsKeyboardModeSupported(
|
||||||
|
sessionId: sessionId, mode: kKeyMapMode);
|
||||||
|
if (!mapSupported) return;
|
||||||
|
final current = await bind.sessionGetKeyboardMode(sessionId: sessionId);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (current == kKeyMapMode) {
|
||||||
|
_waylandKeyboardModeNormalized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await bind.sessionSetKeyboardMode(
|
||||||
|
sessionId: sessionId, value: kKeyMapMode);
|
||||||
|
if (!mounted) return;
|
||||||
|
await _ffi.inputModel.updateKeyboardMode();
|
||||||
|
if (!mounted) return;
|
||||||
|
_waylandKeyboardModeNormalized = true;
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('Failed to normalize Wayland keyboard mode: $e');
|
||||||
|
debugPrintStack(stackTrace: st);
|
||||||
|
} finally {
|
||||||
|
_waylandKeyboardModeNormalizing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel the pointer lock center debounce timer
|
||||||
|
void _cancelPointerLockCenterDebounceTimer() {
|
||||||
|
_pointerLockCenterDebounceTimer?.cancel();
|
||||||
|
_pointerLockCenterDebounceTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isSelectedTab {
|
||||||
|
final controller = widget.tabController;
|
||||||
|
if (controller == null) return true;
|
||||||
|
final tabState = controller.state.value;
|
||||||
|
final selected = tabState.selected;
|
||||||
|
return selected >= 0 &&
|
||||||
|
selected < tabState.tabs.length &&
|
||||||
|
tabState.tabs[selected].key == widget.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isMacOSKeyboardContextActive {
|
||||||
|
return stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMacOSTabStateChanged(DesktopTabState _) {
|
||||||
|
if (!_isSelectedTab) {
|
||||||
|
_macOSFullScreenFocusRecovery.cancel();
|
||||||
|
_syncMacOSKeyboardGrab();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Tab listeners run synchronously. Defer the selected page so the previous
|
||||||
|
// page releases first; a late leave from it can disable the new session.
|
||||||
|
scheduleMicrotask(() {
|
||||||
|
if (mounted) {
|
||||||
|
_syncMacOSKeyboardGrab(reassert: true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _releaseMacOSRemoteInput() {
|
||||||
|
_macOSFullScreenFocusRecovery.cancel();
|
||||||
|
_macOSExplicitFocusRequestPending = false;
|
||||||
|
_macOSInputSuppressed = true;
|
||||||
|
_macOSLocalFocusLost = true;
|
||||||
|
_ffi.inputModel.enterOrLeave(false);
|
||||||
|
_macOSInputActive = false;
|
||||||
|
_rawKeyFocusNode.unfocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMacOSFocusChange() {
|
||||||
|
// requestFocus() notifies later; only a recorded explicit request may clear
|
||||||
|
// the local-focus-loss latch.
|
||||||
|
if (_rawKeyFocusNode.hasPrimaryFocus) {
|
||||||
|
final explicitRequest = _macOSExplicitFocusRequestPending;
|
||||||
|
_macOSExplicitFocusRequestPending = false;
|
||||||
|
if (explicitRequest && _isMacOSKeyboardContextActive) {
|
||||||
|
_macOSLocalFocusLost = false;
|
||||||
|
}
|
||||||
|
_syncMacOSKeyboardGrab(allowInactiveLifecycle: explicitRequest);
|
||||||
|
} else {
|
||||||
|
if (_macOSInputActive) {
|
||||||
|
_ffi.inputModel.enterOrLeave(false);
|
||||||
|
_macOSInputActive = false;
|
||||||
|
}
|
||||||
|
if (_isMacOSKeyboardContextActive) {
|
||||||
|
_macOSLocalFocusLost = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Sync the keyboard grab state with the current context.
|
||||||
|
// 2. Call enterOrLeave() to update the input state in the FFI layer.
|
||||||
|
// 3. Request or unfocus the raw key focus node based on the current context.
|
||||||
|
// Flutter focus and native input are separate; native input activates only
|
||||||
|
// after the FocusNode has primary focus.
|
||||||
|
void _syncMacOSKeyboardGrab({
|
||||||
|
bool reassert = false,
|
||||||
|
bool allowInactiveLifecycle = false,
|
||||||
|
}) {
|
||||||
|
if (!isMacOS) return;
|
||||||
|
// A secondary engine may stay hidden while its window is visible, so
|
||||||
|
// explicit pointer/fullscreen recovery must bypass the global lifecycle.
|
||||||
|
final lifecycleAllowsInput = allowInactiveLifecycle ||
|
||||||
|
_macOSLifecycleState == null ||
|
||||||
|
_macOSLifecycleState == AppLifecycleState.resumed;
|
||||||
|
// Input stays pointer-gated except for focused fullscreen recovery, which
|
||||||
|
// compensates when macOS omits PointerEnter during a Space switch.
|
||||||
|
final shouldFocus = lifecycleAllowsInput &&
|
||||||
|
_isMacOSKeyboardContextActive &&
|
||||||
|
!_macOSInputSuppressed &&
|
||||||
|
_blockableOverlayState.middleBlocked.isFalse &&
|
||||||
|
_cursorOverImage.value &&
|
||||||
|
!_macOSLocalFocusLost;
|
||||||
|
final hasFocus = _rawKeyFocusNode.hasPrimaryFocus;
|
||||||
|
final shouldActivateInput = shouldFocus && hasFocus;
|
||||||
|
|
||||||
|
if (shouldActivateInput != _macOSInputActive ||
|
||||||
|
(shouldActivateInput && reassert)) {
|
||||||
|
_ffi.inputModel.enterOrLeave(shouldActivateInput);
|
||||||
|
}
|
||||||
|
_macOSInputActive = shouldActivateInput;
|
||||||
|
|
||||||
|
if (!shouldFocus) {
|
||||||
|
_macOSExplicitFocusRequestPending = false;
|
||||||
|
if (hasFocus) _rawKeyFocusNode.unfocus();
|
||||||
|
} else if (!hasFocus) {
|
||||||
|
_macOSExplicitFocusRequestPending = allowInactiveLifecycle;
|
||||||
|
_rawKeyFocusNode.requestFocus();
|
||||||
|
} else {
|
||||||
|
_macOSExplicitFocusRequestPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _restoreMacOSKeyboardAfterFullScreen({
|
||||||
|
required int generation,
|
||||||
|
bool allowHiddenLifecycle = false,
|
||||||
|
}) {
|
||||||
|
// Fullscreen callbacks preserve recovery while hidden. Native window focus
|
||||||
|
// may bypass a stale hidden lifecycle for the newly visible Space.
|
||||||
|
if (!_macOSFullScreenFocusRecovery.isCurrent(generation) ||
|
||||||
|
(!allowHiddenLifecycle &&
|
||||||
|
_macOSLifecycleState == AppLifecycleState.hidden)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final contextActive =
|
||||||
|
stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab;
|
||||||
|
// macOS can focus a fullscreen Space without sending PointerEnter. Native
|
||||||
|
// window focus is authoritative here; a later blur cancels this generation
|
||||||
|
// before an off-screen window can restore input.
|
||||||
|
final shouldInferPointerInside = !_cursorOverImage.value &&
|
||||||
|
allowHiddenLifecycle &&
|
||||||
|
stateGlobal.fullscreen.isTrue &&
|
||||||
|
contextActive;
|
||||||
|
final canRestore = contextActive &&
|
||||||
|
_blockableOverlayState.middleBlocked.isFalse &&
|
||||||
|
(_cursorOverImage.value || shouldInferPointerInside);
|
||||||
|
if (!_macOSFullScreenFocusRecovery.consume(generation)) return;
|
||||||
|
if (!canRestore) {
|
||||||
|
// Consuming recovery here requires a later pointer/window/tab event.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shouldInferPointerInside) {
|
||||||
|
_cursorOverImage.value = true;
|
||||||
|
}
|
||||||
|
_macOSLocalFocusLost = false;
|
||||||
|
stateGlobal.getInputSource(force: true);
|
||||||
|
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleMacOSKeyboardAfterFullScreen({
|
||||||
|
required int generation,
|
||||||
|
bool allowHiddenLifecycle = false,
|
||||||
|
}) {
|
||||||
|
// Fullscreen can deliver FocusNode loss after its callback; wait for frame
|
||||||
|
// completion and then advance one event-loop turn before restoring.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
Timer.run(() {
|
||||||
|
if (mounted) {
|
||||||
|
_restoreMacOSKeyboardAfterFullScreen(
|
||||||
|
generation: generation,
|
||||||
|
allowHiddenLifecycle: allowHiddenLifecycle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.ensureVisualUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _queueMacOSKeyboardAfterFullScreen({
|
||||||
|
bool allowHiddenLifecycle = false,
|
||||||
|
}) {
|
||||||
|
final generation = _macOSFullScreenFocusRecovery.queue();
|
||||||
|
if (_macOSLifecycleState == AppLifecycleState.paused ||
|
||||||
|
_macOSLifecycleState == AppLifecycleState.detached) {
|
||||||
|
_macOSFullScreenFocusRecovery.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_scheduleMacOSKeyboardAfterFullScreen(
|
||||||
|
generation: generation,
|
||||||
|
allowHiddenLifecycle: allowHiddenLifecycle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
super.didChangeAppLifecycleState(state);
|
||||||
|
if (!isMacOS || _macOSLifecycleState == state) return;
|
||||||
|
_macOSLifecycleState = state;
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
_syncMacOSKeyboardGrab(reassert: true);
|
||||||
|
} else if (_macOSInputActive) {
|
||||||
|
_ffi.inputModel.enterOrLeave(false);
|
||||||
|
_macOSInputActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final generation = _macOSFullScreenFocusRecovery.pendingGeneration;
|
||||||
|
if (generation == null) return;
|
||||||
|
if (state == AppLifecycleState.inactive ||
|
||||||
|
state == AppLifecycleState.resumed) {
|
||||||
|
_scheduleMacOSKeyboardAfterFullScreen(generation: generation);
|
||||||
|
} else if (state == AppLifecycleState.paused ||
|
||||||
|
state == AppLifecycleState.detached) {
|
||||||
|
_macOSFullScreenFocusRecovery.cancel();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -172,24 +467,71 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
super.onWindowBlur();
|
super.onWindowBlur();
|
||||||
// On windows, we use `focus` way to handle keyboard better.
|
// On windows, we use `focus` way to handle keyboard better.
|
||||||
// Now on Linux, there's some rdev issues which will break the input.
|
// Now on Linux, there's some rdev issues which will break the input.
|
||||||
// We disable the `focus` way for non-Windows temporarily.
|
// We disable the `focus` way for Linux temporarily.
|
||||||
if (isWindows) {
|
if (isWindows || isMacOS) {
|
||||||
_isWindowBlur = true;
|
_isWindowBlur = true;
|
||||||
|
}
|
||||||
|
if (isMacOS) {
|
||||||
|
_macOSFullScreenFocusRecovery.cancel();
|
||||||
|
// A blur or Space switch may not emit PointerExit, so cursor state alone
|
||||||
|
// cannot prevent the old remote surface from reclaiming the keyboard.
|
||||||
|
_macOSLocalFocusLost = true;
|
||||||
|
}
|
||||||
|
if (isWindows) {
|
||||||
// unfocus the primary-focus when the whole window is lost focus,
|
// unfocus the primary-focus when the whole window is lost focus,
|
||||||
// and let OS to handle events instead.
|
// and let OS to handle events instead.
|
||||||
_rawKeyFocusNode.unfocus();
|
_rawKeyFocusNode.unfocus();
|
||||||
}
|
}
|
||||||
stateGlobal.isFocused.value = false;
|
stateGlobal.isFocused.value = false;
|
||||||
|
_syncMacOSKeyboardGrab();
|
||||||
|
|
||||||
|
// When window loses focus, temporarily release relative mouse mode constraints
|
||||||
|
// to allow user to interact with other applications normally.
|
||||||
|
// The cursor will be re-hidden and re-centered when window regains focus.
|
||||||
|
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||||
|
_ffi.inputModel.onWindowBlur();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onWindowFocus() {
|
void onWindowFocus() {
|
||||||
super.onWindowFocus();
|
super.onWindowFocus();
|
||||||
// See [onWindowBlur].
|
// See [onWindowBlur].
|
||||||
if (isWindows) {
|
if (isWindows || isMacOS) {
|
||||||
_isWindowBlur = false;
|
_isWindowBlur = false;
|
||||||
}
|
}
|
||||||
|
if (isMacOS) stateGlobal.getInputSource(force: true);
|
||||||
stateGlobal.isFocused.value = true;
|
stateGlobal.isFocused.value = true;
|
||||||
|
|
||||||
|
// Normal macOS windows wait for PointerEnter or PointerDown. A focused
|
||||||
|
// fullscreen Space queues delayed recovery; if this window blurs again, the
|
||||||
|
// pending recovery is cancelled before native input can reactivate.
|
||||||
|
// Regression: switch directly between fullscreen remote Spaces without
|
||||||
|
// moving or clicking; only the newly focused session may receive input.
|
||||||
|
if (isMacOS &&
|
||||||
|
stateGlobal.fullscreen.isTrue &&
|
||||||
|
!_ffi.inputModel.relativeMouseMode.value) {
|
||||||
|
// Native window focus is authoritative when a secondary engine retains a
|
||||||
|
// stale hidden lifecycle state after its fullscreen Space becomes visible.
|
||||||
|
_queueMacOSKeyboardAfterFullScreen(allowHiddenLifecycle: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore relative mouse mode constraints when window regains focus.
|
||||||
|
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||||
|
if (isMacOS) {
|
||||||
|
// Native relative mode retains pointer capture and does not emit
|
||||||
|
// PointerEnter after window focus returns. Restore both latches unless
|
||||||
|
// a local overlay still owns input.
|
||||||
|
if (_blockableOverlayState.middleBlocked.isFalse) {
|
||||||
|
_cursorOverImage.value = true;
|
||||||
|
_macOSLocalFocusLost = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_rawKeyFocusNode.requestFocus();
|
||||||
|
}
|
||||||
|
_ffi.inputModel.onWindowFocus();
|
||||||
|
}
|
||||||
|
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -200,25 +542,66 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
if (isWindows) {
|
if (isWindows) {
|
||||||
_isWindowBlur = false;
|
_isWindowBlur = false;
|
||||||
}
|
}
|
||||||
if (!isLinux) {
|
WakelockManager.enable(_uniqueKey);
|
||||||
WakelockPlus.enable();
|
// Update pointer lock center when window is restored
|
||||||
}
|
_updatePointerLockCenterIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not.
|
// When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not.
|
||||||
@override
|
@override
|
||||||
void onWindowMaximize() {
|
void onWindowMaximize() {
|
||||||
super.onWindowMaximize();
|
super.onWindowMaximize();
|
||||||
if (!isLinux) {
|
WakelockManager.enable(_uniqueKey);
|
||||||
WakelockPlus.enable();
|
// Update pointer lock center when window is maximized
|
||||||
|
_updatePointerLockCenterIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onWindowResize() {
|
||||||
|
super.onWindowResize();
|
||||||
|
// Update pointer lock center when window is resized
|
||||||
|
_updatePointerLockCenterIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onWindowMove() {
|
||||||
|
super.onWindowMove();
|
||||||
|
// Update pointer lock center when window is moved
|
||||||
|
_updatePointerLockCenterIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update pointer lock center with debouncing to avoid excessive updates
|
||||||
|
/// during rapid window move/resize events.
|
||||||
|
void _updatePointerLockCenterIfNeeded() {
|
||||||
|
if (!_ffi.inputModel.relativeMouseMode.value) return;
|
||||||
|
|
||||||
|
// Cancel any pending update and schedule a new one (debounce pattern)
|
||||||
|
_pointerLockCenterDebounceTimer?.cancel();
|
||||||
|
_pointerLockCenterDebounceTimer = Timer(
|
||||||
|
const Duration(milliseconds: kDefaultPointerLockCenterThrottleMs),
|
||||||
|
() {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||||
|
_ffi.inputModel.updatePointerLockCenter();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onWindowMinimize() {
|
void onWindowMinimize() {
|
||||||
super.onWindowMinimize();
|
super.onWindowMinimize();
|
||||||
if (!isLinux) {
|
WakelockManager.disable(_uniqueKey);
|
||||||
WakelockPlus.disable();
|
if (isMacOS) {
|
||||||
|
_macOSFullScreenFocusRecovery.cancel();
|
||||||
|
_isWindowBlur = true;
|
||||||
|
_cursorOverImage.value = false;
|
||||||
|
stateGlobal.isFocused.value = false;
|
||||||
|
_syncMacOSKeyboardGrab();
|
||||||
|
}
|
||||||
|
// Release cursor constraints when minimized
|
||||||
|
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||||
|
_ffi.inputModel.onWindowBlur();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +610,7 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
super.onWindowEnterFullScreen();
|
super.onWindowEnterFullScreen();
|
||||||
if (isMacOS) {
|
if (isMacOS) {
|
||||||
stateGlobal.setFullscreen(true);
|
stateGlobal.setFullscreen(true);
|
||||||
|
_queueMacOSKeyboardAfterFullScreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +619,7 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
super.onWindowLeaveFullScreen();
|
super.onWindowLeaveFullScreen();
|
||||||
if (isMacOS) {
|
if (isMacOS) {
|
||||||
stateGlobal.setFullscreen(false);
|
stateGlobal.setFullscreen(false);
|
||||||
|
_queueMacOSKeyboardAfterFullScreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,11 +628,31 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
final closeSession = closeSessionOnDispose.remove(widget.id) ?? true;
|
final closeSession = closeSessionOnDispose.remove(widget.id) ?? true;
|
||||||
|
|
||||||
// https://github.com/flutter/flutter/issues/64935
|
// https://github.com/flutter/flutter/issues/64935
|
||||||
|
if (isMacOS) {
|
||||||
|
// Tab moves release before transfer to avoid a late retained-session leave.
|
||||||
|
if (closeSession) {
|
||||||
|
_releaseMacOSRemoteInput();
|
||||||
|
}
|
||||||
|
_tabStateSubscription?.cancel();
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
|
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
|
||||||
_ffi.textureModel.onRemotePageDispose(closeSession);
|
|
||||||
if (closeSession) {
|
// Defensive cleanup: ensure host system-key propagation is reset even if
|
||||||
|
// MouseRegion.onExit never fired (e.g., tab closed while cursor inside).
|
||||||
|
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
|
||||||
|
|
||||||
|
_pointerLockCenterDebounceTimer?.cancel();
|
||||||
|
_pointerLockCenterDebounceTimer = null;
|
||||||
|
_waylandKeyboardModeWorker?.dispose();
|
||||||
|
// Clear callback reference to prevent memory leaks and stale references
|
||||||
|
_ffi.inputModel.onRelativeMouseModeDisabled = null;
|
||||||
|
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
|
||||||
|
_ffi.textureModel.onRemotePageDispose();
|
||||||
|
if (closeSession && !isMacOS) {
|
||||||
// ensure we leave this session, this is a double check
|
// ensure we leave this session, this is a double check
|
||||||
|
// enterOrLeave() is already called previously in _releaseMacOSRemoteInput() for macOS.
|
||||||
_ffi.inputModel.enterOrLeave(false);
|
_ffi.inputModel.enterOrLeave(false);
|
||||||
}
|
}
|
||||||
DesktopMultiWindow.removeListener(this);
|
DesktopMultiWindow.removeListener(this);
|
||||||
@@ -255,6 +660,9 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
_ffi.imageModel.disposeImage();
|
_ffi.imageModel.disposeImage();
|
||||||
_ffi.cursorModel.disposeImages();
|
_ffi.cursorModel.disposeImages();
|
||||||
_rawKeyFocusNode.dispose();
|
_rawKeyFocusNode.dispose();
|
||||||
|
if (closeSession) {
|
||||||
|
clearWaylandKeyboardPromptSuppressedForConnection(sessionId.toString());
|
||||||
|
}
|
||||||
await _ffi.close(closeSession: closeSession);
|
await _ffi.close(closeSession: closeSession);
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_ffi.dialogManager.dismissAll();
|
_ffi.dialogManager.dismissAll();
|
||||||
@@ -262,9 +670,7 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||||
overlays: SystemUiOverlay.values);
|
overlays: SystemUiOverlay.values);
|
||||||
}
|
}
|
||||||
if (!isLinux) {
|
WakelockManager.disable(_uniqueKey);
|
||||||
await WakelockPlus.disable();
|
|
||||||
}
|
|
||||||
await Get.delete<FFI>(tag: widget.id);
|
await Get.delete<FFI>(tag: widget.id);
|
||||||
removeSharedStates(widget.id);
|
removeSharedStates(widget.id);
|
||||||
}
|
}
|
||||||
@@ -321,6 +727,8 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
} else {
|
} else {
|
||||||
_ffi.inputModel.enterOrLeave(false);
|
_ffi.inputModel.enterOrLeave(false);
|
||||||
}
|
}
|
||||||
|
} else if (isMacOS) {
|
||||||
|
_onMacOSFocusChange();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
inputModel: _ffi.inputModel,
|
inputModel: _ffi.inputModel,
|
||||||
@@ -348,10 +756,15 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
}
|
}
|
||||||
}(),
|
}(),
|
||||||
// Use Overlay to enable rebuild every time on menu button click.
|
// Use Overlay to enable rebuild every time on menu button click.
|
||||||
_ffi.ffiModel.pi.isSet.isTrue
|
// Hide toolbar when relative mouse mode is active to prevent
|
||||||
? Overlay(
|
// cursor from escaping to toolbar area.
|
||||||
initialEntries: [OverlayEntry(builder: remoteToolbar)])
|
Obx(() => _ffi.inputModel.relativeMouseMode.value
|
||||||
: remoteToolbar(context),
|
? const Offstage()
|
||||||
|
: _ffi.ffiModel.pi.isSet.isTrue
|
||||||
|
? Overlay(initialEntries: [
|
||||||
|
OverlayEntry(builder: remoteToolbar)
|
||||||
|
])
|
||||||
|
: remoteToolbar(context)),
|
||||||
_ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(),
|
_ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -395,7 +808,7 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
super.build(context);
|
super.build(context);
|
||||||
return WillPopScope(
|
return WillPopScope(
|
||||||
onWillPop: () async {
|
onWillPop: () async {
|
||||||
clientClose(sessionId, _ffi.dialogManager);
|
clientClose(sessionId, _ffi);
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
child: MultiProvider(providers: [
|
child: MultiProvider(providers: [
|
||||||
@@ -408,6 +821,8 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void enterView(PointerEnterEvent evt) {
|
void enterView(PointerEnterEvent evt) {
|
||||||
|
_ffi.canvasModel.rearmEdgeScroll();
|
||||||
|
|
||||||
_cursorOverImage.value = true;
|
_cursorOverImage.value = true;
|
||||||
_firstEnterImage.value = true;
|
_firstEnterImage.value = true;
|
||||||
if (_onEnterOrLeaveImage4Toolbar != null) {
|
if (_onEnterOrLeaveImage4Toolbar != null) {
|
||||||
@@ -417,8 +832,13 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
//
|
//
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// See [onWindowBlur].
|
// See [onWindowBlur].
|
||||||
if (!isWindows) {
|
if (isMacOS) {
|
||||||
|
_macOSLocalFocusLost = false;
|
||||||
|
stateGlobal.getInputSource(force: true);
|
||||||
|
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
|
||||||
|
} else if (!isWindows) {
|
||||||
if (!_rawKeyFocusNode.hasFocus) {
|
if (!_rawKeyFocusNode.hasFocus) {
|
||||||
_rawKeyFocusNode.requestFocus();
|
_rawKeyFocusNode.requestFocus();
|
||||||
}
|
}
|
||||||
@@ -427,6 +847,8 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void leaveView(PointerExitEvent evt) {
|
void leaveView(PointerExitEvent evt) {
|
||||||
|
_ffi.canvasModel.disableEdgeScroll();
|
||||||
|
|
||||||
if (_ffi.ffiModel.keyboard) {
|
if (_ffi.ffiModel.keyboard) {
|
||||||
_ffi.inputModel.tryMoveEdgeOnExit(evt.position);
|
_ffi.inputModel.tryMoveEdgeOnExit(evt.position);
|
||||||
}
|
}
|
||||||
@@ -440,8 +862,11 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
//
|
//
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// See [onWindowBlur].
|
// See [onWindowBlur].
|
||||||
if (!isWindows) {
|
if (isMacOS) {
|
||||||
|
_syncMacOSKeyboardGrab();
|
||||||
|
} else if (!isWindows) {
|
||||||
_ffi.inputModel.enterOrLeave(false);
|
_ffi.inputModel.enterOrLeave(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -466,17 +891,29 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
onEnter: onEnter,
|
onEnter: onEnter,
|
||||||
onExit: onExit,
|
onExit: onExit,
|
||||||
onPointerDown: (event) {
|
onPointerDown: (event) {
|
||||||
// A double check for blur status.
|
// A double check for blur status on Windows and macOS.
|
||||||
// Note: If there's an `onPointerDown` event is triggered, `_isWindowBlur` is expected being false.
|
// Note: If there's an `onPointerDown` event is triggered, `_isWindowBlur` is expected being false.
|
||||||
// Sometimes the system does not send the necessary focus event to flutter. We should manually
|
// Sometimes the system does not send the necessary focus event to flutter. We should manually
|
||||||
// handle this inconsistent status by setting `_isWindowBlur` to false. So we can
|
// handle this inconsistent status by setting `_isWindowBlur` to false. So we can
|
||||||
// ensure the grab-key thread is running when our users are clicking the remote canvas.
|
// ensure the grab-key thread is running when our users are clicking the remote canvas.
|
||||||
if (_isWindowBlur) {
|
if ((isWindows || isMacOS) && _isWindowBlur) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
"Unexpected status: onPointerDown is triggered while the remote window is in blur status");
|
"Unexpected status: onPointerDown is triggered while the remote window is in blur status");
|
||||||
_isWindowBlur = false;
|
_isWindowBlur = false;
|
||||||
}
|
}
|
||||||
if (!_rawKeyFocusNode.hasFocus) {
|
if (isMacOS) {
|
||||||
|
// Regions without matching enter/exit callbacks cannot safely own
|
||||||
|
// keyboard state.
|
||||||
|
if (onEnter == null || onExit == null) return;
|
||||||
|
if (!stateGlobal.isFocused.value) {
|
||||||
|
stateGlobal.isFocused.value = true;
|
||||||
|
}
|
||||||
|
_cursorOverImage.value = true;
|
||||||
|
_macOSLocalFocusLost = false;
|
||||||
|
stateGlobal.getInputSource(force: true);
|
||||||
|
_syncMacOSKeyboardGrab(
|
||||||
|
reassert: !isInputSourceFlutter, allowInactiveLifecycle: true);
|
||||||
|
} else if (!_rawKeyFocusNode.hasFocus) {
|
||||||
_rawKeyFocusNode.requestFocus();
|
_rawKeyFocusNode.requestFocus();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -487,19 +924,22 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
|
|
||||||
Widget getBodyForDesktop(BuildContext context) {
|
Widget getBodyForDesktop(BuildContext context) {
|
||||||
var paints = <Widget>[
|
var paints = <Widget>[
|
||||||
MouseRegion(onEnter: (evt) {
|
MouseRegion(
|
||||||
|
onEnter: (evt) {
|
||||||
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false);
|
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false);
|
||||||
}, onExit: (evt) {
|
},
|
||||||
|
onExit: (evt) {
|
||||||
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
|
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
|
||||||
}, child: LayoutBuilder(builder: (context, constraints) {
|
},
|
||||||
final c = Provider.of<CanvasModel>(context, listen: false);
|
child: _ViewStyleUpdater(
|
||||||
Future.delayed(Duration.zero, () => c.updateViewStyle());
|
canvasModel: _ffi.canvasModel,
|
||||||
|
inputModel: _ffi.inputModel,
|
||||||
|
child: Builder(builder: (context) {
|
||||||
final peerDisplay = CurrentDisplayState.find(widget.id);
|
final peerDisplay = CurrentDisplayState.find(widget.id);
|
||||||
return Obx(
|
return Obx(
|
||||||
() => _ffi.ffiModel.pi.isSet.isFalse
|
() => _ffi.ffiModel.pi.isSet.isFalse
|
||||||
? Container(color: Colors.transparent)
|
? Container(color: Colors.transparent)
|
||||||
: Obx(() {
|
: Obx(() {
|
||||||
widget.toolbarState.initShow(sessionId);
|
|
||||||
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
|
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
|
||||||
return ImagePaint(
|
return ImagePaint(
|
||||||
id: widget.id,
|
id: widget.id,
|
||||||
@@ -507,13 +947,16 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
cursorOverImage: _cursorOverImage,
|
cursorOverImage: _cursorOverImage,
|
||||||
keyboardEnabled: _keyboardEnabled,
|
keyboardEnabled: _keyboardEnabled,
|
||||||
remoteCursorMoved: _remoteCursorMoved,
|
remoteCursorMoved: _remoteCursorMoved,
|
||||||
listenerBuilder: (child) => _buildRawTouchAndPointerRegion(
|
listenerBuilder: (child) =>
|
||||||
|
_buildRawTouchAndPointerRegion(
|
||||||
child, enterView, leaveView),
|
child, enterView, leaveView),
|
||||||
ffi: _ffi,
|
ffi: _ffi,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}))
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!_ffi.canvasModel.cursorEmbedded) {
|
if (!_ffi.canvasModel.cursorEmbedded) {
|
||||||
@@ -542,6 +985,63 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
bool get wantKeepAlive => true;
|
bool get wantKeepAlive => true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A widget that tracks the view size and updates CanvasModel.updateViewStyle()
|
||||||
|
/// and InputModel.updateImageWidgetSize() only when size actually changes.
|
||||||
|
/// This avoids scheduling post-frame callbacks on every LayoutBuilder rebuild.
|
||||||
|
class _ViewStyleUpdater extends StatefulWidget {
|
||||||
|
final CanvasModel canvasModel;
|
||||||
|
final InputModel inputModel;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const _ViewStyleUpdater({
|
||||||
|
Key? key,
|
||||||
|
required this.canvasModel,
|
||||||
|
required this.inputModel,
|
||||||
|
required this.child,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ViewStyleUpdater> createState() => _ViewStyleUpdaterState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ViewStyleUpdaterState extends State<_ViewStyleUpdater> {
|
||||||
|
Size? _lastSize;
|
||||||
|
bool _callbackScheduled = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final maxWidth = constraints.maxWidth;
|
||||||
|
final maxHeight = constraints.maxHeight;
|
||||||
|
// Guard against infinite constraints (e.g., unconstrained ancestor).
|
||||||
|
if (!maxWidth.isFinite || !maxHeight.isFinite) {
|
||||||
|
return widget.child;
|
||||||
|
}
|
||||||
|
final newSize = Size(maxWidth, maxHeight);
|
||||||
|
if (_lastSize != newSize) {
|
||||||
|
_lastSize = newSize;
|
||||||
|
// Schedule the update for after the current frame to avoid setState during build.
|
||||||
|
// Use _callbackScheduled flag to prevent accumulating multiple callbacks
|
||||||
|
// when size changes rapidly before any callback executes.
|
||||||
|
if (!_callbackScheduled) {
|
||||||
|
_callbackScheduled = true;
|
||||||
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_callbackScheduled = false;
|
||||||
|
final currentSize = _lastSize;
|
||||||
|
if (mounted && currentSize != null) {
|
||||||
|
widget.canvasModel.updateViewStyle();
|
||||||
|
widget.inputModel.updateImageWidgetSize(currentSize);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return widget.child;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class ImagePaint extends StatefulWidget {
|
class ImagePaint extends StatefulWidget {
|
||||||
final FFI ffi;
|
final FFI ffi;
|
||||||
final String id;
|
final String id;
|
||||||
@@ -605,6 +1105,9 @@ class _ImagePaintState extends State<ImagePaint> {
|
|||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
cursor: cursorOverImage.isTrue
|
cursor: cursorOverImage.isTrue
|
||||||
? c.cursorEmbedded
|
? c.cursorEmbedded
|
||||||
|
? SystemMouseCursors.none
|
||||||
|
// Hide cursor when relative mouse mode is active
|
||||||
|
: widget.ffi.inputModel.relativeMouseMode.value
|
||||||
? SystemMouseCursors.none
|
? SystemMouseCursors.none
|
||||||
: keyboardEnabled.isTrue
|
: keyboardEnabled.isTrue
|
||||||
? (() {
|
? (() {
|
||||||
@@ -625,7 +1128,7 @@ class _ImagePaintState extends State<ImagePaint> {
|
|||||||
onHover: (evt) {},
|
onHover: (evt) {},
|
||||||
child: child);
|
child: child);
|
||||||
});
|
});
|
||||||
if (c.imageOverflow.isTrue && c.scrollStyle == ScrollStyle.scrollbar) {
|
if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) {
|
||||||
final paintWidth = c.getDisplayWidth() * s;
|
final paintWidth = c.getDisplayWidth() * s;
|
||||||
final paintHeight = c.getDisplayHeight() * s;
|
final paintHeight = c.getDisplayHeight() * s;
|
||||||
final paintSize = Size(paintWidth, paintHeight);
|
final paintSize = Size(paintWidth, paintHeight);
|
||||||
@@ -680,9 +1183,20 @@ class _ImagePaintState extends State<ImagePaint> {
|
|||||||
|
|
||||||
Widget _buildScrollAutoNonTextureRender(
|
Widget _buildScrollAutoNonTextureRender(
|
||||||
ImageModel m, CanvasModel c, double s) {
|
ImageModel m, CanvasModel c, double s) {
|
||||||
|
double sizeScale = s;
|
||||||
|
if (widget.ffi.ffiModel.isPeerLinux) {
|
||||||
|
final displays = widget.ffi.ffiModel.pi.getCurDisplays();
|
||||||
|
if (displays.isNotEmpty) {
|
||||||
|
sizeScale = s / displays[0].scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
return CustomPaint(
|
return CustomPaint(
|
||||||
size: Size(c.size.width, c.size.height),
|
size: Size(c.size.width, c.size.height),
|
||||||
painter: ImagePainter(image: m.image, x: c.x / s, y: c.y / s, scale: s),
|
painter: ImagePainter(
|
||||||
|
image: m.image,
|
||||||
|
x: c.x / sizeScale,
|
||||||
|
y: c.y / sizeScale,
|
||||||
|
scale: sizeScale),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,17 +1209,19 @@ class _ImagePaintState extends State<ImagePaint> {
|
|||||||
if (rect == null) {
|
if (rect == null) {
|
||||||
return Container();
|
return Container();
|
||||||
}
|
}
|
||||||
|
final isPeerLinux = ffiModel.isPeerLinux;
|
||||||
final curDisplay = ffiModel.pi.currentDisplay;
|
final curDisplay = ffiModel.pi.currentDisplay;
|
||||||
for (var i = 0; i < displays.length; i++) {
|
for (var i = 0; i < displays.length; i++) {
|
||||||
final textureId = widget.ffi.textureModel
|
final textureId = widget.ffi.textureModel
|
||||||
.getTextureId(curDisplay == kAllDisplayValue ? i : curDisplay);
|
.getTextureId(curDisplay == kAllDisplayValue ? i : curDisplay);
|
||||||
if (true) {
|
if (true) {
|
||||||
// both "textureId.value != -1" and "true" seems ok
|
// both "textureId.value != -1" and "true" seems ok
|
||||||
|
final sizeScale = isPeerLinux ? s / displays[i].scale : s;
|
||||||
children.add(Positioned(
|
children.add(Positioned(
|
||||||
left: (displays[i].x - rect.left) * s + offset.dx,
|
left: (displays[i].x - rect.left) * s + offset.dx,
|
||||||
top: (displays[i].y - rect.top) * s + offset.dy,
|
top: (displays[i].y - rect.top) * s + offset.dy,
|
||||||
width: displays[i].width * s,
|
width: displays[i].width * sizeScale,
|
||||||
height: displays[i].height * s,
|
height: displays[i].height * sizeScale,
|
||||||
child: Obx(() => Texture(
|
child: Obx(() => Texture(
|
||||||
textureId: textureId.value,
|
textureId: textureId.value,
|
||||||
filterQuality:
|
filterQuality:
|
||||||
@@ -888,3 +1404,4 @@ class CursorPaint extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,15 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
|||||||
label: peerId!,
|
label: peerId!,
|
||||||
selectedIcon: selectedIcon,
|
selectedIcon: selectedIcon,
|
||||||
unselectedIcon: unselectedIcon,
|
unselectedIcon: unselectedIcon,
|
||||||
onTabCloseButton: () => tabController.closeBy(peerId),
|
onTabCloseButton: () async {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: peerId!,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabController.closeBy(peerId!);
|
||||||
|
},
|
||||||
page: RemotePage(
|
page: RemotePage(
|
||||||
key: ValueKey(peerId),
|
key: ValueKey(peerId),
|
||||||
id: peerId!,
|
id: peerId!,
|
||||||
@@ -127,7 +135,13 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
|||||||
body: DesktopTab(
|
body: DesktopTab(
|
||||||
controller: tabController,
|
controller: tabController,
|
||||||
onWindowCloseButton: handleWindowCloseButton,
|
onWindowCloseButton: handleWindowCloseButton,
|
||||||
tail: const AddButton(),
|
tail: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_RelativeMouseModeHint(tabController: tabController),
|
||||||
|
const AddButton(),
|
||||||
|
],
|
||||||
|
),
|
||||||
selectedBorderColor: MyTheme.accent,
|
selectedBorderColor: MyTheme.accent,
|
||||||
pageViewBuilder: (pageView) => pageView,
|
pageViewBuilder: (pageView) => pageView,
|
||||||
labelGetter: DesktopTab.tablabelGetter,
|
labelGetter: DesktopTab.tablabelGetter,
|
||||||
@@ -243,11 +257,11 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
|||||||
MenuEntryButton<String>(
|
MenuEntryButton<String>(
|
||||||
childBuilder: (TextStyle? style) => Obx(() => Text(
|
childBuilder: (TextStyle? style) => Obx(() => Text(
|
||||||
translate(
|
translate(
|
||||||
toolbarState.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
|
toolbarState.hide.isTrue ? 'Show Toolbar' : 'Hide Toolbar'),
|
||||||
style: style,
|
style: style,
|
||||||
)),
|
)),
|
||||||
proc: () {
|
proc: () {
|
||||||
toolbarState.switchShow(sessionId);
|
toolbarState.switchHide(sessionId);
|
||||||
cancelFunc();
|
cancelFunc();
|
||||||
},
|
},
|
||||||
padding: padding,
|
padding: padding,
|
||||||
@@ -316,7 +330,13 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
|||||||
translate('Close'),
|
translate('Close'),
|
||||||
style: style,
|
style: style,
|
||||||
),
|
),
|
||||||
proc: () {
|
proc: () async {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: key,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
tabController.closeBy(key);
|
tabController.closeBy(key);
|
||||||
cancelFunc();
|
cancelFunc();
|
||||||
},
|
},
|
||||||
@@ -360,6 +380,8 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
|||||||
loopCloseWindow();
|
loopCloseWindow();
|
||||||
}
|
}
|
||||||
ConnectionTypeState.delete(id);
|
ConnectionTypeState.delete(id);
|
||||||
|
// Clean up relative mouse mode state for this peer.
|
||||||
|
stateGlobal.relativeMouseModeState.remove(id);
|
||||||
_update_remote_count();
|
_update_remote_count();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,6 +391,14 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
|||||||
|
|
||||||
Future<bool> handleWindowCloseButton() async {
|
Future<bool> handleWindowCloseButton() async {
|
||||||
final connLength = tabController.length;
|
final connLength = tabController.length;
|
||||||
|
if (connLength == 1) {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: tabController.state.value.tabs[0].key,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (connLength <= 1) {
|
if (connLength <= 1) {
|
||||||
tabController.clear();
|
tabController.clear();
|
||||||
return true;
|
return true;
|
||||||
@@ -423,7 +453,15 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
|||||||
label: id,
|
label: id,
|
||||||
selectedIcon: selectedIcon,
|
selectedIcon: selectedIcon,
|
||||||
unselectedIcon: unselectedIcon,
|
unselectedIcon: unselectedIcon,
|
||||||
onTabCloseButton: () => tabController.closeBy(id),
|
onTabCloseButton: () async {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: id,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabController.closeBy(id);
|
||||||
|
},
|
||||||
page: RemotePage(
|
page: RemotePage(
|
||||||
key: ValueKey(id),
|
key: ValueKey(id),
|
||||||
id: id,
|
id: id,
|
||||||
@@ -475,15 +513,17 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
|||||||
final args = jsonDecode(call.arguments);
|
final args = jsonDecode(call.arguments);
|
||||||
final id = args['id'];
|
final id = args['id'];
|
||||||
final close = args['close'];
|
final close = args['close'];
|
||||||
|
RemotePage? remotePage;
|
||||||
try {
|
try {
|
||||||
final remotePage = tabController.state.value.tabs
|
remotePage = tabController.state.value.tabs
|
||||||
.firstWhere((tab) => tab.key == id)
|
.firstWhere((tab) => tab.key == id)
|
||||||
.page as RemotePage;
|
.page as RemotePage;
|
||||||
returnValue = remotePage.ffi.ffiModel.cachedPeerData.toString();
|
returnValue = remotePage.ffi.ffiModel.cachedPeerData.toString();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Failed to get cached session data: $e');
|
debugPrint('Failed to get cached session data: $e');
|
||||||
}
|
}
|
||||||
if (close && returnValue != null) {
|
if (close && returnValue != null && remotePage != null) {
|
||||||
|
remotePage.releaseMacOSInputForTabTransfer();
|
||||||
closeSessionOnDispose[id] = false;
|
closeSessionOnDispose[id] = false;
|
||||||
tabController.closeBy(id);
|
tabController.closeBy(id);
|
||||||
}
|
}
|
||||||
@@ -518,3 +558,69 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
|||||||
return returnValue;
|
return returnValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A widget that displays a hint in the tab bar when relative mouse mode is active.
|
||||||
|
/// This helps users remember how to exit relative mouse mode.
|
||||||
|
class _RelativeMouseModeHint extends StatelessWidget {
|
||||||
|
final DesktopTabController tabController;
|
||||||
|
|
||||||
|
const _RelativeMouseModeHint({Key? key, required this.tabController})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Obx(() {
|
||||||
|
// Check if there are any tabs
|
||||||
|
if (tabController.state.value.tabs.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current selected tab's RemotePage
|
||||||
|
final selectedTabInfo = tabController.state.value.selectedTabInfo;
|
||||||
|
if (selectedTabInfo.page is! RemotePage) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final remotePage = selectedTabInfo.page as RemotePage;
|
||||||
|
final String peerId = remotePage.id;
|
||||||
|
|
||||||
|
// Use global state to check relative mouse mode (synced from InputModel).
|
||||||
|
// This avoids timing issues with FFI registration.
|
||||||
|
final isRelativeMouseMode =
|
||||||
|
stateGlobal.relativeMouseModeState[peerId] ?? false;
|
||||||
|
|
||||||
|
if (!isRelativeMouseMode) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
margin: const EdgeInsets.only(right: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
border: Border.all(color: Colors.orange.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.mouse,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.orange[700],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
translate(
|
||||||
|
'rel-mouse-exit-{${isMacOS ? "Cmd+G" : "Ctrl+Alt"}}-tip'),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.orange[700],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -462,23 +462,7 @@ class _CmHeaderState extends State<_CmHeader>
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
_buildClientAvatar().marginOnly(right: 10.0),
|
||||||
width: 70,
|
|
||||||
height: 70,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: str2color(client.name),
|
|
||||||
borderRadius: BorderRadius.circular(15.0),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
client.name[0],
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 55,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).marginOnly(right: 10.0),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
@@ -511,14 +495,14 @@ class _CmHeaderState extends State<_CmHeader>
|
|||||||
if (client.type_() == ClientType.file)
|
if (client.type_() == ClientType.file)
|
||||||
FittedBox(
|
FittedBox(
|
||||||
child: Text(
|
child: Text(
|
||||||
translate("File Transfer"),
|
translate("Transfer file"),
|
||||||
style: TextStyle(color: Colors.white70, fontSize: 12),
|
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (client.type_() == ClientType.camera)
|
if (client.type_() == ClientType.camera)
|
||||||
FittedBox(
|
FittedBox(
|
||||||
child: Text(
|
child: Text(
|
||||||
translate("View Camera"),
|
translate("View camera"),
|
||||||
style: TextStyle(color: Colors.white70, fontSize: 12),
|
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -582,6 +566,36 @@ class _CmHeaderState extends State<_CmHeader>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => true;
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
|
Widget _buildClientAvatar() {
|
||||||
|
return buildAvatarWidget(
|
||||||
|
avatar: client.avatar,
|
||||||
|
size: 70,
|
||||||
|
borderRadius: 15,
|
||||||
|
fallback: _buildInitialAvatar(),
|
||||||
|
) ??
|
||||||
|
_buildInitialAvatar();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInitialAvatar() {
|
||||||
|
return Container(
|
||||||
|
width: 70,
|
||||||
|
height: 70,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: str2color(client.name),
|
||||||
|
borderRadius: BorderRadius.circular(15.0),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
client.name.isNotEmpty ? client.name[0] : '?',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 55,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PrivilegeBoard extends StatefulWidget {
|
class _PrivilegeBoard extends StatefulWidget {
|
||||||
@@ -596,19 +610,24 @@ class _PrivilegeBoard extends StatefulWidget {
|
|||||||
class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||||
late final client = widget.client;
|
late final client = widget.client;
|
||||||
Widget buildPermissionIcon(bool enabled, IconData iconData,
|
Widget buildPermissionIcon(bool enabled, IconData iconData,
|
||||||
Function(bool)? onTap, String tooltipText) {
|
Function(bool)? onTap, String tooltipText,
|
||||||
|
{required bool canModify}) {
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: "$tooltipText: ${enabled ? "ON" : "OFF"}",
|
message: "$tooltipText: ${enabled ? "ON" : "OFF"}",
|
||||||
waitDuration: Duration.zero,
|
waitDuration: Duration.zero,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: enabled ? MyTheme.accent : Colors.grey[700],
|
color: enabled
|
||||||
|
? (canModify ? MyTheme.accent : MyTheme.accent.withOpacity(0.6))
|
||||||
|
: Colors.grey[700],
|
||||||
borderRadius: BorderRadius.circular(10.0),
|
borderRadius: BorderRadius.circular(10.0),
|
||||||
),
|
),
|
||||||
padding: EdgeInsets.all(8.0),
|
padding: EdgeInsets.all(8.0),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () =>
|
onTap: canModify
|
||||||
checkClickTime(widget.client.id, () => onTap?.call(!enabled)),
|
? () =>
|
||||||
|
checkClickTime(widget.client.id, () => onTap?.call(!enabled))
|
||||||
|
: null,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
children: [
|
children: [
|
||||||
@@ -629,6 +648,9 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final crossAxisCount = 4;
|
final crossAxisCount = 4;
|
||||||
final spacing = 10.0;
|
final spacing = 10.0;
|
||||||
|
final canModifyPermission =
|
||||||
|
bind.mainGetBuildinOption(key: kOptionEnablePermChangeInAcceptWindow) !=
|
||||||
|
'N';
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 160.0,
|
height: 160.0,
|
||||||
@@ -675,6 +697,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
translate('Enable audio'),
|
translate('Enable audio'),
|
||||||
|
canModify: canModifyPermission,
|
||||||
),
|
),
|
||||||
buildPermissionIcon(
|
buildPermissionIcon(
|
||||||
client.recording,
|
client.recording,
|
||||||
@@ -689,6 +712,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
translate('Enable recording session'),
|
translate('Enable recording session'),
|
||||||
|
canModify: canModifyPermission,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
@@ -705,6 +729,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
translate('Enable keyboard/mouse'),
|
translate('Enable keyboard/mouse'),
|
||||||
|
canModify: canModifyPermission,
|
||||||
),
|
),
|
||||||
buildPermissionIcon(
|
buildPermissionIcon(
|
||||||
client.clipboard,
|
client.clipboard,
|
||||||
@@ -719,6 +744,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
translate('Enable clipboard'),
|
translate('Enable clipboard'),
|
||||||
|
canModify: canModifyPermission,
|
||||||
),
|
),
|
||||||
buildPermissionIcon(
|
buildPermissionIcon(
|
||||||
client.audio,
|
client.audio,
|
||||||
@@ -733,6 +759,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
translate('Enable audio'),
|
translate('Enable audio'),
|
||||||
|
canModify: canModifyPermission,
|
||||||
),
|
),
|
||||||
buildPermissionIcon(
|
buildPermissionIcon(
|
||||||
client.file,
|
client.file,
|
||||||
@@ -747,6 +774,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
translate('Enable file copy and paste'),
|
translate('Enable file copy and paste'),
|
||||||
|
canModify: canModifyPermission,
|
||||||
),
|
),
|
||||||
buildPermissionIcon(
|
buildPermissionIcon(
|
||||||
client.restart,
|
client.restart,
|
||||||
@@ -761,6 +789,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
translate('Enable remote restart'),
|
translate('Enable remote restart'),
|
||||||
|
canModify: canModifyPermission,
|
||||||
),
|
),
|
||||||
buildPermissionIcon(
|
buildPermissionIcon(
|
||||||
client.recording,
|
client.recording,
|
||||||
@@ -775,6 +804,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
translate('Enable recording session'),
|
translate('Enable recording session'),
|
||||||
|
canModify: canModifyPermission,
|
||||||
),
|
),
|
||||||
// only windows support block input
|
// only windows support block input
|
||||||
if (isWindows)
|
if (isWindows)
|
||||||
@@ -791,6 +821,23 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
translate('Enable blocking user input'),
|
translate('Enable blocking user input'),
|
||||||
|
canModify: canModifyPermission,
|
||||||
|
),
|
||||||
|
if (bind.mainSupportedPrivacyModeImpls() != '[]')
|
||||||
|
buildPermissionIcon(
|
||||||
|
client.privacyMode,
|
||||||
|
Icons.visibility_off,
|
||||||
|
(enabled) {
|
||||||
|
bind.cmSwitchPermission(
|
||||||
|
connId: client.id,
|
||||||
|
name: "privacy_mode",
|
||||||
|
enabled: enabled);
|
||||||
|
setState(() {
|
||||||
|
client.privacyMode = enabled;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
translate('Enable privacy mode'),
|
||||||
|
canModify: canModifyPermission,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hbb/common.dart';
|
import 'package:flutter_hbb/common.dart';
|
||||||
@@ -8,13 +9,14 @@ import 'package:xterm/xterm.dart';
|
|||||||
import 'terminal_connection_manager.dart';
|
import 'terminal_connection_manager.dart';
|
||||||
|
|
||||||
class TerminalPage extends StatefulWidget {
|
class TerminalPage extends StatefulWidget {
|
||||||
const TerminalPage({
|
TerminalPage({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.password,
|
required this.password,
|
||||||
required this.tabController,
|
required this.tabController,
|
||||||
required this.isSharedPassword,
|
required this.isSharedPassword,
|
||||||
required this.terminalId,
|
required this.terminalId,
|
||||||
|
required this.tabKey,
|
||||||
this.forceRelay,
|
this.forceRelay,
|
||||||
this.connToken,
|
this.connToken,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
@@ -26,19 +28,38 @@ class TerminalPage extends StatefulWidget {
|
|||||||
final String? connToken;
|
final String? connToken;
|
||||||
final int terminalId;
|
final int terminalId;
|
||||||
|
|
||||||
|
/// Tab key for focus management, passed from parent to avoid duplicate construction
|
||||||
|
final String tabKey;
|
||||||
|
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
|
||||||
|
|
||||||
|
FFI get ffi => (_lastState.value! as _TerminalPageState)._ffi;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<TerminalPage> createState() => _TerminalPageState();
|
State<TerminalPage> createState() {
|
||||||
|
final state = _TerminalPageState();
|
||||||
|
_lastState.value = state;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TerminalPageState extends State<TerminalPage>
|
class _TerminalPageState extends State<TerminalPage>
|
||||||
with AutomaticKeepAliveClientMixin {
|
with AutomaticKeepAliveClientMixin {
|
||||||
|
static const EdgeInsets _defaultTerminalPadding =
|
||||||
|
EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
||||||
|
|
||||||
late FFI _ffi;
|
late FFI _ffi;
|
||||||
late TerminalModel _terminalModel;
|
late TerminalModel _terminalModel;
|
||||||
|
double? _cellHeight;
|
||||||
|
final FocusNode _terminalFocusNode = FocusNode(canRequestFocus: false);
|
||||||
|
StreamSubscription<DesktopTabState>? _tabStateSubscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
|
// Listen for tab selection changes to request focus
|
||||||
|
_tabStateSubscription = widget.tabController.state.listen(_onTabStateChanged);
|
||||||
|
|
||||||
// Use shared FFI instance from connection manager
|
// Use shared FFI instance from connection manager
|
||||||
_ffi = TerminalConnectionManager.getConnection(
|
_ffi = TerminalConnectionManager.getConnection(
|
||||||
peerId: widget.id,
|
peerId: widget.id,
|
||||||
@@ -53,16 +74,42 @@ class _TerminalPageState extends State<TerminalPage>
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
|
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
|
||||||
|
|
||||||
|
_terminalModel.onResizeExternal = (w, h, pw, ph) {
|
||||||
|
_cellHeight = ph * 1.0;
|
||||||
|
|
||||||
|
// Enable focus once terminal has valid dimensions (first valid resize)
|
||||||
|
if (!_terminalFocusNode.canRequestFocus && w > 0 && h > 0) {
|
||||||
|
_terminalFocusNode.canRequestFocus = true;
|
||||||
|
// Auto-focus if this tab is currently selected
|
||||||
|
_requestFocusIfSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule the setState for the next frame
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Register this terminal model with FFI for event routing
|
// Register this terminal model with FFI for event routing
|
||||||
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
|
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
|
||||||
|
|
||||||
|
// Auto-close tab when shell exits
|
||||||
|
_terminalModel.onClosed = () {
|
||||||
|
if (mounted) {
|
||||||
|
widget.tabController.closeBy(widget.tabKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Initialize terminal connection
|
// Initialize terminal connection
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
widget.tabController.onSelected?.call(widget.id);
|
widget.tabController.onSelected?.call(widget.id);
|
||||||
|
|
||||||
// Check if this is a new connection or additional terminal
|
// Check if this is a new connection or additional terminal
|
||||||
// Note: When a connection exists, the ref count will be > 1 after this terminal is added
|
// Note: When a connection exists, the ref count will be > 1 after this terminal is added
|
||||||
final isExistingConnection = TerminalConnectionManager.hasConnection(widget.id) &&
|
final isExistingConnection =
|
||||||
|
TerminalConnectionManager.hasConnection(widget.id) &&
|
||||||
TerminalConnectionManager.getTerminalCount(widget.id) > 1;
|
TerminalConnectionManager.getTerminalCount(widget.id) > 1;
|
||||||
|
|
||||||
if (!isExistingConnection) {
|
if (!isExistingConnection) {
|
||||||
@@ -79,25 +126,85 @@ class _TerminalPageState extends State<TerminalPage>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
// Cancel tab state subscription to prevent memory leak
|
||||||
|
_tabStateSubscription?.cancel();
|
||||||
// Unregister terminal model from FFI
|
// Unregister terminal model from FFI
|
||||||
_ffi.unregisterTerminalModel(widget.terminalId);
|
_ffi.unregisterTerminalModel(widget.terminalId);
|
||||||
_terminalModel.dispose();
|
_terminalModel.dispose();
|
||||||
|
_terminalFocusNode.dispose();
|
||||||
// Release connection reference instead of closing directly
|
// Release connection reference instead of closing directly
|
||||||
TerminalConnectionManager.releaseConnection(widget.id);
|
TerminalConnectionManager.releaseConnection(widget.id);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onTabStateChanged(DesktopTabState state) {
|
||||||
|
// Check if this tab is now selected and request focus
|
||||||
|
if (state.selected >= 0 && state.selected < state.tabs.length) {
|
||||||
|
final selectedTab = state.tabs[state.selected];
|
||||||
|
if (selectedTab.key == widget.tabKey && mounted) {
|
||||||
|
_requestFocusIfSelected();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _requestFocusIfSelected() {
|
||||||
|
if (!mounted || !_terminalFocusNode.canRequestFocus) return;
|
||||||
|
// Use post-frame callback to ensure widget is fully laid out in focus tree
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
// Re-check conditions after frame: mounted, focusable, still selected, not already focused
|
||||||
|
if (!mounted || !_terminalFocusNode.canRequestFocus || _terminalFocusNode.hasFocus) return;
|
||||||
|
final state = widget.tabController.state.value;
|
||||||
|
if (state.selected >= 0 && state.selected < state.tabs.length) {
|
||||||
|
if (state.tabs[state.selected].key == widget.tabKey) {
|
||||||
|
_terminalFocusNode.requestFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// This method ensures that the number of visible rows is an integer by computing the
|
||||||
|
// extra space left after dividing the available height by the height of a single
|
||||||
|
// terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding.
|
||||||
|
EdgeInsets _calculatePadding(double heightPx) {
|
||||||
|
final cellHeight = _cellHeight;
|
||||||
|
if (!heightPx.isFinite ||
|
||||||
|
heightPx <= 0 ||
|
||||||
|
cellHeight == null ||
|
||||||
|
!cellHeight.isFinite ||
|
||||||
|
cellHeight <= 0) {
|
||||||
|
return _defaultTerminalPadding;
|
||||||
|
}
|
||||||
|
final rows = (heightPx / cellHeight).floor();
|
||||||
|
if (rows <= 0) {
|
||||||
|
return _defaultTerminalPadding;
|
||||||
|
}
|
||||||
|
final extraSpace = heightPx - rows * cellHeight;
|
||||||
|
if (!extraSpace.isFinite || extraSpace < 0) {
|
||||||
|
return _defaultTerminalPadding;
|
||||||
|
}
|
||||||
|
final topBottom = extraSpace / 2.0;
|
||||||
|
return EdgeInsets.symmetric(
|
||||||
|
horizontal: _defaultTerminalPadding.horizontal / 2,
|
||||||
|
vertical: topBottom,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
body: TerminalView(
|
body: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final heightPx = constraints.maxHeight;
|
||||||
|
return TerminalView(
|
||||||
_terminalModel.terminal,
|
_terminalModel.terminal,
|
||||||
controller: _terminalModel.terminalController,
|
controller: _terminalModel.terminalController,
|
||||||
autofocus: true,
|
focusNode: _terminalFocusNode,
|
||||||
|
// Note: autofocus is not used here because focus is managed manually
|
||||||
|
// via _onTabStateChanged() to handle tab switching properly.
|
||||||
backgroundOpacity: 0.7,
|
backgroundOpacity: 0.7,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0),
|
padding: _calculatePadding(heightPx),
|
||||||
onSecondaryTapDown: (details, offset) async {
|
onSecondaryTapDown: (details, offset) async {
|
||||||
final selection = _terminalModel.terminalController.selection;
|
final selection = _terminalModel.terminalController.selection;
|
||||||
if (selection != null) {
|
if (selection != null) {
|
||||||
@@ -112,6 +219,8 @@ class _TerminalPageState extends State<TerminalPage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:desktop_multi_window/desktop_multi_window.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hbb/common.dart';
|
import 'package:flutter_hbb/common.dart';
|
||||||
|
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||||
import 'package:flutter_hbb/consts.dart';
|
import 'package:flutter_hbb/consts.dart';
|
||||||
import 'package:flutter_hbb/models/state_model.dart';
|
import 'package:flutter_hbb/models/state_model.dart';
|
||||||
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||||
@@ -33,6 +34,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
static const IconData selectedIcon = Icons.terminal;
|
static const IconData selectedIcon = Icons.terminal;
|
||||||
static const IconData unselectedIcon = Icons.terminal_outlined;
|
static const IconData unselectedIcon = Icons.terminal_outlined;
|
||||||
int _nextTerminalId = 1;
|
int _nextTerminalId = 1;
|
||||||
|
// Lightweight idempotency guard for async close operations
|
||||||
|
final Set<String> _closingTabs = {};
|
||||||
|
// When true, all session cleanup should persist (window-level close in progress)
|
||||||
|
bool _windowClosing = false;
|
||||||
|
|
||||||
_TerminalTabPageState(Map<String, dynamic> params) {
|
_TerminalTabPageState(Map<String, dynamic> params) {
|
||||||
Get.put(DesktopTabController(tabType: DesktopTabType.terminal));
|
Get.put(DesktopTabController(tabType: DesktopTabType.terminal));
|
||||||
@@ -41,6 +46,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
.setTitle(getWindowNameWithId(id));
|
.setTitle(getWindowNameWithId(id));
|
||||||
};
|
};
|
||||||
tabController.onRemoved = (_, id) => onRemoveId(id);
|
tabController.onRemoved = (_, id) => onRemoveId(id);
|
||||||
|
tabController.onCloseWindow = _closeWindowFromConnection;
|
||||||
final terminalId = params['terminalId'] ?? _nextTerminalId++;
|
final terminalId = params['terminalId'] ?? _nextTerminalId++;
|
||||||
tabController.add(_createTerminalTab(
|
tabController.add(_createTerminalTab(
|
||||||
peerId: params['id'],
|
peerId: params['id'],
|
||||||
@@ -61,27 +67,20 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
String? connToken,
|
String? connToken,
|
||||||
}) {
|
}) {
|
||||||
final tabKey = '${peerId}_$terminalId';
|
final tabKey = '${peerId}_$terminalId';
|
||||||
|
final alias = bind.mainGetPeerOptionSync(id: peerId, key: 'alias');
|
||||||
|
final tabLabel =
|
||||||
|
alias.isNotEmpty ? '$alias #$terminalId' : '$peerId #$terminalId';
|
||||||
return TabInfo(
|
return TabInfo(
|
||||||
key: tabKey,
|
key: tabKey,
|
||||||
label: '$peerId #$terminalId',
|
label: tabLabel,
|
||||||
selectedIcon: selectedIcon,
|
selectedIcon: selectedIcon,
|
||||||
unselectedIcon: unselectedIcon,
|
unselectedIcon: unselectedIcon,
|
||||||
onTabCloseButton: () async {
|
onTabCloseButton: () => _closeTab(tabKey),
|
||||||
// Close the terminal session first
|
|
||||||
final ffi = TerminalConnectionManager.getExistingConnection(peerId);
|
|
||||||
if (ffi != null) {
|
|
||||||
final terminalModel = ffi.terminalModels[terminalId];
|
|
||||||
if (terminalModel != null) {
|
|
||||||
await terminalModel.closeTerminal();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Then close the tab
|
|
||||||
tabController.closeBy(tabKey);
|
|
||||||
},
|
|
||||||
page: TerminalPage(
|
page: TerminalPage(
|
||||||
key: ValueKey(tabKey),
|
key: ValueKey(tabKey),
|
||||||
id: peerId,
|
id: peerId,
|
||||||
terminalId: terminalId,
|
terminalId: terminalId,
|
||||||
|
tabKey: tabKey,
|
||||||
password: password,
|
password: password,
|
||||||
isSharedPassword: isSharedPassword,
|
isSharedPassword: isSharedPassword,
|
||||||
tabController: tabController,
|
tabController: tabController,
|
||||||
@@ -91,6 +90,161 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unified tab close handler for all close paths (button, shortcut, programmatic).
|
||||||
|
/// Shows audit dialog, cleans up session if not persistent, then removes the UI tab.
|
||||||
|
Future<void> _closeTab(String tabKey) async {
|
||||||
|
// Idempotency guard: skip if already closing this tab
|
||||||
|
if (_closingTabs.contains(tabKey)) return;
|
||||||
|
_closingTabs.add(tabKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Snapshot peerTabCount BEFORE any await to avoid race with concurrent
|
||||||
|
// _closeAllTabs clearing tabController (which would make the live count
|
||||||
|
// drop to 0 and incorrectly trigger session persistence).
|
||||||
|
// Note: the snapshot may become stale if other individual tabs are closed
|
||||||
|
// during the audit dialog, but this is an acceptable trade-off.
|
||||||
|
int? snapshotPeerTabCount;
|
||||||
|
final parsed = _parseTabKey(tabKey);
|
||||||
|
if (parsed != null) {
|
||||||
|
final (peerId, _) = parsed;
|
||||||
|
snapshotPeerTabCount = tabController.state.value.tabs.where((t) {
|
||||||
|
final p = _parseTabKey(t.key);
|
||||||
|
return p != null && p.$1 == peerId;
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: tabKey,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close terminal session if not in persistent mode.
|
||||||
|
// Wrapped separately so session cleanup failure never blocks UI tab removal.
|
||||||
|
try {
|
||||||
|
await _closeTerminalSessionIfNeeded(tabKey,
|
||||||
|
peerTabCount: snapshotPeerTabCount);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[TerminalTabPage] Session cleanup failed for $tabKey: $e');
|
||||||
|
}
|
||||||
|
// Always close the tab from UI, regardless of session cleanup result
|
||||||
|
tabController.closeBy(tabKey);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[TerminalTabPage] Error closing tab $tabKey: $e');
|
||||||
|
} finally {
|
||||||
|
_closingTabs.remove(tabKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close all tabs with session cleanup.
|
||||||
|
/// Used for window-level close operations (onDestroy, handleWindowCloseButton).
|
||||||
|
/// UI tabs are removed immediately; session cleanup runs in parallel with a
|
||||||
|
/// bounded timeout so window close is not blocked indefinitely.
|
||||||
|
Future<void> _closeAllTabs() async {
|
||||||
|
_windowClosing = true;
|
||||||
|
final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList();
|
||||||
|
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
|
||||||
|
// Keep the cleanup target lookup below synchronous before its first await:
|
||||||
|
// it relies on the current frame still retaining each TerminalPage's FFI/model.
|
||||||
|
tabController.clear();
|
||||||
|
// Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout).
|
||||||
|
// Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls.
|
||||||
|
final futures = tabKeys
|
||||||
|
.where((tabKey) => !_closingTabs.contains(tabKey))
|
||||||
|
.map((tabKey) async {
|
||||||
|
try {
|
||||||
|
await _closeTerminalSessionIfNeeded(tabKey, persistAll: true);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[TerminalTabPage] Session cleanup failed for $tabKey: $e');
|
||||||
|
}
|
||||||
|
}).toList();
|
||||||
|
if (futures.isNotEmpty) {
|
||||||
|
await Future.wait(futures).timeout(
|
||||||
|
const Duration(seconds: 4),
|
||||||
|
onTimeout: () {
|
||||||
|
debugPrint(
|
||||||
|
'[TerminalTabPage] Session cleanup timed out for batch close');
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the terminal session on server side based on persistent mode.
|
||||||
|
///
|
||||||
|
/// [persistAll] controls behavior when persistent mode is enabled:
|
||||||
|
/// - `true` (window close): persist all sessions, don't close any.
|
||||||
|
/// - `false` (tab close): only persist the last session for the peer,
|
||||||
|
/// close others so only the most recent disconnected session survives.
|
||||||
|
///
|
||||||
|
/// Note: if [_windowClosing] is true, persistAll is forced to true so that
|
||||||
|
/// in-flight _closeTab() calls don't accidentally close sessions that the
|
||||||
|
/// window-close flow intends to preserve.
|
||||||
|
Future<void> _closeTerminalSessionIfNeeded(String tabKey,
|
||||||
|
{bool persistAll = false, int? peerTabCount}) async {
|
||||||
|
// If window close is in progress, override to persist all sessions
|
||||||
|
// even if this call originated from an individual tab close.
|
||||||
|
if (_windowClosing) {
|
||||||
|
persistAll = true;
|
||||||
|
}
|
||||||
|
final parsed = _parseTabKey(tabKey);
|
||||||
|
if (parsed == null) return;
|
||||||
|
final (peerId, terminalId) = parsed;
|
||||||
|
|
||||||
|
final ffi = TerminalConnectionManager.getExistingConnection(peerId);
|
||||||
|
if (ffi == null) return;
|
||||||
|
|
||||||
|
final isPersistent = bind.sessionGetToggleOptionSync(
|
||||||
|
sessionId: ffi.sessionId,
|
||||||
|
arg: kOptionTerminalPersistent,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isPersistent) {
|
||||||
|
if (persistAll) {
|
||||||
|
// Window close: persist all sessions
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Tab close: only persist if this is the last tab for this peer.
|
||||||
|
// Use the snapshot value if provided (avoids race with concurrent tab removal).
|
||||||
|
final effectivePeerTabCount = peerTabCount ??
|
||||||
|
tabController.state.value.tabs.where((t) {
|
||||||
|
final p = _parseTabKey(t.key);
|
||||||
|
return p != null && p.$1 == peerId;
|
||||||
|
}).length;
|
||||||
|
if (effectivePeerTabCount <= 1) {
|
||||||
|
// Last tab for this peer — persist the session
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Not the last tab — fall through to close the session
|
||||||
|
}
|
||||||
|
|
||||||
|
final terminalModel = ffi.terminalModels[terminalId];
|
||||||
|
if (terminalModel != null) {
|
||||||
|
// closeTerminal() has internal 3s timeout, no need for external timeout
|
||||||
|
await terminalModel.closeTerminal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse tabKey (format: "peerId_terminalId") into its components.
|
||||||
|
/// Note: peerId may contain underscores, so we use lastIndexOf('_').
|
||||||
|
/// Returns null if tabKey format is invalid.
|
||||||
|
(String peerId, int terminalId)? _parseTabKey(String tabKey) {
|
||||||
|
final lastUnderscore = tabKey.lastIndexOf('_');
|
||||||
|
if (lastUnderscore <= 0) {
|
||||||
|
debugPrint('[TerminalTabPage] Invalid tabKey format: $tabKey');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final terminalIdStr = tabKey.substring(lastUnderscore + 1);
|
||||||
|
final terminalId = int.tryParse(terminalIdStr);
|
||||||
|
if (terminalId == null) {
|
||||||
|
debugPrint('[TerminalTabPage] Invalid terminalId in tabKey: $tabKey');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final peerId = tabKey.substring(0, lastUnderscore);
|
||||||
|
return (peerId, terminalId);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _tabMenuBuilder(String peerId, CancelFunc cancelFunc) {
|
Widget _tabMenuBuilder(String peerId, CancelFunc cancelFunc) {
|
||||||
final List<MenuEntryBase<String>> menu = [];
|
final List<MenuEntryBase<String>> menu = [];
|
||||||
const EdgeInsets padding = EdgeInsets.only(left: 8.0, right: 5.0);
|
const EdgeInsets padding = EdgeInsets.only(left: 8.0, right: 5.0);
|
||||||
@@ -174,7 +328,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
} else if (call.method == kWindowEventRestoreTerminalSessions) {
|
} else if (call.method == kWindowEventRestoreTerminalSessions) {
|
||||||
_restoreSessions(call.arguments);
|
_restoreSessions(call.arguments);
|
||||||
} else if (call.method == "onDestroy") {
|
} else if (call.method == "onDestroy") {
|
||||||
tabController.clear();
|
// Clean up sessions before window destruction (bounded wait)
|
||||||
|
await _closeAllTabs();
|
||||||
} else if (call.method == kWindowActionRebuild) {
|
} else if (call.method == kWindowActionRebuild) {
|
||||||
reloadCurrentWindow();
|
reloadCurrentWindow();
|
||||||
} else if (call.method == kWindowEventActiveSession) {
|
} else if (call.method == kWindowEventActiveSession) {
|
||||||
@@ -184,7 +339,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
final currentTab = tabController.state.value.selectedTabInfo;
|
final currentTab = tabController.state.value.selectedTabInfo;
|
||||||
assert(call.arguments is String,
|
assert(call.arguments is String,
|
||||||
"Expected String arguments for kWindowEventActiveSession, got ${call.arguments.runtimeType}");
|
"Expected String arguments for kWindowEventActiveSession, got ${call.arguments.runtimeType}");
|
||||||
if (currentTab.key.startsWith(call.arguments)) {
|
// Use lastIndexOf to handle peerIds containing underscores
|
||||||
|
final lastUnderscore = currentTab.key.lastIndexOf('_');
|
||||||
|
if (lastUnderscore > 0 &&
|
||||||
|
currentTab.key.substring(0, lastUnderscore) == call.arguments) {
|
||||||
windowOnTop(windowId());
|
windowOnTop(windowId());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -213,8 +371,34 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
final persistentSessions =
|
final persistentSessions =
|
||||||
args['persistent_sessions'] as List<dynamic>? ?? [];
|
args['persistent_sessions'] as List<dynamic>? ?? [];
|
||||||
final sortedSessions = persistentSessions.whereType<int>().toList()..sort();
|
final sortedSessions = persistentSessions.whereType<int>().toList()..sort();
|
||||||
|
var peerId = args['peer_id'] as String? ?? '';
|
||||||
|
if (peerId.isEmpty) {
|
||||||
|
if (tabController.state.value.tabs.isEmpty ||
|
||||||
|
tabController.state.value.selected >=
|
||||||
|
tabController.state.value.tabs.length) {
|
||||||
|
debugPrint('[TerminalTabPage] Skip restore: no selected tab');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final currentTab = tabController.state.value.selectedTabInfo;
|
||||||
|
final parsed = _parseTabKey(currentTab.key);
|
||||||
|
if (parsed == null) return;
|
||||||
|
peerId = parsed.$1;
|
||||||
|
}
|
||||||
|
final existingTerminalIds = tabController.state.value.tabs
|
||||||
|
.map((tab) => _parseTabKey(tab.key))
|
||||||
|
.where((parsed) => parsed != null && parsed.$1 == peerId)
|
||||||
|
.map((parsed) => parsed!.$2)
|
||||||
|
.toSet();
|
||||||
|
if (existingTerminalIds.isEmpty) {
|
||||||
|
debugPrint(
|
||||||
|
'[TerminalTabPage] Skip restore: no seed tab for peer $peerId');
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (final terminalId in sortedSessions) {
|
for (final terminalId in sortedSessions) {
|
||||||
_addNewTerminalForCurrentPeer(terminalId: terminalId);
|
if (!existingTerminalIds.add(terminalId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_addNewTerminal(peerId, terminalId: terminalId);
|
||||||
// A delay is required to ensure the UI has sufficient time to update
|
// A delay is required to ensure the UI has sufficient time to update
|
||||||
// before adding the next terminal. Without this delay, `_TerminalPageState::dispose()`
|
// before adding the next terminal. Without this delay, `_TerminalPageState::dispose()`
|
||||||
// may be called prematurely while the tab widget is still in the tab controller.
|
// may be called prematurely while the tab widget is still in the tab controller.
|
||||||
@@ -255,7 +439,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
// macOS: Cmd+W (standard for close tab)
|
// macOS: Cmd+W (standard for close tab)
|
||||||
final currentTab = tabController.state.value.selectedTabInfo;
|
final currentTab = tabController.state.value.selectedTabInfo;
|
||||||
if (tabController.state.value.tabs.length > 1) {
|
if (tabController.state.value.tabs.length > 1) {
|
||||||
tabController.closeBy(currentTab.key);
|
_closeTab(currentTab.key);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} else if (!isMacOS &&
|
} else if (!isMacOS &&
|
||||||
@@ -264,7 +448,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
// Other platforms: Ctrl+Shift+W (to avoid conflict with Ctrl+W word delete)
|
// Other platforms: Ctrl+Shift+W (to avoid conflict with Ctrl+W word delete)
|
||||||
final currentTab = tabController.state.value.selectedTabInfo;
|
final currentTab = tabController.state.value.selectedTabInfo;
|
||||||
if (tabController.state.value.tabs.length > 1) {
|
if (tabController.state.value.tabs.length > 1) {
|
||||||
tabController.closeBy(currentTab.key);
|
_closeTab(currentTab.key);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,7 +503,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
void _addNewTerminal(String peerId, {int? terminalId}) {
|
void _addNewTerminal(String peerId, {int? terminalId}) {
|
||||||
// Find first tab for this peer to get connection parameters
|
// Find first tab for this peer to get connection parameters
|
||||||
final firstTab = tabController.state.value.tabs.firstWhere(
|
final firstTab = tabController.state.value.tabs.firstWhere(
|
||||||
(tab) => tab.key.startsWith('$peerId\_'),
|
(tab) {
|
||||||
|
final last = tab.key.lastIndexOf('_');
|
||||||
|
return last > 0 && tab.key.substring(0, last) == peerId;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (firstTab.page is TerminalPage) {
|
if (firstTab.page is TerminalPage) {
|
||||||
final page = firstTab.page as TerminalPage;
|
final page = firstTab.page as TerminalPage;
|
||||||
@@ -340,12 +527,11 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
|
|
||||||
void _addNewTerminalForCurrentPeer({int? terminalId}) {
|
void _addNewTerminalForCurrentPeer({int? terminalId}) {
|
||||||
final currentTab = tabController.state.value.selectedTabInfo;
|
final currentTab = tabController.state.value.selectedTabInfo;
|
||||||
final parts = currentTab.key.split('_');
|
final parsed = _parseTabKey(currentTab.key);
|
||||||
if (parts.isNotEmpty) {
|
if (parsed == null) return;
|
||||||
final peerId = parts[0];
|
final (peerId, _) = parsed;
|
||||||
_addNewTerminal(peerId, terminalId: terminalId);
|
_addNewTerminal(peerId, terminalId: terminalId);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -358,10 +544,9 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
selectedBorderColor: MyTheme.accent,
|
selectedBorderColor: MyTheme.accent,
|
||||||
labelGetter: DesktopTab.tablabelGetter,
|
labelGetter: DesktopTab.tablabelGetter,
|
||||||
tabMenuBuilder: (key) {
|
tabMenuBuilder: (key) {
|
||||||
// Extract peerId from tab key (format: "peerId_terminalId")
|
final parsed = _parseTabKey(key);
|
||||||
final parts = key.split('_');
|
if (parsed == null) return Container();
|
||||||
if (parts.isEmpty) return Container();
|
final (peerId, _) = parsed;
|
||||||
final peerId = parts[0];
|
|
||||||
return _tabMenuBuilder(peerId, () {});
|
return _tabMenuBuilder(peerId, () {});
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
@@ -390,6 +575,11 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _closeWindowFromConnection() async {
|
||||||
|
await _closeAllTabs();
|
||||||
|
await WindowController.fromWindowId(windowId()).close();
|
||||||
|
}
|
||||||
|
|
||||||
int windowId() {
|
int windowId() {
|
||||||
return widget.params["windowId"];
|
return widget.params["windowId"];
|
||||||
}
|
}
|
||||||
@@ -407,8 +597,16 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
|
|
||||||
Future<bool> handleWindowCloseButton() async {
|
Future<bool> handleWindowCloseButton() async {
|
||||||
final connLength = tabController.state.value.tabs.length;
|
final connLength = tabController.state.value.tabs.length;
|
||||||
|
if (connLength == 1) {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: tabController.state.value.tabs[0].key,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (connLength <= 1) {
|
if (connLength <= 1) {
|
||||||
tabController.clear();
|
await _closeAllTabs();
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
final bool res;
|
final bool res;
|
||||||
@@ -419,7 +617,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
res = await closeConfirmDialog();
|
res = await closeConfirmDialog();
|
||||||
}
|
}
|
||||||
if (res) {
|
if (res) {
|
||||||
tabController.clear();
|
await _closeAllTabs();
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_hbb/common/widgets/remote_input.dart';
|
import 'package:flutter_hbb/common/widgets/remote_input.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
||||||
import 'package:flutter_hbb/models/state_model.dart';
|
import 'package:flutter_hbb/models/state_model.dart';
|
||||||
|
|
||||||
import '../../consts.dart';
|
import '../../consts.dart';
|
||||||
@@ -20,6 +19,7 @@ import '../../common/shared_state.dart';
|
|||||||
import '../../utils/image.dart';
|
import '../../utils/image.dart';
|
||||||
import '../widgets/remote_toolbar.dart';
|
import '../widgets/remote_toolbar.dart';
|
||||||
import '../widgets/kb_layout_type_chooser.dart';
|
import '../widgets/kb_layout_type_chooser.dart';
|
||||||
|
import '../widgets/raster_stall_monitor.dart';
|
||||||
import '../widgets/tabbar_widget.dart';
|
import '../widgets/tabbar_widget.dart';
|
||||||
|
|
||||||
import 'package:flutter_hbb/native/custom_cursor.dart'
|
import 'package:flutter_hbb/native/custom_cursor.dart'
|
||||||
@@ -77,6 +77,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
|||||||
String keyboardMode = "legacy";
|
String keyboardMode = "legacy";
|
||||||
bool _isWindowBlur = false;
|
bool _isWindowBlur = false;
|
||||||
final _cursorOverImage = false.obs;
|
final _cursorOverImage = false.obs;
|
||||||
|
final _uniqueKey = UniqueKey();
|
||||||
|
|
||||||
var _blockableOverlayState = BlockableOverlayState();
|
var _blockableOverlayState = BlockableOverlayState();
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
|||||||
super.initState();
|
super.initState();
|
||||||
_ffi = FFI(widget.sessionId);
|
_ffi = FFI(widget.sessionId);
|
||||||
Get.put<FFI>(_ffi, tag: widget.id);
|
Get.put<FFI>(_ffi, tag: widget.id);
|
||||||
|
RasterStallMonitor.start();
|
||||||
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
|
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
|
||||||
showKBLayoutTypeChooserIfNeeded(
|
showKBLayoutTypeChooserIfNeeded(
|
||||||
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
|
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
|
||||||
@@ -124,9 +126,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
|||||||
_ffi.dialogManager
|
_ffi.dialogManager
|
||||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||||
});
|
});
|
||||||
if (!isLinux) {
|
WakelockManager.enable(_uniqueKey);
|
||||||
WakelockPlus.enable();
|
|
||||||
}
|
|
||||||
|
|
||||||
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
|
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
|
||||||
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
|
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
|
||||||
@@ -185,26 +185,20 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
|||||||
if (isWindows) {
|
if (isWindows) {
|
||||||
_isWindowBlur = false;
|
_isWindowBlur = false;
|
||||||
}
|
}
|
||||||
if (!isLinux) {
|
WakelockManager.enable(_uniqueKey);
|
||||||
WakelockPlus.enable();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not.
|
// When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not.
|
||||||
@override
|
@override
|
||||||
void onWindowMaximize() {
|
void onWindowMaximize() {
|
||||||
super.onWindowMaximize();
|
super.onWindowMaximize();
|
||||||
if (!isLinux) {
|
WakelockManager.enable(_uniqueKey);
|
||||||
WakelockPlus.enable();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onWindowMinimize() {
|
void onWindowMinimize() {
|
||||||
super.onWindowMinimize();
|
super.onWindowMinimize();
|
||||||
if (!isLinux) {
|
WakelockManager.disable(_uniqueKey);
|
||||||
WakelockPlus.disable();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -230,7 +224,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
|||||||
// https://github.com/flutter/flutter/issues/64935
|
// https://github.com/flutter/flutter/issues/64935
|
||||||
super.dispose();
|
super.dispose();
|
||||||
debugPrint("VIEW CAMERA PAGE dispose session $sessionId ${widget.id}");
|
debugPrint("VIEW CAMERA PAGE dispose session $sessionId ${widget.id}");
|
||||||
_ffi.textureModel.onViewCameraPageDispose(closeSession);
|
_ffi.textureModel.onViewCameraPageDispose();
|
||||||
if (closeSession) {
|
if (closeSession) {
|
||||||
// ensure we leave this session, this is a double check
|
// ensure we leave this session, this is a double check
|
||||||
_ffi.inputModel.enterOrLeave(false);
|
_ffi.inputModel.enterOrLeave(false);
|
||||||
@@ -247,9 +241,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
|||||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||||
overlays: SystemUiOverlay.values);
|
overlays: SystemUiOverlay.values);
|
||||||
}
|
}
|
||||||
if (!isLinux) {
|
WakelockManager.disable(_uniqueKey);
|
||||||
await WakelockPlus.disable();
|
|
||||||
}
|
|
||||||
await Get.delete<FFI>(tag: widget.id);
|
await Get.delete<FFI>(tag: widget.id);
|
||||||
removeSharedStates(widget.id);
|
removeSharedStates(widget.id);
|
||||||
}
|
}
|
||||||
@@ -360,7 +352,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
|||||||
super.build(context);
|
super.build(context);
|
||||||
return WillPopScope(
|
return WillPopScope(
|
||||||
onWillPop: () async {
|
onWillPop: () async {
|
||||||
clientClose(sessionId, _ffi.dialogManager);
|
clientClose(sessionId, _ffi);
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
child: MultiProvider(providers: [
|
child: MultiProvider(providers: [
|
||||||
@@ -465,7 +457,6 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
|||||||
() => _ffi.ffiModel.pi.isSet.isFalse
|
() => _ffi.ffiModel.pi.isSet.isFalse
|
||||||
? Container(color: Colors.transparent)
|
? Container(color: Colors.transparent)
|
||||||
: Obx(() {
|
: Obx(() {
|
||||||
widget.toolbarState.initShow(sessionId);
|
|
||||||
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
|
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
|
||||||
return ImagePaint(
|
return ImagePaint(
|
||||||
id: widget.id,
|
id: widget.id,
|
||||||
@@ -527,7 +518,7 @@ class _ImagePaintState extends State<ImagePaint> {
|
|||||||
|
|
||||||
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
|
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
|
||||||
|
|
||||||
if (c.imageOverflow.isTrue && c.scrollStyle == ScrollStyle.scrollbar) {
|
if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) {
|
||||||
final paintWidth = c.getDisplayWidth() * s;
|
final paintWidth = c.getDisplayWidth() * s;
|
||||||
final paintHeight = c.getDisplayHeight() * s;
|
final paintHeight = c.getDisplayHeight() * s;
|
||||||
final paintSize = Size(paintWidth, paintHeight);
|
final paintSize = Size(paintWidth, paintHeight);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:desktop_multi_window/desktop_multi_window.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hbb/common.dart';
|
import 'package:flutter_hbb/common.dart';
|
||||||
import 'package:flutter_hbb/common/shared_state.dart';
|
import 'package:flutter_hbb/common/shared_state.dart';
|
||||||
|
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||||
import 'package:flutter_hbb/consts.dart';
|
import 'package:flutter_hbb/consts.dart';
|
||||||
import 'package:flutter_hbb/models/input_model.dart';
|
import 'package:flutter_hbb/models/input_model.dart';
|
||||||
import 'package:flutter_hbb/models/state_model.dart';
|
import 'package:flutter_hbb/models/state_model.dart';
|
||||||
@@ -79,7 +80,15 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
|
|||||||
label: peerId!,
|
label: peerId!,
|
||||||
selectedIcon: selectedIcon,
|
selectedIcon: selectedIcon,
|
||||||
unselectedIcon: unselectedIcon,
|
unselectedIcon: unselectedIcon,
|
||||||
onTabCloseButton: () => tabController.closeBy(peerId),
|
onTabCloseButton: () async {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: peerId!,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabController.closeBy(peerId!);
|
||||||
|
},
|
||||||
page: ViewCameraPage(
|
page: ViewCameraPage(
|
||||||
key: ValueKey(peerId),
|
key: ValueKey(peerId),
|
||||||
id: peerId!,
|
id: peerId!,
|
||||||
@@ -241,11 +250,11 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
|
|||||||
MenuEntryButton<String>(
|
MenuEntryButton<String>(
|
||||||
childBuilder: (TextStyle? style) => Obx(() => Text(
|
childBuilder: (TextStyle? style) => Obx(() => Text(
|
||||||
translate(
|
translate(
|
||||||
toolbarState.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
|
toolbarState.hide.isTrue ? 'Show Toolbar' : 'Hide Toolbar'),
|
||||||
style: style,
|
style: style,
|
||||||
)),
|
)),
|
||||||
proc: () {
|
proc: () {
|
||||||
toolbarState.switchShow(sessionId);
|
toolbarState.switchHide(sessionId);
|
||||||
cancelFunc();
|
cancelFunc();
|
||||||
},
|
},
|
||||||
padding: padding,
|
padding: padding,
|
||||||
@@ -287,7 +296,13 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
|
|||||||
translate('Close'),
|
translate('Close'),
|
||||||
style: style,
|
style: style,
|
||||||
),
|
),
|
||||||
proc: () {
|
proc: () async {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: key,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
tabController.closeBy(key);
|
tabController.closeBy(key);
|
||||||
cancelFunc();
|
cancelFunc();
|
||||||
},
|
},
|
||||||
@@ -340,6 +355,14 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
|
|||||||
|
|
||||||
Future<bool> handleWindowCloseButton() async {
|
Future<bool> handleWindowCloseButton() async {
|
||||||
final connLength = tabController.length;
|
final connLength = tabController.length;
|
||||||
|
if (connLength == 1) {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: tabController.state.value.tabs[0].key,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (connLength <= 1) {
|
if (connLength <= 1) {
|
||||||
tabController.clear();
|
tabController.clear();
|
||||||
return true;
|
return true;
|
||||||
@@ -393,7 +416,15 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
|
|||||||
label: id,
|
label: id,
|
||||||
selectedIcon: selectedIcon,
|
selectedIcon: selectedIcon,
|
||||||
unselectedIcon: unselectedIcon,
|
unselectedIcon: unselectedIcon,
|
||||||
onTabCloseButton: () => tabController.closeBy(id),
|
onTabCloseButton: () async {
|
||||||
|
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||||
|
id: id,
|
||||||
|
tabController: tabController,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabController.closeBy(id);
|
||||||
|
},
|
||||||
page: ViewCameraPage(
|
page: ViewCameraPage(
|
||||||
key: ValueKey(id),
|
key: ValueKey(id),
|
||||||
id: id,
|
id: id,
|
||||||
|
|||||||
52
flutter/lib/desktop/widgets/raster_stall_monitor.dart
Normal file
52
flutter/lib/desktop/widgets/raster_stall_monitor.dart
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
|
||||||
|
import '../../common.dart';
|
||||||
|
import '../../consts.dart';
|
||||||
|
import '../../models/platform_model.dart';
|
||||||
|
import '../../models/state_model.dart';
|
||||||
|
|
||||||
|
/// Records a hung raster thread (frames continuously scheduled but no frame
|
||||||
|
/// timings delivered for 30s) in `texture-render-health`; a hang cannot be
|
||||||
|
/// rescued in-process, so the next launch defaults texture rendering off.
|
||||||
|
class RasterStallMonitor {
|
||||||
|
static bool _started = false;
|
||||||
|
static bool _reported = false;
|
||||||
|
static DateTime? _lastTimings;
|
||||||
|
static DateTime _lastQuiet = DateTime.now();
|
||||||
|
|
||||||
|
static void start() {
|
||||||
|
if (_started || isWeb) return;
|
||||||
|
_started = true;
|
||||||
|
SchedulerBinding.instance.addTimingsCallback((_) {
|
||||||
|
_lastTimings = DateTime.now();
|
||||||
|
});
|
||||||
|
Timer.periodic(const Duration(seconds: 2), (_) {
|
||||||
|
if (_reported) return;
|
||||||
|
final now = DateTime.now();
|
||||||
|
final lifecycle = SchedulerBinding.instance.lifecycleState;
|
||||||
|
// Minimized/inactive (incl. screen lock) or idle (nothing scheduled):
|
||||||
|
// no timings is legitimate, keep moving the quiet anchor forward.
|
||||||
|
if (stateGlobal.isMinimized ||
|
||||||
|
(lifecycle != null && lifecycle != AppLifecycleState.resumed) ||
|
||||||
|
!SchedulerBinding.instance.hasScheduledFrame) {
|
||||||
|
_lastQuiet = now;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var ref = _lastQuiet;
|
||||||
|
final lastTimings = _lastTimings;
|
||||||
|
if (lastTimings != null && lastTimings.isAfter(ref)) {
|
||||||
|
ref = lastTimings;
|
||||||
|
}
|
||||||
|
if (now.difference(ref) > const Duration(seconds: 30)) {
|
||||||
|
_reported = true;
|
||||||
|
bind.mainSetLocalOption(
|
||||||
|
key: kOptionTextureRenderHealth, value: 'failed-raster-stall');
|
||||||
|
debugPrint(
|
||||||
|
'raster thread stall detected, texture rendering disabled for next launch');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import 'package:flutter_hbb/consts.dart';
|
|||||||
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||||
import 'package:flutter_hbb/desktop/pages/view_camera_page.dart';
|
import 'package:flutter_hbb/desktop/pages/view_camera_page.dart';
|
||||||
import 'package:flutter_hbb/main.dart';
|
import 'package:flutter_hbb/main.dart';
|
||||||
|
import 'package:flutter_hbb/models/model.dart';
|
||||||
import 'package:flutter_hbb/models/platform_model.dart';
|
import 'package:flutter_hbb/models/platform_model.dart';
|
||||||
import 'package:flutter_hbb/models/state_model.dart';
|
import 'package:flutter_hbb/models/state_model.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
@@ -99,6 +100,7 @@ class DesktopTabController {
|
|||||||
/// index, key
|
/// index, key
|
||||||
Function(int, String)? onRemoved;
|
Function(int, String)? onRemoved;
|
||||||
Function(String)? onSelected;
|
Function(String)? onSelected;
|
||||||
|
Future<void> Function()? onCloseWindow;
|
||||||
|
|
||||||
DesktopTabController(
|
DesktopTabController(
|
||||||
{required this.tabType, this.onRemoved, this.onSelected});
|
{required this.tabType, this.onRemoved, this.onSelected});
|
||||||
@@ -292,7 +294,6 @@ class DesktopTab extends StatefulWidget {
|
|||||||
// ignore: must_be_immutable
|
// ignore: must_be_immutable
|
||||||
class _DesktopTabState extends State<DesktopTab>
|
class _DesktopTabState extends State<DesktopTab>
|
||||||
with MultiWindowListener, WindowListener {
|
with MultiWindowListener, WindowListener {
|
||||||
final _saveFrameDebounce = Debouncer(delay: Duration(seconds: 1));
|
|
||||||
Timer? _macOSCheckRestoreTimer;
|
Timer? _macOSCheckRestoreTimer;
|
||||||
int _macOSCheckRestoreCounter = 0;
|
int _macOSCheckRestoreCounter = 0;
|
||||||
|
|
||||||
@@ -370,7 +371,7 @@ class _DesktopTabState extends State<DesktopTab>
|
|||||||
|
|
||||||
void _setMaximized(bool maximize) {
|
void _setMaximized(bool maximize) {
|
||||||
stateGlobal.setMaximized(maximize);
|
stateGlobal.setMaximized(maximize);
|
||||||
_saveFrameDebounce.call(_saveFrame);
|
_saveFrame();
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,6 +389,7 @@ class _DesktopTabState extends State<DesktopTab>
|
|||||||
void onWindowMinimize() {
|
void onWindowMinimize() {
|
||||||
stateGlobal.setMinimized(true);
|
stateGlobal.setMinimized(true);
|
||||||
stateGlobal.setMaximized(false);
|
stateGlobal.setMaximized(false);
|
||||||
|
_updateSessionsRenderVisible(false);
|
||||||
super.onWindowMinimize();
|
super.onWindowMinimize();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,6 +397,7 @@ class _DesktopTabState extends State<DesktopTab>
|
|||||||
void onWindowMaximize() {
|
void onWindowMaximize() {
|
||||||
stateGlobal.setMinimized(false);
|
stateGlobal.setMinimized(false);
|
||||||
_setMaximized(true);
|
_setMaximized(true);
|
||||||
|
_updateSessionsRenderVisible(true);
|
||||||
super.onWindowMaximize();
|
super.onWindowMaximize();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,27 +405,57 @@ class _DesktopTabState extends State<DesktopTab>
|
|||||||
void onWindowUnmaximize() {
|
void onWindowUnmaximize() {
|
||||||
stateGlobal.setMinimized(false);
|
stateGlobal.setMinimized(false);
|
||||||
_setMaximized(false);
|
_setMaximized(false);
|
||||||
|
_updateSessionsRenderVisible(true);
|
||||||
super.onWindowUnmaximize();
|
super.onWindowUnmaximize();
|
||||||
}
|
}
|
||||||
|
|
||||||
_saveFrame() async {
|
@override
|
||||||
|
void onWindowRestore() {
|
||||||
|
// A plain restore (no maximize involved) must clear the minimized flag.
|
||||||
|
stateGlobal.setMinimized(false);
|
||||||
|
_updateSessionsRenderVisible(true);
|
||||||
|
super.onWindowRestore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hidden window composites nothing; pause the Rust-side texture watchdog
|
||||||
|
// for its sessions so it cannot record a false failure.
|
||||||
|
void _updateSessionsRenderVisible(bool visible) {
|
||||||
|
if (tabType != DesktopTabType.remoteScreen &&
|
||||||
|
tabType != DesktopTabType.viewCamera) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (final tab in controller.state.value.tabs) {
|
||||||
|
try {
|
||||||
|
final ffi = Get.find<FFI>(tag: tab.key);
|
||||||
|
bind.sessionSetRenderVisible(
|
||||||
|
sessionId: ffi.sessionId, visible: visible);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_saveFrame({bool? flush}) async {
|
||||||
|
try {
|
||||||
if (tabType == DesktopTabType.main) {
|
if (tabType == DesktopTabType.main) {
|
||||||
await saveWindowPosition(WindowType.Main);
|
await saveWindowPosition(WindowType.Main, flush: flush);
|
||||||
} else if (kWindowType != null && kWindowId != null) {
|
} else if (kWindowType != null && kWindowId != null) {
|
||||||
await saveWindowPosition(kWindowType!, windowId: kWindowId);
|
await saveWindowPosition(kWindowType!,
|
||||||
|
windowId: kWindowId, flush: flush);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving window position: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onWindowMoved() {
|
void onWindowMoved() {
|
||||||
_saveFrameDebounce.call(_saveFrame);
|
_saveFrame();
|
||||||
super.onWindowMoved();
|
super.onWindowMoved();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onWindowResized() {
|
void onWindowResized() {
|
||||||
_saveFrameDebounce.call(_saveFrame);
|
_saveFrame();
|
||||||
super.onWindowMoved();
|
super.onWindowResized();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -460,6 +493,8 @@ class _DesktopTabState extends State<DesktopTab>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _saveFrame(flush: true);
|
||||||
|
|
||||||
// hide window on close
|
// hide window on close
|
||||||
if (isMainWindow) {
|
if (isMainWindow) {
|
||||||
if (rustDeskWinManager.getActiveWindows().contains(kMainWindowId)) {
|
if (rustDeskWinManager.getActiveWindows().contains(kMainWindowId)) {
|
||||||
@@ -586,14 +621,13 @@ class _DesktopTabState extends State<DesktopTab>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBar() {
|
Widget _buildBar() {
|
||||||
|
final isIncomingHomePage = bind.isIncomingOnly() && isInHomePage();
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
// custom double tap handler
|
// custom double tap handler
|
||||||
onTap: !(bind.isIncomingOnly() && isInHomePage()) &&
|
onTap: !isIncomingHomePage && showMaximize
|
||||||
showMaximize
|
|
||||||
? () {
|
? () {
|
||||||
final current = DateTime.now().millisecondsSinceEpoch;
|
final current = DateTime.now().millisecondsSinceEpoch;
|
||||||
final elapsed = current - _lastClickTime;
|
final elapsed = current - _lastClickTime;
|
||||||
@@ -604,7 +638,7 @@ class _DesktopTabState extends State<DesktopTab>
|
|||||||
.then((value) => stateGlobal.setMaximized(value));
|
.then((value) => stateGlobal.setMaximized(value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: null,
|
: (isIncomingHomePage ? () {} : null), // Keep tap recognizer for Windows touch.
|
||||||
onPanStart: (_) => startDragging(isMainWindow),
|
onPanStart: (_) => startDragging(isMainWindow),
|
||||||
onPanCancel: () {
|
onPanCancel: () {
|
||||||
// We want to disable dragging of the tab area in the tab bar.
|
// We want to disable dragging of the tab area in the tab bar.
|
||||||
@@ -1079,11 +1113,12 @@ class _TabState extends State<_Tab> with RestorationMixin {
|
|||||||
return ConstrainedBox(
|
return ConstrainedBox(
|
||||||
constraints: BoxConstraints(maxWidth: widget.maxLabelWidth ?? 200),
|
constraints: BoxConstraints(maxWidth: widget.maxLabelWidth ?? 200),
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: widget.tabType == DesktopTabType.main
|
message:
|
||||||
? ''
|
widget.tabType == DesktopTabType.main ? '' : widget.label.value,
|
||||||
: translate(widget.label.value),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
translate(widget.label.value),
|
widget.tabType == DesktopTabType.main
|
||||||
|
? translate(widget.label.value)
|
||||||
|
: widget.label.value,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
|
|||||||
172
flutter/lib/desktop/widgets/texture_render_probe.dart
Normal file
172
flutter/lib/desktop/widgets/texture_render_probe.dart
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
|
||||||
|
import '../../common.dart';
|
||||||
|
import '../../consts.dart';
|
||||||
|
import '../../models/platform_model.dart';
|
||||||
|
import '../../models/state_model.dart';
|
||||||
|
|
||||||
|
import 'package:texture_rgba_renderer/texture_rgba_renderer.dart'
|
||||||
|
if (dart.library.html) 'package:flutter_hbb/web/texture_rgba_renderer.dart';
|
||||||
|
|
||||||
|
/// Startup probe: renders one frame through a 1x1 external texture and
|
||||||
|
/// records in `texture-render-health` whether the engine consumed it, so a
|
||||||
|
/// broken environment is detected before the first session goes black.
|
||||||
|
class TextureRenderProbe extends StatefulWidget {
|
||||||
|
const TextureRenderProbe({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TextureRenderProbe> createState() => _TextureRenderProbeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TextureRenderProbeState extends State<TextureRenderProbe> {
|
||||||
|
static bool _ranThisLaunch = false;
|
||||||
|
final _renderer = TextureRgbaRenderer();
|
||||||
|
int _textureId = -1;
|
||||||
|
int _textureKey = -1;
|
||||||
|
int _ptr = 0;
|
||||||
|
Timer? _timer;
|
||||||
|
int _ticks = 0;
|
||||||
|
bool _sawTimings = false;
|
||||||
|
bool _wasEffectiveOn = false;
|
||||||
|
DateTime? _lastTimings;
|
||||||
|
DateTime? _firstPush;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (_ranThisLaunch || isWeb || !isDesktop) return;
|
||||||
|
_ranThisLaunch = true;
|
||||||
|
if (bind.isIncomingOnly()) return;
|
||||||
|
// An old plugin without the consumed counter cannot be judged, and a
|
||||||
|
// recorded raster-stall means compositing a texture may hang this window.
|
||||||
|
if (!bind.mainTextureRenderProbeSupported()) return;
|
||||||
|
if (bind
|
||||||
|
.mainGetLocalOption(key: kOptionTextureRenderHealth)
|
||||||
|
.startsWith('failed-raster-stall')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_wasEffectiveOn = bind.mainGetUseTextureRender();
|
||||||
|
// Only probe after the window has really rendered a frame: a hidden
|
||||||
|
// window (silent/tray start) must not record a false failure.
|
||||||
|
SchedulerBinding.instance.addTimingsCallback(_onTimings);
|
||||||
|
Future.delayed(const Duration(seconds: 5), () {
|
||||||
|
if (!_sawTimings) {
|
||||||
|
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
|
||||||
|
_finish(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTimings(List<FrameTiming> timings) {
|
||||||
|
_lastTimings = DateTime.now();
|
||||||
|
if (_sawTimings) return;
|
||||||
|
_sawTimings = true;
|
||||||
|
_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _start() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
_textureKey = bind.getNextTextureKey();
|
||||||
|
final id = await _renderer.createTexture(_textureKey);
|
||||||
|
if (!mounted || id == -1) {
|
||||||
|
_finish(!mounted ? null : false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ptr = await _renderer.getTexturePtr(_textureKey);
|
||||||
|
if (!mounted || _ptr <= 0) {
|
||||||
|
_finish(!mounted ? null : false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _textureId = id);
|
||||||
|
_timer = Timer.periodic(const Duration(milliseconds: 100), (_) {
|
||||||
|
_ticks += 1;
|
||||||
|
_firstPush ??= DateTime.now();
|
||||||
|
bind.mainPushTextureProbeFrame(ptr: _ptr);
|
||||||
|
final consumed = bind.mainGetTextureProbeConsumed(ptr: _ptr) > 0;
|
||||||
|
// "Consumed" advances inside the plugin callback, before the GL/Metal
|
||||||
|
// upload; only a frame timing after the push proves a completed frame.
|
||||||
|
final frameCompleted = consumed &&
|
||||||
|
_lastTimings != null &&
|
||||||
|
_lastTimings!.isAfter(_firstPush!);
|
||||||
|
if (frameCompleted) {
|
||||||
|
_finish(true);
|
||||||
|
} else if (_ticks >= 10) {
|
||||||
|
if (consumed) {
|
||||||
|
_finish(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only a window that is visibly compositing can prove a failure.
|
||||||
|
final lifecycle = SchedulerBinding.instance.lifecycleState;
|
||||||
|
final active =
|
||||||
|
lifecycle == null || lifecycle == AppLifecycleState.resumed;
|
||||||
|
final timingsFresh = _lastTimings != null &&
|
||||||
|
DateTime.now().difference(_lastTimings!) <
|
||||||
|
const Duration(milliseconds: 1500);
|
||||||
|
_finish(!stateGlobal.isMinimized && active && timingsFresh
|
||||||
|
? false
|
||||||
|
: null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _finish(bool? ok) {
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
|
||||||
|
if (ok != null) {
|
||||||
|
final old = bind.mainGetLocalOption(key: kOptionTextureRenderHealth);
|
||||||
|
if (ok) {
|
||||||
|
// This rgba probe disproves only the rgba black-texture class: gpu
|
||||||
|
// failures and raster stalls clear via the option toggle alone.
|
||||||
|
final clearable = old.isEmpty ||
|
||||||
|
old.startsWith('failed-probe') ||
|
||||||
|
old.startsWith('failed-watchdog-rgba');
|
||||||
|
if (clearable) {
|
||||||
|
bind.mainSetLocalOption(key: kOptionTextureRenderHealth, value: 'ok');
|
||||||
|
}
|
||||||
|
} else if (!old.startsWith('failed')) {
|
||||||
|
debugPrint('texture render probe failed, disabling texture rendering');
|
||||||
|
bind.mainSetLocalOption(
|
||||||
|
key: kOptionTextureRenderHealth, value: 'failed-probe');
|
||||||
|
if (_wasEffectiveOn) {
|
||||||
|
showToast(translate('texture-render-fallback-tip'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_textureKey != -1) {
|
||||||
|
_renderer.closeTexture(_textureKey);
|
||||||
|
_textureKey = -1;
|
||||||
|
}
|
||||||
|
_ptr = 0;
|
||||||
|
if (mounted && _textureId != -1) {
|
||||||
|
setState(() => _textureId = -1);
|
||||||
|
} else {
|
||||||
|
_textureId = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
|
||||||
|
if (_textureKey != -1) {
|
||||||
|
_renderer.closeTexture(_textureKey);
|
||||||
|
_textureKey = -1;
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_textureId == -1) return const SizedBox.shrink();
|
||||||
|
// Must actually composite for the engine to sample the texture; the
|
||||||
|
// pushed pixel is fully transparent.
|
||||||
|
return IgnorePointer(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 1, height: 1, child: Texture(textureId: _textureId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import 'common.dart';
|
|||||||
import 'consts.dart';
|
import 'consts.dart';
|
||||||
import 'mobile/pages/home_page.dart';
|
import 'mobile/pages/home_page.dart';
|
||||||
import 'mobile/pages/server_page.dart';
|
import 'mobile/pages/server_page.dart';
|
||||||
|
import 'mobile/widgets/deploy_dialog.dart';
|
||||||
import 'models/platform_model.dart';
|
import 'models/platform_model.dart';
|
||||||
|
|
||||||
import 'package:flutter_hbb/plugin/handlers.dart'
|
import 'package:flutter_hbb/plugin/handlers.dart'
|
||||||
@@ -575,11 +576,20 @@ _registerEventHandler() {
|
|||||||
NativeUiHandler.instance.onEvent(evt);
|
NativeUiHandler.instance.onEvent(evt);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (isAndroid) {
|
||||||
|
platformFFI.registerEventHandler(
|
||||||
|
'android_needs_deploy', 'android_needs_deploy', (_) async {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
showDeployPromptDialog();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget keyListenerBuilder(BuildContext context, Widget? child) {
|
Widget keyListenerBuilder(BuildContext context, Widget? child) {
|
||||||
return RawKeyboardListener(
|
return RawKeyboardListener(
|
||||||
focusNode: FocusNode(),
|
// `skipTraversal: isWeb` is to fix "Bad state: RenderBox was not laid out: minified:aeL#c19e4"
|
||||||
|
focusNode: FocusNode(skipTraversal: isWeb),
|
||||||
child: child ?? Container(),
|
child: child ?? Container(),
|
||||||
onKey: (RawKeyEvent event) {
|
onKey: (RawKeyEvent event) {
|
||||||
if (event.logicalKey == LogicalKeyboardKey.shiftLeft) {
|
if (event.logicalKey == LogicalKeyboardKey.shiftLeft) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user