mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-22 19:08:51 +00:00
Compare commits
618
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84dc9c4f22 | ||
|
|
f6bfd6953f | ||
|
|
58d891f63d | ||
|
|
62ade9b9ad | ||
|
|
330f80fcf5 | ||
|
|
da5fe1359b | ||
|
|
546c09b3ba | ||
|
|
5fcf82abfc | ||
|
|
58923a22e8 | ||
|
|
426217327b | ||
|
|
c1347d28c5 | ||
|
|
6020d94d55 | ||
|
|
a3e8c86331 | ||
|
|
9f01b21a54 | ||
|
|
5633453f5b | ||
|
|
460985672c | ||
|
|
aa90259f1c | ||
|
|
b5cba7c885 | ||
|
|
4c1f2b978b | ||
|
|
1328092a7e | ||
|
|
45c68177d0 | ||
|
|
090266ad40 | ||
|
|
d07b55104d | ||
|
|
3353d2d5d3 | ||
|
|
946006f100 | ||
|
|
f7fb03f25d | ||
|
|
9415ef6579 | ||
|
|
2e32a1659c | ||
|
|
edc2845324 | ||
|
|
8f9be76fac | ||
|
|
92714667f6 | ||
|
|
02c4d7d825 | ||
|
|
fd98f572d2 | ||
|
|
4a572572db | ||
|
|
01c3f0bca6 | ||
|
|
d40728d0b8 | ||
|
|
f757044d8c | ||
|
|
d0539b83e9 | ||
|
|
7cb0b68d73 | ||
|
|
3881cce291 | ||
|
|
3f36adfa90 | ||
|
|
add7806197 | ||
|
|
de28945484 | ||
|
|
313a38c0a8 | ||
|
|
1e85d54952 | ||
|
|
9e28e11954 | ||
|
|
48d74df226 | ||
|
|
601ff4218b | ||
|
|
4771eb45fa | ||
|
|
baa58b21fa | ||
|
|
24eec5d6bb | ||
|
|
2ac4ce3a73 | ||
|
|
d84aa7f1e1 | ||
|
|
ca94b5fe6f | ||
|
|
3fadbf4ba9 | ||
|
|
6baeb53b20 | ||
|
|
a96222012b | ||
|
|
6233b90ba7 | ||
|
|
c605bd5781 | ||
|
|
3134849b81 | ||
|
|
5fba7e5dba | ||
|
|
ec325c648c | ||
|
|
cca1058b4a | ||
|
|
99dc199bb8 | ||
|
|
22bdad22fe | ||
|
|
d9e79d604b | ||
|
|
b31ef2273a | ||
|
|
7fe0770b9d | ||
|
|
6023d3547a | ||
|
|
15533338e8 | ||
|
|
ee3e3f0bd4 | ||
|
|
9de5b333ac | ||
|
|
9b4e45aa0b | ||
|
|
0dac325868 | ||
|
|
0ef8966844 | ||
|
|
e4f2c7fc4c | ||
|
|
f44d7ed598 | ||
|
|
102f553f2c | ||
|
|
69806c9edd | ||
|
|
c42207fdee | ||
|
|
fbe953e9b0 | ||
|
|
b6820d6710 | ||
|
|
ab6cf4fd58 | ||
|
|
7dcd7fd05b | ||
|
|
51e02a6b1a | ||
|
|
b4d58a362e | ||
|
|
6e15e30790 | ||
|
|
b9d587e80e | ||
|
|
fa4821bcea | ||
|
|
de06c1ed5f | ||
|
|
b0e9e87129 | ||
|
|
52df73c44c | ||
|
|
40c744da10 | ||
|
|
9e2596e975 | ||
|
|
d27f45e063 | ||
|
|
9aeb9fb752 | ||
|
|
60fa813eb2 | ||
|
|
d1d8c3f652 | ||
|
|
11455e0fe9 | ||
|
|
0efddc3894 | ||
|
|
12d43b1f99 | ||
|
|
41d465a678 | ||
|
|
d8e64a73bf | ||
|
|
771f7e869f | ||
|
|
657a8f4df6 | ||
|
|
ab10e37d43 | ||
|
|
23bdebd973 | ||
|
|
d9584410af | ||
|
|
c4536f412b | ||
|
|
7fd44641f2 | ||
|
|
3723fcffbd | ||
|
|
120b6830ff | ||
|
|
a52e2bb6b3 | ||
|
|
8408cc72b8 | ||
|
|
0b40964202 | ||
|
|
3489e01a22 | ||
|
|
d2606b3f51 | ||
|
|
4b29d8c34d | ||
|
|
d331db00aa | ||
|
|
f40c600e67 | ||
|
|
9c2d61f6ae | ||
|
|
e4fe308cdf | ||
|
|
01daf92fc3 | ||
|
|
d4394e87cc | ||
|
|
c56a65f58b | ||
|
|
924310c124 | ||
|
|
0404448f74 | ||
|
|
4ee413acde | ||
|
|
1f6010e07c | ||
|
|
90d7521840 | ||
|
|
665a578957 | ||
|
|
44ac32db0c | ||
|
|
f948e08fbc | ||
|
|
65f5771a6e | ||
|
|
33d59932f9 | ||
|
|
049f97a4c3 | ||
|
|
62a291fcb8 | ||
|
|
7459cc7f86 | ||
|
|
d27ae11d15 | ||
|
|
b640069961 | ||
|
|
9f935718fe | ||
|
|
1c863aa9f5 | ||
|
|
891f0acb3c | ||
|
|
55c05338a1 | ||
|
|
4e3fd82bd1 | ||
|
|
41b08a2393 | ||
|
|
730e377418 | ||
|
|
175fdfa428 | ||
|
|
d60a27b675 | ||
|
|
fd6a7e0ba3 | ||
|
|
db8bf6ba33 | ||
|
|
4d9cde8fee | ||
|
|
f8458b70a8 | ||
|
|
0a84dee777 | ||
|
|
46316c9895 | ||
|
|
0e1ccb55d2 | ||
|
|
ec8a506353 | ||
|
|
eb741a362d | ||
|
|
97e422226f | ||
|
|
8ba09001cd | ||
|
|
ecad646330 | ||
|
|
87cb799783 | ||
|
|
466fd249fe | ||
|
|
a4ba67ac22 | ||
|
|
99b6495b7d | ||
|
|
6eb9f38487 | ||
|
|
bf4d9e0f6f | ||
|
|
9418e2c8bb | ||
|
|
c4c88900c4 | ||
|
|
aadaa2d975 | ||
|
|
580eee3572 | ||
|
|
e359d856f2 | ||
|
|
e08c1c598f | ||
|
|
4df3702dbb | ||
|
|
b7c692171d | ||
|
|
d95aef1d0c | ||
|
|
887e374c58 | ||
|
|
34dcf852d5 | ||
|
|
5e7eb51e46 | ||
|
|
d40fd80be7 | ||
|
|
1c5b4d8450 | ||
|
|
ef69a7f8e9 | ||
|
|
a1849aae15 | ||
|
|
1e25e239c2 | ||
|
|
7bda68876b | ||
|
|
362bdce73b | ||
|
|
5e99e71728 | ||
|
|
09e85240ea | ||
|
|
7998845515 | ||
|
|
e8eca728fe | ||
|
|
438d468c76 | ||
|
|
07130947f9 | ||
|
|
16e26b69ff | ||
|
|
cd5c70a244 | ||
|
|
e6240f6229 | ||
|
|
1498c0ea52 | ||
|
|
67cf5ecfd5 | ||
|
|
0c3c00db99 | ||
|
|
7988e48b5d | ||
|
|
0105724108 | ||
|
|
2894195ab3 | ||
|
|
02156c0edc | ||
|
|
06844dc703 | ||
|
|
2d1a7c973d | ||
|
|
77c9ccd649 | ||
|
|
95b32053d5 | ||
|
|
b1760ff098 | ||
|
|
b0aa597787 | ||
|
|
dcb36e376f | ||
|
|
139195cb0c | ||
|
|
991cb604c2 | ||
|
|
1ef42e6504 | ||
|
|
d7de56f046 | ||
|
|
4bc63d506c | ||
|
|
567110f063 | ||
|
|
ffde863e92 | ||
|
|
e2b567d5b3 | ||
|
|
77b6f1fc12 | ||
|
|
97b10b637d | ||
|
|
7da4cf5e31 | ||
|
|
a8416b27c1 | ||
|
|
9552e530be | ||
|
|
fe5a22e468 | ||
|
|
59fe9b5ea8 | ||
|
|
862d73bf9f | ||
|
|
9dcfe4ecbc | ||
|
|
2fe7a9449a | ||
|
|
916ee3276c | ||
|
|
97f9c2fc5d | ||
|
|
4a2d19f0db | ||
|
|
a7bcc6d6ff | ||
|
|
f4d53a659b | ||
|
|
3074db1cbb | ||
|
|
4f9023d80f | ||
|
|
825bb708df | ||
|
|
55db465d8e | ||
|
|
e9e2d87405 | ||
|
|
44128f7e97 | ||
|
|
782c9757f3 | ||
|
|
5454bdc244 | ||
|
|
843b00b22f | ||
|
|
9fa99f8594 | ||
|
|
85d97a831c | ||
|
|
7d0f9d7518 | ||
|
|
4588832c80 | ||
|
|
bc5b474293 | ||
|
|
18e3070d4c | ||
|
|
3062c6c4d6 | ||
|
|
4376f659ac | ||
|
|
0ba8e30fbc | ||
|
|
fdf397d7dc | ||
|
|
d1fcf69b7b | ||
|
|
49010614fd | ||
|
|
778b8c6807 | ||
|
|
1593b163fb | ||
|
|
102429e6b5 | ||
|
|
6ce65b24f4 | ||
|
|
a4f4869443 | ||
|
|
a57976e88f | ||
|
|
3c28f605d1 | ||
|
|
76a67e7ee5 | ||
|
|
330b237c9c | ||
|
|
7689af50d6 | ||
|
|
d769320d45 | ||
|
|
01c0b38669 | ||
|
|
b61122ccca | ||
|
|
4fbe8d2976 | ||
|
|
8c731ddedc | ||
|
|
524b538908 | ||
|
|
72b826d65b | ||
|
|
0d125374d3 | ||
|
|
745e599118 | ||
|
|
ffc01cbac9 | ||
|
|
d2e0ac40aa | ||
|
|
4ed5190d7f | ||
|
|
3ce80b25ab | ||
|
|
ff3981de73 | ||
|
|
f32e8c5d2d | ||
|
|
4812b3ebfa | ||
|
|
2c51dbe4e3 | ||
|
|
261225340f | ||
|
|
0444dc39a0 | ||
|
|
01463fb78e | ||
|
|
3acffcd362 | ||
|
|
2ea8c3f86f | ||
|
|
9ba207e900 | ||
|
|
6551f870b6 | ||
|
|
7a9e9de38c | ||
|
|
1d5c9ae010 | ||
|
|
8199623267 | ||
|
|
6ea957ec5d | ||
|
|
51549f9a60 | ||
|
|
3d7191f4d3 | ||
|
|
d25e302a57 | ||
|
|
4004aecaea | ||
|
|
b29cc01d55 | ||
|
|
5a10623414 | ||
|
|
be875da985 | ||
|
|
2a3309902a | ||
|
|
f3e6b00e1a | ||
|
|
58e33f8fbb | ||
|
|
ef375c623e | ||
|
|
e4d0b92e55 | ||
|
|
650049b469 | ||
|
|
731bb0247c | ||
|
|
b13fbb52b3 | ||
|
|
48f75c92da | ||
|
|
f39b9a11fd | ||
|
|
120aa8a7a0 | ||
|
|
09ad21489b | ||
|
|
0a2ed2fb65 | ||
|
|
4b29532261 | ||
|
|
22d5f17828 | ||
|
|
4dc7c1b59c | ||
|
|
d0285d236e | ||
|
|
a89031eab2 | ||
|
|
7a819dd190 | ||
|
|
6291e43605 | ||
|
|
cc80bd2a5d | ||
|
|
908906f3f4 | ||
|
|
9894ee0e65 | ||
|
|
62a10ea460 | ||
|
|
8f58c39bd7 | ||
|
|
2d78f40e60 | ||
|
|
f6437548ba | ||
|
|
b2d5dfecc1 | ||
|
|
62ed3452d6 | ||
|
|
d96d03f452 | ||
|
|
cf06f2ca30 | ||
|
|
42c8e762a0 | ||
|
|
dd84097b6d | ||
|
|
8de536960e | ||
|
|
466bf6a813 | ||
|
|
e100c165fb | ||
|
|
370d9f7adc | ||
|
|
fbe746a776 | ||
|
|
a51b8d2271 | ||
|
|
68e130aae6 | ||
|
|
50f1237617 | ||
|
|
a95f95ae37 | ||
|
|
9853ffce61 | ||
|
|
6215cae877 | ||
|
|
2f4d3894b5 | ||
|
|
bf506712ba | ||
|
|
c230a97cca | ||
|
|
f2eb75da0b | ||
|
|
7d6a3ce137 | ||
|
|
18381ff701 | ||
|
|
3d112c4b8c | ||
|
|
18d4715cd9 | ||
|
|
25beadea65 | ||
|
|
ac7c91fc9c | ||
|
|
de1d2f77df | ||
|
|
aeeb1a7cde | ||
|
|
fa3d0873ed | ||
|
|
4aa6ac6fcf | ||
|
|
b53967b423 | ||
|
|
8bcb0f78dd | ||
|
|
e50bafbb75 | ||
|
|
54f9212a2f | ||
|
|
477f019737 | ||
|
|
41d2ef688e | ||
|
|
4378308c30 | ||
|
|
e11a182afd | ||
|
|
34565964b0 | ||
|
|
4aadfa0439 | ||
|
|
394b609ec4 | ||
|
|
29954e6067 | ||
|
|
272973e6f9 | ||
|
|
e15242f506 | ||
|
|
6d9024e0d0 | ||
|
|
121ba72957 | ||
|
|
adf96ca2e7 | ||
|
|
0757800b3d | ||
|
|
102404e8c3 | ||
|
|
9b1c79277a | ||
|
|
379e4b5c55 | ||
|
|
b3b6b14eb4 | ||
|
|
be56ca2836 | ||
|
|
b14a332405 | ||
|
|
65c9352c5c | ||
|
|
69de1ee36f | ||
|
|
9687c0ffc0 | ||
|
|
b75e5bea3b | ||
|
|
8616f3d1c5 | ||
|
|
3ddb19edee | ||
|
|
8b1613c7ee | ||
|
|
4f12bf1e00 | ||
|
|
471694dc48 | ||
|
|
fc7556ae7f | ||
|
|
bebe509a25 | ||
|
|
55eaeb8709 | ||
|
|
3c52dad774 | ||
|
|
5a57388b8d | ||
|
|
7a3d2870c5 | ||
|
|
cc0c3fb10a | ||
|
|
b71084f366 | ||
|
|
5dc213820b | ||
|
|
f518d87b71 | ||
|
|
1dae6a439d | ||
|
|
022821211f | ||
|
|
03716da731 | ||
|
|
2420791015 | ||
|
|
8ae567c06b | ||
|
|
a25952dc60 | ||
|
|
4d93274eb1 | ||
|
|
a663e97a5d | ||
|
|
8b0649da3a | ||
|
|
097110b359 | ||
|
|
1778b2317c | ||
|
|
e5405f4eb6 | ||
|
|
23bb8040ad | ||
|
|
3293a12542 | ||
|
|
42e6f267b8 | ||
|
|
831b8fed43 | ||
|
|
72a3c8522f | ||
|
|
ec9b1197c9 | ||
|
|
43e6cba2ef | ||
|
|
8e56820168 | ||
|
|
376d524a57 | ||
|
|
61bc027072 | ||
|
|
4cf1da167c | ||
|
|
dee13aefc5 | ||
|
|
7dd71bc2da | ||
|
|
ef10edfe29 | ||
|
|
c3330c4260 | ||
|
|
e4b4fb0cac | ||
|
|
4f9cbf702f | ||
|
|
6a2eeec928 | ||
|
|
c6fdf9bd4c | ||
|
|
e8a30df944 | ||
|
|
ee2640d18e | ||
|
|
d337b34380 | ||
|
|
731b7e356a | ||
|
|
a8604adbca | ||
|
|
8959030255 | ||
|
|
1d71fb4044 | ||
|
|
8c8c00fcba | ||
|
|
990cb4993f | ||
|
|
b321ff48d0 | ||
|
|
ec83040535 | ||
|
|
55a476053a | ||
|
|
eededda4fd | ||
|
|
92d00ca04a | ||
|
|
b93ace5cbf | ||
|
|
f4a7b39731 | ||
|
|
f9b7dcf9d1 | ||
|
|
663c203914 | ||
|
|
36c6cf6bd2 | ||
|
|
23b82e1f00 | ||
|
|
4ac1d80fe8 | ||
|
|
2f0369a7ad | ||
|
|
457c688872 | ||
|
|
0b364ae1fe | ||
|
|
9eadb37622 | ||
|
|
627933ae06 | ||
|
|
8c74c72b50 | ||
|
|
17a3e2b6d6 | ||
|
|
e4126949bf | ||
|
|
15ca8d4881 | ||
|
|
704831830e | ||
|
|
de30e34538 | ||
|
|
4e5ff2bd46 | ||
|
|
6fbf3ca9aa | ||
|
|
e41e12324d | ||
|
|
7f2f8ee09a | ||
|
|
611fb324b9 | ||
|
|
1b67ac1e6d | ||
|
|
49a2c643c6 | ||
|
|
996852d15c | ||
|
|
1199f690ad | ||
|
|
099465212e | ||
|
|
8482c3e85b | ||
|
|
3a1aeb7a4b | ||
|
|
13691c1cc0 | ||
|
|
d1430e3054 | ||
|
|
fc6f52e852 | ||
|
|
3c00ea6440 | ||
|
|
87b9a3a9ce | ||
|
|
12d4dbfbc9 | ||
|
|
3edb15c417 | ||
|
|
01a0f4f332 | ||
|
|
b87d2e476f | ||
|
|
2aa3ca3c45 | ||
|
|
c02e864873 | ||
|
|
3c03f370f9 | ||
|
|
8efb66ba19 | ||
|
|
65e52aa7b9 | ||
|
|
212438a6e3 | ||
|
|
f387d3f464 | ||
|
|
90a4606c65 | ||
|
|
245837ff27 | ||
|
|
ca2f5dbcee | ||
|
|
5e14632207 | ||
|
|
fa0365470e | ||
|
|
b8043cc2ad | ||
|
|
30478894fd | ||
|
|
6bd87bccc6 | ||
|
|
ac8a0a112c | ||
|
|
64eae8c8c5 | ||
|
|
b821b6f8f4 | ||
|
|
a3048770b6 | ||
|
|
1c1b699598 | ||
|
|
3e777d2485 | ||
|
|
ebf6045184 | ||
|
|
15ae2e9115 | ||
|
|
639c4d7db0 | ||
|
|
f1e0a758d1 | ||
|
|
9404226235 | ||
|
|
d5fee489f5 | ||
|
|
82d84d86b1 | ||
|
|
72390d7b51 | ||
|
|
698e249fbf | ||
|
|
1aeb5abb74 | ||
|
|
f9b7aa03ca | ||
|
|
3405a07d33 | ||
|
|
4f321fb68d | ||
|
|
d72e5e0516 | ||
|
|
ed0e7a9b2d | ||
|
|
4c23cb71c0 | ||
|
|
f1f7da1f40 | ||
|
|
a3a273cfd3 | ||
|
|
3917c26802 | ||
|
|
00ac44f6ca | ||
|
|
de5ba60bc4 | ||
|
|
8b8825ce68 | ||
|
|
54ba84eddc | ||
|
|
a8a689d682 | ||
|
|
6eeac4fa04 | ||
|
|
8b961b980a | ||
|
|
4193a82dfb | ||
|
|
833f0d2c27 | ||
|
|
3709e7320c | ||
|
|
e92792e3e8 | ||
|
|
310cb40af9 | ||
|
|
d1ac66a803 | ||
|
|
661144021e | ||
|
|
73b2845ba4 | ||
|
|
10dc38495f | ||
|
|
efdd5fc645 | ||
|
|
61def74383 | ||
|
|
ce366ea325 | ||
|
|
d8556e8f17 | ||
|
|
01eb35df25 | ||
|
|
b8e5b8e133 | ||
|
|
9c0b441119 | ||
|
|
3f57a4b76a | ||
|
|
bd482ea6dd | ||
|
|
64a54ca408 | ||
|
|
5f3e1e1e6c | ||
|
|
dde193cbf4 | ||
|
|
4964910cc9 | ||
|
|
06850b8900 | ||
|
|
7a0bd980c6 | ||
|
|
f3cd0509cb | ||
|
|
94ce2a3611 | ||
|
|
bcedfeba10 | ||
|
|
b34c5de2ab | ||
|
|
52d013e1db | ||
|
|
6267ae5638 | ||
|
|
5e90592e80 | ||
|
|
ef2c6cb30e | ||
|
|
24c5617230 | ||
|
|
fb8ace5bf4 | ||
|
|
ce84d1f27f | ||
|
|
8e12e5eeda | ||
|
|
118ca7c60b | ||
|
|
d61ccc633a | ||
|
|
badcb6ec21 | ||
|
|
7a9a67a8c3 | ||
|
|
dd3d4d326c | ||
|
|
6d735653a2 | ||
|
|
ac65168417 | ||
|
|
f7e726714a | ||
|
|
7d589c4456 | ||
|
|
79a0e58a3c | ||
|
|
db55720c93 | ||
|
|
0efbd55564 | ||
|
|
5b52eeeb36 | ||
|
|
8b7435890e | ||
|
|
33dcb1aef6 | ||
|
|
e8d7dc24f2 | ||
|
|
d30255bf62 | ||
|
|
01904ce2fd | ||
|
|
e56856f401 | ||
|
|
e02ad435b3 | ||
|
|
a399ff8934 | ||
|
|
d7f76246d3 | ||
|
|
fca70a6063 | ||
|
|
2c35c7bd21 | ||
|
|
5548284c65 | ||
|
|
a2a0f80252 | ||
|
|
1667b7bc91 | ||
|
|
ac6adc2d54 | ||
|
|
c910060f36 | ||
|
|
3efff16df2 | ||
|
|
03b173fd98 | ||
|
|
794b83a0db | ||
|
|
511213e820 | ||
|
|
9c9ccecbc5 | ||
|
|
5be3012e9b | ||
|
|
573dd521bb | ||
|
|
c2a67f81e5 | ||
|
|
31815f1d13 | ||
|
|
501e4a6737 | ||
|
|
517b1a02ce | ||
|
|
39e9e87b74 | ||
|
|
ce094f397a | ||
|
|
485715f11e | ||
|
|
c5830e4683 | ||
|
|
c7771c7bb7 | ||
|
|
8f6a123f29 | ||
|
|
6720ea4a21 | ||
|
|
f77d9a467e | ||
|
|
94349d86b0 | ||
|
|
0e6dc6871b | ||
|
|
2cf4510cbd | ||
|
|
47fff74655 |
No files matched your search
@@ -29,6 +29,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 35
|
||||
name: test (ubuntu-latest)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -69,6 +70,7 @@ jobs:
|
||||
run: cargo run --locked -q -p xtask -- assets
|
||||
|
||||
wasm:
|
||||
timeout-minutes: 30
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners).
|
||||
# It then runs packaging/freebsd/package.sh, the script the release job runs, and checks the
|
||||
# tarball, so a broken FreeBSD package fails here before it can fail a release. Nothing is
|
||||
# uploaded and no secrets are used.
|
||||
# (GitHub has no FreeBSD runners). It then runs packaging/freebsd/package.sh, the script
|
||||
# the release job runs, and checks the tarball, so a broken FreeBSD package fails here
|
||||
# before it can fail a release. Nothing is uploaded and no secrets are used.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build takes one to several hours, so it runs nightly on main rather than on every push
|
||||
@@ -11,6 +10,8 @@ name: FreeBSD
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 2 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/freebsd.yml
|
||||
@@ -27,35 +28,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test + package)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build, test and package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
# The release package, as release.yml builds it. Free the debug build's disk first.
|
||||
rm -rf target/debug
|
||||
CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/figcraft-*-freebsd-x86_64.tar.gz >"$HOME/freebsd-contents.txt"
|
||||
for f in '/bin/figcraft$' '/share/applications/ai\.storyteller\.figcraft\.desktop$' '/share/doc/figcraft/LICENSE-MIT$'; do
|
||||
grep -E "$f" "$HOME/freebsd-contents.txt"
|
||||
done
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
run-package-check: true
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
@@ -0,0 +1,33 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
run: |
|
||||
if [ -f flake.nix ]; then
|
||||
nix flake check --no-build --all-systems
|
||||
else
|
||||
echo "No flake.nix present in this repository; skipping check."
|
||||
fi
|
||||
@@ -19,57 +19,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
|
||||
- name: Server install script (Caddyfile handling)
|
||||
run: bash packaging/server/install-test.sh
|
||||
- name: AppImage AppRun desktop integration
|
||||
run: bash packaging/linux/apprun-test.sh
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
./packaging/windows/check-icons.ps1
|
||||
- name: FreeBSD package layout (dry run, no build)
|
||||
run: bash packaging/freebsd/package.sh --dry-run
|
||||
- name: No template leftovers from other apps in package metadata
|
||||
run: |
|
||||
if grep -rniE 'photoshop|psd|photocraft|vectorcraft|illustrator|adobe|figma' packaging/ .github/ --exclude=packaging-lint.yml; then
|
||||
echo "::error::package metadata mentions another product (see above)"
|
||||
exit 1
|
||||
fi
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/figcraft.wxs packaging/windows/figcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
desktop-file-validate packaging/linux/ai.storyteller.figcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.figcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.figcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.figcraft.metainfo.xml"
|
||||
python3 - <<'EOF'
|
||||
import yaml
|
||||
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.figcraft.yml"))
|
||||
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.figcraft.bundle.yml"))
|
||||
for m in (src, bundle):
|
||||
assert m["id"] == "ai.storyteller.figcraft" and m["command"] == "figcraft", m["id"]
|
||||
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
|
||||
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
|
||||
print("Flatpak manifests ok")
|
||||
EOF
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
with:
|
||||
extra-checks-script: packaging/check-app-specific.sh
|
||||
+24
-532
@@ -1,11 +1,3 @@
|
||||
# Release pipeline: every push to `release` builds signed installers for every platform and
|
||||
# creates or updates the draft GitHub Release "FigCraft v<version>". See docs/releasing.md.
|
||||
#
|
||||
# Only the jobs that read secrets (macOS and Windows signing, and the draft release) run in the
|
||||
# `release` environment (restricted to the `release` branch), which holds the signing secrets; the
|
||||
# rest build on any branch, so `gh workflow run release.yml --ref <branch>` is a real dry run.
|
||||
# Every secret is optional: missing ones produce unsigned artifacts and a warning, never a failed
|
||||
# build. Playbook: storytold/craftrules release/playbook.md.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -19,534 +11,34 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
FIGCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
# craft-fonts (https://github.com/storytold/craft-fonts): shared font assets every release
|
||||
# embeds (Inter and the Latin presentation fonts from fonts/latin-manifest.txt; the web build
|
||||
# embeds Inter only, see crates/text/build.rs). An optional build input for everyone else.
|
||||
# crates/text/build.rs needs fonts/latin-manifest.txt, so the pin must include it. Bump
|
||||
# deliberately; every build job checks out this commit.
|
||||
CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
v="${INPUT_VERSION#v}"
|
||||
v="${v:-$VERSION}"
|
||||
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{
|
||||
echo "version=$v"
|
||||
echo "date=$(date -u +%Y-%m-%d)"
|
||||
echo "prerelease=$pre"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
echo "### FigCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
environment: release
|
||||
env:
|
||||
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: packaging/macos/import-cert.sh
|
||||
- name: Build, sign, notarize
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
# Unpacks the CLI zip and checks the binary users get: Developer ID, hardened runtime,
|
||||
# timestamp, and Gatekeeper's notarization lookup (codesign --verify, spctl). Also the DMG's
|
||||
# stapled ticket. Strict when the signing secrets are present, warnings otherwise.
|
||||
- name: Verify signatures and notarization
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packaging/macos/verify.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
- name: Remove temporary keychain
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
|
||||
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
|
||||
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
shell: bash
|
||||
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
- name: Build, sign, package
|
||||
shell: pwsh
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
|
||||
desktop-file-utils appstream python3-yaml zsync
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/figcraft$|metainfo|applications'
|
||||
ldd target/release/figcraft
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.figcraft.yml')); assert m['id'] == 'ai.storyteller.figcraft'"
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
# Update information is embedded, and the .zsync for delta updates sits beside it.
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
server:
|
||||
name: Server Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The headless collaboration server (docs/server.md): figcraft-server-<v>-linux-<arch>.tar.gz
|
||||
# with the binary, licences, docs/server.md and the deployment examples. Same runners as the
|
||||
# app, so the same glibc floor; no craft-fonts (the server draws no text).
|
||||
# shortcut: Linux only (where servers run); add macOS/Windows archives, signed like figcraft-cli,
|
||||
# when people host there.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm }
|
||||
env:
|
||||
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-server-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
|
||||
- name: Build and package (tar.gz)
|
||||
run: packaging/server/package.sh
|
||||
- name: Check the archive
|
||||
run: |
|
||||
tar -tzf "dist/release/figcraft-server-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" | tee "$RUNNER_TEMP/server-contents.txt"
|
||||
for f in '/figcraft-server$' '/figcraft-server\.service$' '/Caddyfile$' '/nginx\.conf$' '/install\.sh$' '/figcraft-backup\.timer$' '/server\.md$' '/LICENSE-MIT$'; do
|
||||
grep -E "$f" "$RUNNER_TEMP/server-contents.txt"
|
||||
done
|
||||
ldd target/release/figcraft-server
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-server-linux-${{ matrix.arch }}
|
||||
path: dist/release/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Install flatpak-builder
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
|
||||
# `appstreamcli compose`, which renders the scalable icon and fails without it.
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/figcraft-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
|
||||
# .github/workflows/freebsd.yml. A private repo's ubuntu-latest has 2 vCPUs and 7 GB, too small
|
||||
# for this VM (QEMU "cannot set up guest memory"), so it runs on the org's 16-core larger runner.
|
||||
runs-on: github-ubuntu-16core
|
||||
timeout-minutes: 180
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
envs: "FIGCRAFT_VERSION FIGCRAFT_BUILD_DATE FIGCRAFT_BUILD_SHA INPUT_VERSION"
|
||||
# The FreeBSD CI packages, plus bash for the packaging script.
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
|
||||
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
|
||||
export CARGO_TARGET_DIR=/var/tmp/figcraft-target CARGO_BUILD_JOBS=4
|
||||
if [ -n "$INPUT_VERSION" ]; then cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"; fi
|
||||
# craft-fonts was checked out next to the sources and synced into the VM with them.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/figcraft-*-freebsd-x86_64.tar.gz >"$HOME/freebsd-contents.txt"
|
||||
for f in '/bin/figcraft$' '/share/applications/ai\.storyteller\.figcraft\.desktop$' '/share/doc/figcraft/LICENSE-MIT$'; do
|
||||
grep -E "$f" "$HOME/freebsd-contents.txt"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
env:
|
||||
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
run: |
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
trunk --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
|
||||
# The wasm32 build embeds only Inter from craft-fonts (crates/text/build.rs): browsers have
|
||||
# no system fonts to fall back on, and the rest would push the wasm towards
|
||||
# packaging/web/package.sh's 24 MiB gate.
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Build and zip
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
|
||||
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
|
||||
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
|
||||
# CLI is smoke-tested under QEMU.
|
||||
runs-on: ubuntu-24.04
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
env:
|
||||
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-riscv64
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
. /etc/os-release
|
||||
sudo dpkg --add-architecture riscv64
|
||||
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
fi
|
||||
if [ -f /etc/apt/sources.list ]; then
|
||||
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
|
||||
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
|
||||
fi
|
||||
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
|
||||
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
|
||||
done | sudo tee /etc/apt/sources.list.d/riscv64.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
|
||||
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
|
||||
libxi-dev:riscv64 libgl1-mesa-dev:riscv64
|
||||
- name: Build and package (tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: "1"
|
||||
CROSS_ARCH: riscv64
|
||||
CROSS_TARGET: riscv64gc-unknown-linux-gnu
|
||||
CROSS_COMPILE: riscv64-linux-gnu-
|
||||
EMULATOR: qemu-riscv64
|
||||
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
|
||||
run: packaging/linux/package.sh --formats tar
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web, server]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Checksums
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
sha256sum -- * | tee SHA256SUMS.txt
|
||||
- name: Create or update the draft release
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
title="FigCraft v$VERSION"
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
if [ "$draft" != "true" ]; then
|
||||
echo "::error::Release $tag is already published. Bump the version (cargo run -p xtask -- version set X.Y.Z) before pushing to release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updating draft $tag: replacing its assets"
|
||||
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
|
||||
gh release delete-asset "$tag" "$asset" --yes
|
||||
done
|
||||
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
echo "Creating draft $tag"
|
||||
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
app-name: figcraft
|
||||
app-title: FigCraft
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
has-server: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
@@ -4,15 +4,17 @@
|
||||
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
# Nightly on main (dependency and runner-image drift shows up within a day), and on PRs that
|
||||
# change the Windows packaging itself.
|
||||
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
|
||||
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "41 4 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -22,77 +24,10 @@ concurrency:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: package (cross-compiled on x64)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
- name: Build and package (unsigned)
|
||||
shell: pwsh
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: figcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
install:
|
||||
name: install and run on ARM64
|
||||
needs: package
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: figcraft-windows-arm64
|
||||
path: dist
|
||||
- name: Install the MSI, check it, run it, uninstall it
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
|
||||
$dir = Join-Path $env:ProgramFiles 'FigCraft'
|
||||
# figcraft-cli.exe ships once apps/figcraft-cli exists; check it when installed.
|
||||
foreach ($exe in 'figcraft.exe', 'figcraft-cli.exe') {
|
||||
$path = Join-Path $dir $exe
|
||||
if (-not (Test-Path $path)) {
|
||||
if ($exe -eq 'figcraft-cli.exe') { Write-Output "skip $exe (not in this build)"; continue }
|
||||
throw "$exe not installed in $dir"
|
||||
}
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
|
||||
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
|
||||
Write-Output "ok $path (ARM64)"
|
||||
}
|
||||
# The installed binary runs natively on ARM64 (the CLI when present, else the app's --version).
|
||||
$cli = Join-Path $dir 'figcraft-cli.exe'
|
||||
if (-not (Test-Path $cli)) { $cli = Join-Path $dir 'figcraft.exe' }
|
||||
$out = & $cli --version 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "$cli --version exited $LASTEXITCODE" }
|
||||
Write-Output "ok $(Split-Path -Leaf $cli) --version: $($out.Trim())"
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
|
||||
if (Test-Path (Join-Path $dir 'figcraft.exe')) { throw 'uninstall left figcraft.exe behind' }
|
||||
Write-Output 'ok uninstalled'
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: figcraft
|
||||
app-dir: FigCraft
|
||||
Generated
+42
-23
@@ -1407,7 +1407,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "figcraft"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"eframe",
|
||||
@@ -1418,6 +1418,7 @@ dependencies = [
|
||||
"figcraft-format",
|
||||
"figcraft-text",
|
||||
"figcraft-ui-egui",
|
||||
"fmv-macos-events",
|
||||
"image",
|
||||
"log",
|
||||
"rfd",
|
||||
@@ -1430,7 +1431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-cli"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
@@ -1444,7 +1445,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -1459,7 +1460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab-net"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
@@ -1482,7 +1483,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-color"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1491,7 +1492,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-doc"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-geom",
|
||||
@@ -1503,7 +1504,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-engine"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
@@ -1530,9 +1531,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-format"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-pathops",
|
||||
"flate2",
|
||||
"image",
|
||||
"ruzstd",
|
||||
@@ -1544,7 +1546,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-geom"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1552,7 +1554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-layout"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1560,7 +1562,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-mcp"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab-net",
|
||||
@@ -1576,7 +1578,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pathops"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1585,7 +1587,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pdf"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1602,7 +1604,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"serde",
|
||||
@@ -1615,7 +1617,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-render"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"figcraft-color",
|
||||
@@ -1633,7 +1635,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-server"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"ctrlc",
|
||||
@@ -1657,7 +1659,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-svg"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -1666,17 +1668,20 @@ dependencies = [
|
||||
"figcraft-pathops",
|
||||
"figcraft-render",
|
||||
"figcraft-text",
|
||||
"flate2",
|
||||
"thiserror 2.0.21",
|
||||
"usvg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-text"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
"harfrust 0.13.3",
|
||||
"log",
|
||||
"png",
|
||||
"skrifa 0.47.0",
|
||||
"unicode-bidi",
|
||||
"unicode-segmentation",
|
||||
@@ -1684,7 +1689,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-ui-egui"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"egui",
|
||||
@@ -1692,6 +1697,7 @@ dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
"figcraft-format",
|
||||
"figcraft-geom",
|
||||
"figcraft-layout",
|
||||
"figcraft-pathops",
|
||||
@@ -1703,17 +1709,19 @@ dependencies = [
|
||||
"serde_json",
|
||||
"toml",
|
||||
"wat",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-web"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"figcraft-collab-net",
|
||||
"figcraft-engine",
|
||||
"figcraft-ui-egui",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
@@ -1746,6 +1754,17 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fmv-macos-events"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -3922,9 +3941,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
|
||||
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
@@ -5803,7 +5822,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ exclude = ["plugins"]
|
||||
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -59,7 +59,7 @@ linesweeper = "0.4"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
# `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe).
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
|
||||
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
crc32fast = "1"
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🔌 Offline and private</h3>
|
||||
Your files are plain <code>.figcraft</code> archives on your own disk. No account, no cloud, no telemetry. SVG, PNG, JPG, WebP and PDF in and out; <code>.fig</code> files open (best effort), and so do designs exported as the public REST API file JSON, with their prototypes and variables.
|
||||
Your files are plain <code>.figcraft</code> archives on your own disk. No account, no telemetry, and nothing leaves your computer unless you share a file (<a href="#privacy-and-online-sharing">what sharing sends where</a>). SVG, PNG, JPG, WebP and PDF in and out; <code>.fig</code> files open (best effort), and so do designs exported as the public REST API file JSON, with their prototypes and variables.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🤖 Agent-ready</h3>
|
||||
@@ -191,7 +191,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<h3>Prototype and hand off</h3>
|
||||
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
|
||||
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -227,10 +227,10 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
|
||||
- **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust.
|
||||
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
|
||||
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
|
||||
- **Languages:** English and Spanish (a draft waiting for a native speaker's review), picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
|
||||
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
|
||||
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
|
||||
- **Live sessions (preview):** host the open file from the app (this machine or your local network), share the invite link, and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
|
||||
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (Share ▸ Publish to server…) and hands out share links that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
|
||||
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
|
||||
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
|
||||
- **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session.
|
||||
- **Clean-room:** implemented from public documentation and observed behaviour only. No proprietary code, file formats, icons or images.
|
||||
|
||||
@@ -251,6 +251,10 @@ Fonts for the UI and the Text tool come from [craft-fonts](https://github.com/st
|
||||
git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
```
|
||||
|
||||
If you clone it after a first build, run `cargo clean -p figcraft-text` once so the next build embeds the fonts.
|
||||
|
||||
**Start from a template.** The home screen's Templates tab has eight starter files (a mobile app, a landing page, a dashboard, a design system starter, a wireframe kit, social post sizes, presentation slides and an icon set starter), also under File ▸ New from template…. They are built in code from original, synthetic content, so scripts and agents can open them too: `file.newFromTemplate {template}` ([`docs/mcp.md`](docs/mcp.md#starter-templates)).
|
||||
|
||||
For the web build, install [trunk](https://trunkrs.dev/) and run `cargo xtask wasm`.
|
||||
|
||||
New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs/`](docs/).
|
||||
@@ -260,6 +264,31 @@ Installers for macOS (universal, signed and notarized), Windows (x64, x86 and ar
|
||||
> [!IMPORTANT]
|
||||
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Privacy and online sharing
|
||||
|
||||
FigCraft works offline: it has no account and no telemetry, and it contacts no server until you
|
||||
share or join a file. Live sessions are a **preview**. Before you share anything that matters,
|
||||
know what sharing does:
|
||||
|
||||
- **Share ▸ Online uploads the file to a server.** By default that is the public server at
|
||||
`collab.craftapps.dev`; you can set another one, or your own, in Preferences (and a build can
|
||||
ship without it: [`docs/releasing.md`](docs/releasing.md#the-default-server)). The file stays on
|
||||
the server after you stop sharing, until you remove it (Share ▸ Advanced ▸ Remove from server…)
|
||||
or nobody has opened it for 30 days, when a public server deletes it.
|
||||
- **It is not end-to-end encrypted.** The connection to the server is encrypted (TLS), but the
|
||||
server stores the file unencrypted and whoever runs the server can read it. Don't share
|
||||
confidential work through a server you don't trust; run your own
|
||||
([`docs/server.md`](docs/server.md)) or share on the local network instead.
|
||||
- **The code or link is the access.** There are no accounts: anyone who has an edit code or link
|
||||
can open and change the file, anyone with a view code or link can open it. Stop sharing revokes
|
||||
them and disconnects everyone else.
|
||||
- **Share ▸ Local network involves no server**, but it is not encrypted either: your app hosts
|
||||
the session and others connect to it directly, so use it on networks you trust.
|
||||
- **No guarantees.** The public server is offered as is, without backups or uptime promises, and
|
||||
files on it can be removed. Keep your own saved copy of anything you share.
|
||||
|
||||
The default server's terms are in [`docs/online-sharing-terms.md`](docs/online-sharing-terms.md).
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
FigCraft is one of the **Crafting Apps**: free, open-source creative tools from the
|
||||
|
||||
+116
-14
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -13,10 +13,10 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| Menu/command breadth | **100%** (207 / 207) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
|
||||
| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
|
||||
| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
|
||||
| Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) |
|
||||
| Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
|
||||
| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
|
||||
| Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
|
||||
| Remaining to beta | **~155–280 h** one agent | estimated |
|
||||
| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
|
||||
@@ -26,8 +26,8 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
||||
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
||||
|---|---|---|---|
|
||||
| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
|
||||
| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the experimental `.fig` export opens elsewhere (5–15) |
|
||||
| Essentials user | ~57% | ~60–110 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
|
||||
| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) |
|
||||
| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) |
|
||||
|
||||
Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
|
||||
per 1,000 added lines, 1–2.5 h per feature PR, ~7 h per honest depth point in the polish tail; see
|
||||
@@ -58,7 +58,7 @@ native speakers' reviews of the six language drafts need people.
|
||||
|---|---|---|---|
|
||||
| Features (depth) | 63% (core editor 72%) | 28–67 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
|
||||
| File formats | 60% (`.fig` read best effort with editable vectors; experimental write, checked only through our own importer) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| File formats | 60% (`.fig` read best effort with editable vectors; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
|
||||
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
|
||||
@@ -100,18 +100,18 @@ native speakers' reviews of the six language drafts need people.
|
||||
|---|---|---|---|
|
||||
| English | en | 100% | full |
|
||||
| Simplified Chinese | zh-Hans | 100% | draft (needs native review) |
|
||||
| Spanish | es, es-419 | 100% (es-419: an overlay of 87 entries) | draft (needs native review) |
|
||||
| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) |
|
||||
| Hindi | hi | 0% | none |
|
||||
| Arabic | ar | 0% | none |
|
||||
| French | fr | 99% | draft (needs native review) |
|
||||
| Portuguese (Brazil) | pt-BR | 99% | draft (needs native review) |
|
||||
| French | fr | 100% | draft (needs native review) |
|
||||
| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) |
|
||||
| Indonesian | id | 0% | none |
|
||||
| Japanese | ja | 99% | draft (needs native review) |
|
||||
| German | de | 99% | draft (needs native review) |
|
||||
| Korean | ko | 99% | draft (needs native review) |
|
||||
| Japanese | ja | 100% | draft (needs native review) |
|
||||
| German | de | 100% | draft (needs native review) |
|
||||
| Korean | ko | 100% | draft (needs native review) |
|
||||
| Vietnamese | vi | 0% | none |
|
||||
|
||||
No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
|
||||
Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
|
||||
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
|
||||
French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸
|
||||
Language; Traditional Chinese has no catalog; CJK UI
|
||||
@@ -123,7 +123,7 @@ IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-pa
|
||||
|
||||
Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the experimental `.fig` export (landed 2026-10-12) on real files: 5–15 h.
|
||||
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h.
|
||||
2. Real-file corpus and measured fidelity: 15–30 h.
|
||||
3. Tool-by-tool interaction pass against Figma: 40–60 h.
|
||||
4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h).
|
||||
@@ -131,12 +131,70 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
|
||||
- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
|
||||
- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged.
|
||||
- **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged.
|
||||
- **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged.
|
||||
- **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged.
|
||||
- **2026-10-13** · MCP/control transport hardening (#201): 32 MiB incoming frames are bounded while reading; oversized frames return an error and close without draining. The opt-in loopback control listener caps active workers at 8, applies 60 s socket read/write inactivity timeouts and caps both request queues at 8, discarding timed-out requests including those deferred to hidden windows. Reader, queue, idle-client and blocked-writer regressions cover the limits. Numbers unchanged.
|
||||
- **2026-10-13** · Attached MCP (M0.9, #194): a transport failure no longer replays a request that may already have edited the document. The caller gets an outcome-unknown error and must inspect before retrying; the next explicit call reconnects. Loopback tests cover a lost mutation reply, a single applied edit, reconnect and a connection failure before sending. Numbers unchanged.
|
||||
- **2026-10-13** · Constrain proportions in the Layout panel (M1, #220): with the W/H link on, typing a width or height scales the other side by the layer's own ratio; the property edit dropped the ratio, so the link only worked while dragging resize handles. Both sides in one edit stay as typed, the `w`/`h` aliases work the same, each layer of a multi-selection keeps its own ratio, and a zero side has no ratio to keep. Regression test in the transform behaviour suite. Numbers unchanged.
|
||||
- **2026-10-13** · Repeat last command (M1, #181): repeated edits keep the underlying command and parameters, so repeated nudges accumulate with one undo step each. A stored self-reference is refused instead of recursing. Regression tests cover three repeats, their undo steps, and missing or self-referencing commands. Numbers unchanged.
|
||||
- **2026-10-13** · Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged.
|
||||
- **2026-10-13** · Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged.
|
||||
- **2026-10-13** · Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged.
|
||||
- **2026-10-13** · Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged.
|
||||
- **2026-10-13** · Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged.
|
||||
- **2026-10-13** · Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged.
|
||||
- **2026-10-13** · Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged.
|
||||
- **2026-10-13** · Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged.
|
||||
- **2026-10-13** · `.fig` export: instance sizes, variables, styles (gap 1): instances write what they change from
|
||||
their main (size, name, paints) as an override addressed by the main's id, what follows for their layers as
|
||||
derived data and a scale factor when scaled as a whole; variable collections, modes, variables and bindings,
|
||||
the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the
|
||||
import reads all of it back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width;
|
||||
number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged.
|
||||
text, effect and grid styles, number, string and boolean bindings, bindings on effects, layout guides and gradient stops, and variable-width strokes are not yet checked there. Numbers unchanged.
|
||||
- **2026-10-13** · Starter templates and first-run help: eight templates built in code (mobile app, landing page,
|
||||
dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter), a
|
||||
Templates tab on the home screen, `template.list` and `file.newFromTemplate` for scripts and agents, and a Getting
|
||||
started card that can be closed and comes back from the Help menu
|
||||
- **2026-10-12** · Your online files: a file shared or published online stays yours after its tab closes or the app
|
||||
quits. The app remembers it (with its owner token, kept with the preferences and never shown) and Home ▸ **Online**
|
||||
lists what you own: open a file again as its owner, start or stop sharing, copy its code, rename it, remove it from
|
||||
the server or forget it here. Servers are asked once, when the list opens; tabs mark files in a live session; a
|
||||
crash reopens such a file from its server and puts back the changes that hadn't reached it. `collab.files`, `collab.openFile`, `collab.forgetFile`; servers need an
|
||||
upgrade only to tell which files are still there. The six draft catalogs translate every string again
|
||||
- **2026-10-12** · Everyday editing partials: number fields (scrub rates with Shift and Alt, arrow keys by the nudge
|
||||
preferences, arithmetic with brackets, relative edits of Mixed values per layer), outline mode's hidden layers and
|
||||
object bounds, Hide when publishing in the Assets panel, a cursor for every tool and modifier, panel widths that are
|
||||
kept, and dark pages (white text on them) for files made in the dark theme
|
||||
- **2026-10-12** · Sharing with a join code: Share ▸ **Online** (through the server in Preferences, built in) or
|
||||
**Local network**, one Start sharing button and a short code to read out (`K7M-2QX-9TD`; a longer one locally that
|
||||
carries the host's address); others type it under Join with a code (the popover, the home screen), where a link
|
||||
works too. The server names links with codes, keeps them hashed and limits wrong ones; `collab.share`,
|
||||
`collab.join {code}`; servers must be upgraded to hand codes out (older ones keep working with links)
|
||||
- **2026-10-12** · Text metrics measured: our example documents, re-saved by other software, were read back and
|
||||
compared layer by layer. Line heights are now whole pixels, advances sit on a 1024-per-em grid and letter spacing
|
||||
goes between glyphs only; all 230 self-sizing text boxes in the 7 documents match to the pixel (24 of 131 before
|
||||
in the design-system example). One font family; real files still unchecked (gap 3). No number moves
|
||||
- **2026-10-12** · Live sessions are per document: every open file has its own session or none, several run at
|
||||
once (host one, join another, publish a third) and keep syncing behind the tab in front, the Share popover,
|
||||
people, cursors, chat, spotlight and follow are the file in front's, `collab.peers` lists them all (`sessions`),
|
||||
and closing a file ends its session only (before, one session followed every tab and starting another replaced it)
|
||||
- **2026-10-12** · A public `figcraft-server`: `open_publish = true` lets anyone publish without the server key
|
||||
(Share ▸ Publish to server asks the server first and shows who hosts it, its terms and how long unopened files are
|
||||
kept), with an owner token shown once that manages the file later (`collab.manageFile`); limits per address,
|
||||
idle expiry, reports (Share ▸ Report this file…), `block-ip` and `files list --sort`, `GET /healthz`, and
|
||||
`packaging/server/install.sh` that sets up a Debian/Ubuntu machine with Caddy, ufw and backups
|
||||
([server.md](docs/server.md#running-a-public-server)). Numbers unchanged (hosting, not a Figma feature).
|
||||
- **2026-10-12** · `.fig` export opens elsewhere (gap 1): written files were checked by opening them in other
|
||||
software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft. The
|
||||
export now says that the document and its pages are visible and opaque, numbers its schema as the format does,
|
||||
and writes outlines, text settings, standard font style names and line height units; effects such as glass and
|
||||
noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers
|
||||
unchanged.
|
||||
- **2026-10-12** · Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig
|
||||
(experimental), `figcraft-cli export --format fig` and MCP's `export` write the whole document: layers,
|
||||
geometry, paints with their images, strokes, effects, styled text, vector networks, auto layout, components and
|
||||
@@ -257,6 +315,50 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
|
||||
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
|
||||
| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. |
|
||||
| 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. |
|
||||
| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. |
|
||||
| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. |
|
||||
| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
|
||||
| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged |
|
||||
| 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `<image>` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Leptos code (#223): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: leptos`: a `#[component]` with `#[prop]` defaults, variant enums and `view!` markup, instances as calls, Code Connect `use` lines; the Dev Mode language list and rem units include it. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Full screen (#227): View ▸ Full screen (`view.fullscreen {on?}`, F11 on Windows and Linux, Ctrl+Cmd+F on macOS) makes the window fill the screen and leaves it again; FigCraft's own item. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Colour picker: wrapped rows of page and recent swatches stay inset on both sides instead of running past the picker's right edge. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Server snapshots (#188): figcraft-server never replaces a readable snapshot with one too large to be read back, and reads snapshots up to twice the file cap (recent batches kept for reconnecting clients may take a file over it). Numbers unchanged |
|
||||
| 2026-10-14 | minor | Preferences owner-only on Unix (#200): `state.json`, which holds online owner tokens, is written with mode 0600 from the first byte, and an older world-readable file is narrowed. Numbers unchanged |
|
||||
| 2026-10-14 | minor | ruzstd 0.9.1 (#182): `.fig` import requires the release that fixes RUSTSEC-2026-0344 (a zstd decompression bomb). Numbers unchanged |
|
||||
| 2026-10-14 | minor | Layer cycles (#205): `validate` reports children listed twice, pages with a parent and layers not reachable from a page (cycles, detached subtrees); `repair` drops them in one linear pass. Numbers unchanged |
|
||||
| 2026-10-14 | minor | AppImage CLI: `figcraft.AppImage cli <args>` runs the bundled `figcraft-cli` (MCP server included) on Linux without a second download. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Boolean outline depth (#193): nested boolean groups share one outline depth budget instead of each level getting its own. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Command batches roll back as one: a failed `command.batch` (MCP `batch`) restores every open document and its state, and puts the tab in front back. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Desktop builds no longer rebuild the text crate on every build when the craft-fonts manifest is missing. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Direct3D 12 on Windows: the desktop app asks wgpu for Direct3D 12 there instead of Vulkan, so it starts without Vulkan loader errors on machines with stray overlay layers. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Plugin metering (#192): every host call and every byte copied between a plugin and the host costs fuel, so a plugin can't stall the app through cheap-looking host calls. Numbers unchanged |
|
||||
| 2026-10-14 | minor | SVG/PDF export depth (#208): explicit preflight refusal beyond 256 descendant levels, propagated through public APIs, commands and CLI, with boundary/content and destination-preservation regressions. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | MCP/control transport hardening (#201): frame limits during reads, bounded connections and both UI request queues, socket inactivity timeouts; percentages and estimates unchanged |
|
||||
| 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. |
|
||||
| 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Repeat last command (M1, #181): keep the underlying command and parameters across repeats, reject self-reference, and retain one undo step per repeated edit. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | `.fig` export (gap 1): instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes are written and read back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged |
|
||||
| 2026-10-13 | minor | Starter templates and a first run that explains itself: eight templates built in code through the command layer (mobile app, landing page, dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter; original and synthetic, deterministic, about 10–190 ms each to build), `template.list` and `file.newFromTemplate` for the app, CLI and MCP, File ▸ New from template…, a Templates tab on the home screen (cards with thumbnails rendered from the documents, search, kind filter, keyboard and screen-reader names) in place of the Community placeholder, a row of templates on Recents while there are no recent files, a Getting started card that can be closed and comes back from Help ▸ Getting started (`help.gettingStarted`), and a first run that opens on the home screen. The Drafts placeholder is gone (the app has no drafts folder). Essentials ~57% → ~59% (discoverability ×0.92 → ×0.94), its hours ~60–110 h → ~55–100 h; other numbers unchanged |
|
||||
| 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. The draft catalogs (French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese) translate the 71 strings and 5 command labels they lacked and the new ones. Numbers unchanged |
|
||||
| 2026-10-12 | minor | Everyday editing partials closed (gap 4): number fields scrub a step a point (×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it), step with the arrow keys by the nudge preferences, take brackets, units and relative edits (`+10`, `*2`, `50%`), commit on Tab and nothing when left untouched, and on Mixed apply relative edits to each layer in one undo step; outline mode's Include hidden layers and Include object bounds (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`), drawn and clickable; Hide when publishing from the Assets panel (`component.hideWhenPublishing`); a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy, not-allowed), with guides and handles keeping theirs; panel widths with limits, double-click reset and the preferences (`view.panelWidths`); a file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5) and text drawn on a dark page is white. Menu breadth 211/211. UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding: the side-by-side comparison is what remains |
|
||||
| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Numbers unchanged |
|
||||
| 2026-10-12 | minor | Live sessions per document (M9, a fix from real use): the engine keeps one session per open document instead of one for the app, the link carries several under their own ids (desktop, browser, tests), the window and the `collab.*` commands follow the active document, `collab.peers` gains `session` and `sessions`, `ui.inspect`'s `live` gains `session` and `sessions`; joining opens the session's file in its own tab without touching other sessions and closing a file ends its session only. Numbers unchanged |
|
||||
| 2026-10-12 | minor | `.fig` export checked by opening written files in other software (gap 1): layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, after fixes to what the export writes (visible document and pages, the format's schema numbering, outlines, text settings, font style names, line height units); effects such as glass and noise, images and prototype interactions still unchecked there. Numbers unchanged |
|
||||
| 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged |
|
||||
| 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged |
|
||||
| 2026-10-12 | minor | Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig (experimental), the CLI and MCP write the whole document (layer tree, geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto layout, constraints, components, sets, instances with their overrides and property values, prototype interactions) with a kiwi encoder mirroring the decoder and the schema of the fields written; what's left out is a warning; verified only by round trips through our own importer. File formats 59% → 60%; import/export 84%/57% → 86%/58%; file exchange stays ×0.85; mainstream hours ~130–244 h → ~120–229 h; feature breadth (~84%), ready for real work (~53%), essentials (~57%), mainstream (~49%), beta (~155–280 h) and full parity (~265–523 h) unchanged at this rounding |
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Windows: give the main thread the stack Linux and macOS give it (see `main_thread_stack`).
|
||||
fn main() {
|
||||
main_thread_stack();
|
||||
}
|
||||
|
||||
/// Windows starts the main thread with 1 MB of stack, Linux and macOS with 8 MB. Layers nest up
|
||||
/// to 256 deep in an export, and that recursion needs the same room everywhere.
|
||||
fn main_thread_stack() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return;
|
||||
}
|
||||
let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
|
||||
println!("cargo::rustc-link-arg-bins={}", if msvc { "/STACK:8388608" } else { "-Wl,--stack,8388608" });
|
||||
}
|
||||
@@ -38,7 +38,7 @@ fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
|
||||
|
||||
fn page_named(doc: &Document, name: Option<&str>) -> CliResult<NodeId> {
|
||||
match name {
|
||||
None => doc.first_page().ok_or_else(|| CliError::Failed("the document has no pages".into())),
|
||||
None => doc.first_open_page().or_else(|| doc.first_page()).ok_or_else(|| CliError::Failed("the document has no pages".into())),
|
||||
Some(n) => doc
|
||||
.pages
|
||||
.iter()
|
||||
@@ -102,8 +102,8 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> {
|
||||
let v = figcraft_format::to_json(doc).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
serde_json::to_vec_pretty(&v).map_err(|e| CliError::Failed(e.to_string()))?
|
||||
}
|
||||
"svg" => figcraft_svg::export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
|
||||
.ok_or_else(|| CliError::Failed("the page is empty: nothing to export".into()))?
|
||||
"svg" => figcraft_svg::try_export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
|
||||
.map_err(|e| CliError::Failed(e.to_string()))?
|
||||
.into_bytes(),
|
||||
"png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?,
|
||||
"pdf" => page_pdf(doc, page, &PrintSettings::default())?,
|
||||
@@ -379,4 +379,47 @@ mod tests {
|
||||
assert_eq!(sanitize("../.."), "_");
|
||||
assert_eq!(sanitize(""), "layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_page_skips_leading_divider() {
|
||||
let mut doc = Document::new("divider");
|
||||
let page = doc.first_page().unwrap();
|
||||
doc.create(page, figcraft_doc::NodeKind::Frame, "First", 0.0, 0.0, 40.0, 30.0).unwrap();
|
||||
let divider = doc.add_page("- Divider");
|
||||
doc.pages.retain(|p| *p != divider);
|
||||
doc.pages.insert(0, divider);
|
||||
assert!(doc.is_divider(divider));
|
||||
assert_eq!(page_named(&doc, None).unwrap(), page);
|
||||
assert_eq!(page_named(&doc, Some("- Divider")).unwrap(), divider);
|
||||
let pm = render_page(&doc, page_named(&doc, None).unwrap(), 1.0).unwrap();
|
||||
assert_eq!((pm.width(), pm.height()), (40, 30));
|
||||
assert!(render_page(&doc, divider, 1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_errors_preserve_conversion_destinations() {
|
||||
let mut doc = Document::new("export depth");
|
||||
let page = doc.first_page().unwrap();
|
||||
let root = doc.create(page, figcraft_doc::NodeKind::Frame, "root", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
let mut parent = root;
|
||||
for _ in 1..257 {
|
||||
parent = doc.create(parent, figcraft_doc::NodeKind::Group, "group", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
}
|
||||
doc.create(parent, figcraft_doc::NodeKind::Rectangle, "leaf", 0.0, 0.0, 8.0, 8.0).unwrap();
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-cli-export-depth-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
for ext in ["svg", "pdf"] {
|
||||
let path = dir.join(format!("previous.{ext}"));
|
||||
std::fs::write(&path, b"previous export").unwrap();
|
||||
let CliError::Failed(error) = write_doc(&doc, page, path.to_str().unwrap()).unwrap_err() else {
|
||||
panic!("depth overflow must be an operation failure");
|
||||
};
|
||||
assert!(error.contains("maximum export depth of 256"), "{ext}: {error}");
|
||||
assert!(error.contains("select an inner layer"), "{ext}: {error}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"previous export");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -438,44 +438,66 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes
|
||||
mean(3, |i| exec(&mut s, "node.set", json!({"ids": labels, "props": {"fontSize": 13 + (i % 2) * 2}}))),
|
||||
);
|
||||
let doc = s.doc().map_err(failed)?.clone();
|
||||
let (card, _) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]).ok_or_else(|| failed("no card"))?;
|
||||
table.put(
|
||||
col,
|
||||
"switch a card's variant (nested overrides kept)",
|
||||
mean(5, |i| {
|
||||
exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}}))
|
||||
}),
|
||||
);
|
||||
let (nav, _) = instance_with(&doc, &inst, ds.nav, &["Profile"]).ok_or_else(|| failed("no nav"))?;
|
||||
table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true));
|
||||
table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true));
|
||||
let (button, bglyph) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]).ok_or_else(|| failed("no button"))?;
|
||||
table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true));
|
||||
// A button glyph the drop really lands on: screens can overlap (text sizes differ without
|
||||
// fonts), so ask the hit test the swap uses.
|
||||
let now = s.doc().map_err(failed)?.clone();
|
||||
let page = s.page().map_err(failed)?;
|
||||
let b = std::iter::once(bglyph)
|
||||
.chain(inst.iter().filter_map(|i| {
|
||||
let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None };
|
||||
(figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))?
|
||||
}))
|
||||
.filter_map(|g| now.world_bounds(g))
|
||||
.find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some())
|
||||
.ok_or_else(|| failed("no button glyph to drop on"))?;
|
||||
let other = ds.icons.get(5).copied().unwrap_or(icon0);
|
||||
table.put(
|
||||
col,
|
||||
"Alt-drop swap onto an instance",
|
||||
timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true),
|
||||
);
|
||||
exec(&mut s, "edit.copy", json!({"ids": [nav]}));
|
||||
table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true));
|
||||
// Small workloads may not contain every instance type; the rows needing a missing one stay empty.
|
||||
if let Some((card, _)) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]) {
|
||||
table.put(
|
||||
col,
|
||||
"switch a card's variant (nested overrides kept)",
|
||||
mean(5, |i| {
|
||||
exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}}))
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
table.put(col, "switch a card's variant (nested overrides kept)", f64::NAN);
|
||||
}
|
||||
let nav = instance_with(&doc, &inst, ds.nav, &["Profile"]).map(|(nav, _)| nav);
|
||||
if let Some(nav) = nav {
|
||||
table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true));
|
||||
table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true));
|
||||
} else {
|
||||
table.put(col, "reset all changes on a nav", f64::NAN);
|
||||
table.put(col, "detach a nav", f64::NAN);
|
||||
}
|
||||
if let Some((button, bglyph)) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]) {
|
||||
table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true));
|
||||
// A button glyph the drop really lands on: screens can overlap (text sizes differ without
|
||||
// fonts), so ask the hit test the swap uses.
|
||||
let now = s.doc().map_err(failed)?.clone();
|
||||
let page = s.page().map_err(failed)?;
|
||||
let drop_at = std::iter::once(bglyph)
|
||||
.chain(inst.iter().filter_map(|i| {
|
||||
let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None };
|
||||
(figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))?
|
||||
}))
|
||||
.filter_map(|g| now.world_bounds(g))
|
||||
.find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some());
|
||||
let other = ds.icons.get(5).copied().unwrap_or(icon0);
|
||||
table.put(
|
||||
col,
|
||||
"Alt-drop swap onto an instance",
|
||||
match drop_at {
|
||||
Some(b) => timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true),
|
||||
None => f64::NAN,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
table.put(col, "push a button's changes to its main", f64::NAN);
|
||||
table.put(col, "Alt-drop swap onto an instance", f64::NAN);
|
||||
}
|
||||
if let Some(nav) = nav {
|
||||
exec(&mut s, "edit.copy", json!({"ids": [nav]}));
|
||||
table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true));
|
||||
} else {
|
||||
table.put(col, "copy + paste a nav", f64::NAN);
|
||||
}
|
||||
table.put(col, "undo (deep main edit)", mean(10, |_| _ = s.undo()));
|
||||
table.put(col, "redo", mean(10, |_| _ = s.redo()));
|
||||
|
||||
// As a library: publish it, use it from another file, publish a change, accept the update.
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}", std::process::id()));
|
||||
// One directory per run, so concurrent runs in a process don't delete each other's files.
|
||||
static RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let run = RUNS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}-{run}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).map_err(failed)?;
|
||||
let path = dir.join("design-system.figcraft").to_string_lossy().into_owned();
|
||||
let (lib_doc, lib_ds) = design_system_document(&spec).map_err(failed)?;
|
||||
@@ -533,6 +555,14 @@ mod tests {
|
||||
assert!(t.rows.iter().all(|r| r.1.len() == 1 && r.1[0].is_finite()), "{:?}", t.rows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measures_components_with_a_single_instance() {
|
||||
let mut t = super::Table { rows: Vec::new() };
|
||||
super::measure_components(&mut t, 0, 1).unwrap();
|
||||
assert!(t.rows.iter().all(|r| r.1.len() == 1), "{:?}", t.rows);
|
||||
assert!(t.rows.iter().any(|r| r.0.starts_with("build design system") && r.1[0].is_finite()), "{:?}", t.rows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measures_a_small_document() {
|
||||
let mut t = super::Table { rows: Vec::new() };
|
||||
|
||||
@@ -78,6 +78,27 @@ fn run_export_info_convert() {
|
||||
std::fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_takes_the_documented_gradient_shorthand() {
|
||||
// #252: only MCP used to accept it; `run` said "`fills` must be paints or colours".
|
||||
let d = tmp("gradient");
|
||||
let script = d.join("s.json");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r##"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":200,"height":100}},
|
||||
{"command":"node.set","params":{"ids":[2],"fills":[{"type":"linearGradient","stops":["#ff0000","#0000ff"],"from":[0,0.5],"to":[1,0.5]}]}}]"##,
|
||||
)
|
||||
.unwrap();
|
||||
let file = d.join("g.figcraft");
|
||||
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
|
||||
assert!(out.status.success(), "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
let json = d.join("g.json");
|
||||
assert!(bin().arg("convert").arg(&file).arg(&json).status().unwrap().success());
|
||||
let doc = std::fs::read_to_string(&json).unwrap();
|
||||
assert!(doc.contains("\"kind\":\"linear\"") || doc.contains("\"kind\": \"linear\""), "{doc}");
|
||||
std::fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_over_stdio() {
|
||||
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Depth refusal through the actual CLI, including its exit status and destination writes.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_doc::color::{Color, Paint};
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn export_depth_cli_refuses_partial_files_and_accepts_an_inner_root() {
|
||||
let mut doc = Document::new("export depth");
|
||||
let root = doc.create(doc.first_page().unwrap(), NodeKind::Frame, "root", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
let mut parent = root;
|
||||
for depth in 1..257 {
|
||||
let name = if depth == 1 { "inner-root" } else { "group" };
|
||||
parent = doc.create(parent, NodeKind::Group, name, 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
}
|
||||
let leaf = doc.create(parent, NodeKind::Rectangle, "leaf", 3.0, 5.0, 13.0, 17.0).unwrap();
|
||||
doc.get_mut(leaf).unwrap().fills = vec![Paint::solid(Color::from_hex("FF0000").unwrap())];
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-cli-depth-process-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let input = dir.join("deep.figcraft");
|
||||
std::fs::write(&input, figcraft_format::save_bytes(&doc, None).unwrap()).unwrap();
|
||||
for ext in ["svg", "pdf"] {
|
||||
let path = dir.join(format!("previous.{ext}"));
|
||||
std::fs::write(&path, b"previous export").unwrap();
|
||||
for verb in ["convert", "export"] {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
|
||||
command.arg(verb).arg(&input);
|
||||
if verb == "export" {
|
||||
command.arg("-o");
|
||||
}
|
||||
let output = command.arg(&path).arg("--no-system-fonts").output().unwrap();
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1), "{verb} {ext}: {error}");
|
||||
assert!(error.contains("maximum export depth of 256"), "{verb} {ext}: {error}");
|
||||
assert!(error.contains("select an inner layer"), "{verb} {ext}: {error}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"previous export");
|
||||
}
|
||||
let inner = dir.join(format!("inner.{ext}"));
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
|
||||
.arg("export")
|
||||
.arg(&input)
|
||||
.arg("-o")
|
||||
.arg(&inner)
|
||||
.args(["--node", "inner-root", "--no-system-fonts"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let bytes = std::fs::read(inner).unwrap();
|
||||
if ext == "svg" {
|
||||
assert!(String::from_utf8_lossy(&bytes).contains("#FF0000"));
|
||||
} else {
|
||||
assert!(bytes.starts_with(b"%PDF"));
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 5, "no staging files after refusal");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -5,10 +5,16 @@
|
||||
//! everyone besides ([`PUBLISHES_ALL`] times one address's). The table keeps at most
|
||||
//! [`MAX_ADDRESSES`] addresses: when it is full the half used longest ago is forgotten, so a flood
|
||||
//! of addresses costs bounded memory (and forgotten addresses start with full buckets).
|
||||
//!
|
||||
//! Join codes are short, so guessing them is held back three ways: an address may try
|
||||
//! [`CODES_PER_MINUTE`] codes a minute, [`WRONG_CODES`] wrong ones in a row lock it out of codes for
|
||||
//! [`CODE_LOCKOUT`] (links still join), and everyone together may get [`WRONG_CODES_PER_HOUR`]
|
||||
//! wrong an hour (an attacker with many addresses is slowed to that; while it lasts, people join
|
||||
//! with their links).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab_net::hub::{Block, ip_blocks};
|
||||
use figcraft_collab_net::net::Bucket;
|
||||
@@ -28,6 +34,15 @@ pub const DOWNLOAD_BURST: f64 = (16u64 << 20) as f64;
|
||||
/// times what the widest block (an IPv6 /48, four addresses' worth) may, so no block takes it all.
|
||||
pub const PUBLISHES_ALL: f64 = 32.0;
|
||||
|
||||
/// Join codes one address may try a minute (right or wrong).
|
||||
pub const CODES_PER_MINUTE: f64 = 10.0;
|
||||
/// Wrong codes in a row (within [`CODE_LOCKOUT`] of each other) that lock an address out of codes.
|
||||
pub const WRONG_CODES: u32 = 10;
|
||||
/// How long an address is locked out of join codes.
|
||||
pub const CODE_LOCKOUT: Duration = Duration::from_secs(15 * 60);
|
||||
/// Wrong codes everyone together may try an hour.
|
||||
pub const WRONG_CODES_PER_HOUR: f64 = 1_000.0;
|
||||
|
||||
/// The rates (see the server's settings).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AbuseConfig {
|
||||
@@ -49,6 +64,8 @@ pub enum Action {
|
||||
Report,
|
||||
/// Downloading this many bytes.
|
||||
Download(usize),
|
||||
/// Trying a join code.
|
||||
Code,
|
||||
}
|
||||
|
||||
struct Address {
|
||||
@@ -58,6 +75,12 @@ struct Address {
|
||||
presence: Bucket,
|
||||
reports: Bucket,
|
||||
downloads: Bucket,
|
||||
codes: Bucket,
|
||||
/// Wrong join codes in a row, and when the last one was.
|
||||
wrong: u32,
|
||||
wrong_at: Instant,
|
||||
/// Locked out of join codes until then.
|
||||
locked: Option<Instant>,
|
||||
used: Instant,
|
||||
}
|
||||
|
||||
@@ -67,6 +90,8 @@ pub struct Abuse {
|
||||
table: HashMap<Block, Address>,
|
||||
/// Publishing without the key, everyone together.
|
||||
publishes: Bucket,
|
||||
/// Wrong join codes, everyone together.
|
||||
wrong_codes: Bucket,
|
||||
}
|
||||
|
||||
/// `n` every `period` seconds on average, and all `n` at once at most.
|
||||
@@ -77,7 +102,44 @@ fn bucket(n: f64, period: f64) -> Bucket {
|
||||
impl Abuse {
|
||||
pub fn new(cfg: AbuseConfig) -> Self {
|
||||
let publishes = bucket(f64::from(cfg.publishes_per_hour) * PUBLISHES_ALL, 3600.0);
|
||||
Self { cfg, table: HashMap::new(), publishes }
|
||||
Self { cfg, table: HashMap::new(), publishes, wrong_codes: bucket(WRONG_CODES_PER_HOUR, 3600.0) }
|
||||
}
|
||||
|
||||
/// How long `ip` is still locked out of join codes (in any block it is in), if it is.
|
||||
pub fn code_locked(&self, ip: IpAddr, now: Instant) -> Option<Duration> {
|
||||
let until = ip_blocks(ip).into_iter().filter_map(|(k, _)| self.table.get(&k)?.locked).max()?;
|
||||
Some(until.saturating_duration_since(now)).filter(|d| !d.is_zero())
|
||||
}
|
||||
|
||||
/// Everyone together has wrong codes left to try (see [`WRONG_CODES_PER_HOUR`]).
|
||||
pub fn codes_open(&mut self, now: Instant) -> bool {
|
||||
let open = self.wrong_codes.take(now);
|
||||
if open {
|
||||
self.wrong_codes.give(1.0);
|
||||
}
|
||||
open
|
||||
}
|
||||
|
||||
/// `ip` tried a join code that was right (`ok`) or wrong: a right one forgives the wrong ones
|
||||
/// before it; enough wrong ones in a row lock the address out.
|
||||
pub fn code_tried(&mut self, ip: IpAddr, ok: bool, now: Instant) {
|
||||
if !ok {
|
||||
self.wrong_codes.take(now);
|
||||
}
|
||||
for (k, times) in ip_blocks(ip) {
|
||||
let a = self.address(k, f64::from(times), now);
|
||||
if ok || now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT {
|
||||
a.wrong = 0;
|
||||
}
|
||||
if !ok {
|
||||
a.wrong += 1;
|
||||
a.wrong_at = now;
|
||||
if a.wrong >= WRONG_CODES.saturating_mul(times) {
|
||||
a.wrong = 0;
|
||||
a.locked = Some(now + CODE_LOCKOUT);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `ip` may do `what` now (counting it, in every block it is in, when it may).
|
||||
@@ -114,6 +176,10 @@ impl Abuse {
|
||||
presence: Bucket::new(f64::from(c.messages_per_second) * times * 2.0, f64::from(c.messages_per_second) * times * 8.0),
|
||||
reports: bucket(REPORTS_PER_HOUR * times, 3600.0),
|
||||
downloads: Bucket::new(DOWNLOAD_RATE * times, DOWNLOAD_BURST * times),
|
||||
codes: bucket(CODES_PER_MINUTE * times, 60.0),
|
||||
wrong: 0,
|
||||
wrong_at: now,
|
||||
locked: None,
|
||||
used: now,
|
||||
});
|
||||
a.used = now;
|
||||
@@ -128,6 +194,7 @@ impl Abuse {
|
||||
Action::Presence => (&mut a.presence, 1.0),
|
||||
Action::Report => (&mut a.reports, 1.0),
|
||||
Action::Download(n) => (&mut a.downloads, n as f64),
|
||||
Action::Code => (&mut a.codes, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -344,7 +344,7 @@ fn key(cfg: &Config, rotate: bool) -> Result<String, String> {
|
||||
if rotate {
|
||||
out.push_str("A new server key: the old one no longer works.\n");
|
||||
}
|
||||
let _ = writeln!(out, "Publish to this address (in FigCraft: Share > Publish to server…):");
|
||||
let _ = writeln!(out, "Publish to this address (in FigCraft: File > Publish to server…):");
|
||||
let _ = writeln!(out, " {}?key={key}", public.url());
|
||||
let _ = writeln!(out, "Whoever has it can publish files and manage their links: share it only with people you trust.");
|
||||
if cfg.open_publish {
|
||||
@@ -375,7 +375,7 @@ fn used(f: &FileRow) -> u64 {
|
||||
|
||||
fn files_table(mut files: Vec<FileRow>, sort: Sort, now: u64) -> String {
|
||||
if files.is_empty() {
|
||||
return "No files yet (publish one from FigCraft: Share > Publish to server…).\n".into();
|
||||
return "No files yet (publish one from FigCraft: File > Publish to server…).\n".into();
|
||||
}
|
||||
match sort {
|
||||
Sort::Id => {}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Join codes on the server: a short name for one share link, typed in place of the link.
|
||||
//!
|
||||
//! A code is 45 random bits ([`figcraft_collab::code`]). Like a link's token it is never stored:
|
||||
//! a link keeps the code's hash, to find the link by, and its own token wrapped with the code, to
|
||||
//! hand to whoever types the code (they then join as with the link). Both come from the code
|
||||
//! stretched with SHA-256 ([`STRETCH`] rounds), so a copy of the data directory doesn't give the
|
||||
//! codes away cheaply; guessing against a running server is held back by the limits on wrong codes
|
||||
//! (`abuse.rs`).
|
||||
|
||||
use figcraft_collab::{code, same_secret};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Rounds of SHA-256 a code goes through before it is compared or used as a key.
|
||||
pub const STRETCH: u32 = 10_000;
|
||||
|
||||
// A copy of the data directory mustn't give codes away cheaply.
|
||||
const _: () = assert!(STRETCH >= 10_000);
|
||||
|
||||
/// Longest token a code can wrap (one SHA-256 output).
|
||||
const MAX_TOKEN: usize = 32;
|
||||
|
||||
/// What a link keeps of its join code (`links.json`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Sealed {
|
||||
/// The stretched code's hash, hex: what a typed code is looked up by.
|
||||
pub hash: String,
|
||||
/// The link's token, wrapped with the stretched code (hex).
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
/// A typed code, stretched: ready to look links up with (what it is found by, and the pad its
|
||||
/// link's token is wrapped with).
|
||||
pub struct Key {
|
||||
hash: String,
|
||||
pad: [u8; 32],
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
impl Key {
|
||||
/// Stretch `code` (its symbols, as [`code::normalize`] gives them).
|
||||
pub fn new(code: &str) -> Key {
|
||||
let mut k: [u8; 32] = Sha256::new().chain_update(b"figcraft join code\0").chain_update(code.as_bytes()).finalize().into();
|
||||
for _ in 0..STRETCH {
|
||||
k = Sha256::digest(k).into();
|
||||
}
|
||||
let part = |what: &[u8]| -> [u8; 32] { Sha256::new().chain_update(what).chain_update(k).finalize().into() };
|
||||
Key { hash: hex(&part(b"find\0")), pad: part(b"wrap\0") }
|
||||
}
|
||||
|
||||
/// What a link with this code keeps as [`Sealed::hash`].
|
||||
pub fn hash(&self) -> &str {
|
||||
&self.hash
|
||||
}
|
||||
|
||||
/// Wrap or unwrap `bytes` (the same operation: XOR with a pad only this code gives).
|
||||
fn wrap(&self, bytes: &[u8]) -> Vec<u8> {
|
||||
bytes.iter().zip(self.pad).map(|(b, p)| b ^ p).collect()
|
||||
}
|
||||
|
||||
/// Keep `token` under this code.
|
||||
pub fn seal(&self, token: &str) -> Result<Sealed, String> {
|
||||
if token.len() > MAX_TOKEN {
|
||||
return Err("this link's token is too long for a join code".into());
|
||||
}
|
||||
Ok(Sealed { hash: self.hash.clone(), token: hex(&self.wrap(token.as_bytes())) })
|
||||
}
|
||||
|
||||
/// The token `sealed` keeps, when this is its code (compared in constant time).
|
||||
pub fn open(&self, sealed: &Sealed) -> Option<String> {
|
||||
if !same_secret(&self.hash, &sealed.hash) || sealed.token.len() > 2 * MAX_TOKEN {
|
||||
return None;
|
||||
}
|
||||
let wrapped: Option<Vec<u8>> =
|
||||
(0..sealed.token.len() / 2).map(|i| sealed.token.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect();
|
||||
String::from_utf8(self.wrap(&wrapped?)).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// A new code's symbols, from the operating system's random numbers.
|
||||
pub fn new_code() -> Result<String, String> {
|
||||
figcraft_collab_net::token::random().map(code::online)
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
//! Join codes on the server: sharing online from one app and joining by code from another (real
|
||||
//! links over loopback; stopping revokes the others' links and codes and disconnects them while
|
||||
//! the owner stays in the file, and sharing again makes new ones for the same file), codes kept
|
||||
//! only hashed, revoked with their link and gone with their file,
|
||||
//! the limits on wrong codes, junk requests, and clients from before join codes.
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::Replica;
|
||||
use figcraft_collab::code;
|
||||
use figcraft_collab::message::{Admin, AdminReply, ClientMsg, Hello, OpenHosting, PROTOCOL, Resolve, ServerMsg};
|
||||
use figcraft_collab_net::hub::Backend;
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::live::Status;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::abuse::{Abuse, AbuseConfig, Action, CODE_LOCKOUT, CODES_PER_MINUTE, WRONG_CODES, WRONG_CODES_PER_HOUR};
|
||||
use crate::codes::Key;
|
||||
use crate::config::Config;
|
||||
use crate::store::{CODE_GONE, CODES_BUSY, NO_CODE, Store};
|
||||
use crate::test_util::{Raw, TempDir, config, live, pump, session};
|
||||
|
||||
fn open(dir: &std::path::Path) -> Config {
|
||||
Config { open_publish: true, operator_contact: "[email protected]".into(), terms_url: "https://example.com/terms".into(), ..config(dir) }
|
||||
}
|
||||
|
||||
fn snapshot(name: &str) -> Vec<figcraft_collab::Change> {
|
||||
let mut d = Document::new(name);
|
||||
let page = d.first_page().unwrap();
|
||||
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap();
|
||||
d.repair();
|
||||
Replica::new(d).snapshot().unwrap()
|
||||
}
|
||||
|
||||
fn ip(s: &str) -> IpAddr {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
/// An app whose server for online sharing is the one at `url`.
|
||||
fn app(name: &str, url: &str) -> Session {
|
||||
let mut s = session(name);
|
||||
ex(&mut s, "pref.publishServer", json!({"url": url}));
|
||||
s
|
||||
}
|
||||
|
||||
fn layers(s: &Session) -> usize {
|
||||
s.doc().map_or(0, |d| d.node_count())
|
||||
}
|
||||
|
||||
fn failed(s: &Session) -> Option<String> {
|
||||
match &s.live().status {
|
||||
Status::Failed { reason } => Some(reason.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_code(shown: &str) -> bool {
|
||||
shown.len() == 11 && matches!(code::parse(shown), Ok(code::Code::Online(_)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_shared_online_is_joined_by_its_codes() {
|
||||
let dir = TempDir::new("codes-share");
|
||||
let mut server = crate::start(&open(dir.path())).unwrap();
|
||||
let url = format!("ws://{}/", server.addr());
|
||||
let mut a = app("Ada", &url);
|
||||
// Sharing: the file goes to the server in Preferences, and the server names its links.
|
||||
let started = ex(&mut a, "collab.share", json!({}));
|
||||
assert_eq!((&started["status"], &started["share"]["via"], &started["code"]), (&json!("connecting"), &json!("online"), &Value::Null));
|
||||
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some());
|
||||
let shared = a.live_summary();
|
||||
let (edit, view) = (shared["share"]["editCode"].as_str().unwrap().to_string(), shared["share"]["viewCode"].as_str().unwrap().to_string());
|
||||
assert!(is_code(&edit) && is_code(&view) && edit != view, "{edit} {view}");
|
||||
assert_eq!(shared["code"], edit.as_str(), "the code shown lets people edit unless asked otherwise");
|
||||
let p = a.live().published.clone().unwrap();
|
||||
assert!(p.owner.is_some(), "the owner token is kept as before");
|
||||
// Others get links of their own (the owner keeps the file's first ones): four in all.
|
||||
let edit_link = shared["share"]["editLink"].as_str().unwrap().to_string();
|
||||
assert!(edit_link != p.edit && shared["share"]["viewLink"] != p.view.as_str() && shared["share"]["shared"] == true);
|
||||
let ids = |dir: &std::path::Path| -> Vec<String> {
|
||||
let links: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("files").join(&p.file).join("links.json")).unwrap()).unwrap();
|
||||
links.as_array().unwrap().iter().map(|l| l["id"].as_str().unwrap().to_string()).collect()
|
||||
};
|
||||
let before_stop = ids(dir.path());
|
||||
assert_eq!(before_stop.len(), 4);
|
||||
// On disk: hashes only, of the codes as of the links' tokens.
|
||||
let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap();
|
||||
for secret in [edit.as_str(), view.as_str(), &edit.replace('-', ""), &view.replace('-', "")] {
|
||||
assert!(!links.contains(secret), "{links}");
|
||||
}
|
||||
assert!(!links.contains(p.edit.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
|
||||
assert_eq!(links.matches("\"code\"").count(), 2);
|
||||
// Someone types the edit code, the way people type.
|
||||
let mut b = app("Bo", &url);
|
||||
assert_eq!(ex(&mut b, "collab.join", json!({"code": edit.to_lowercase().replace('-', " ")}))["status"], "connecting");
|
||||
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
||||
assert_eq!(b.live_summary()["access"], "edit");
|
||||
assert_eq!(b.live_summary()["link"], edit_link.as_str(), "joined as with the shared link: reconnects need no code");
|
||||
let before = layers(&a);
|
||||
ex(&mut a, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 30}));
|
||||
pump(&mut [&mut a, &mut b], "Ada's edit", |s| layers(s[1]) == before + 1);
|
||||
ex(&mut b, "shape.ellipse", json!({"x": 80, "y": 10, "width": 40, "height": 30}));
|
||||
pump(&mut [&mut a, &mut b], "Bo's edit", |s| layers(s[0]) == before + 2);
|
||||
// The view code lets someone look, not edit.
|
||||
assert_eq!(ex(&mut a, "collab.share", json!({"access": "view"}))["code"], view.as_str());
|
||||
let mut c = app("Cy", &url);
|
||||
ex(&mut c, "collab.join", json!({"code": view}));
|
||||
pump(&mut [&mut a, &mut b, &mut c], "Cy in", |s| live(s[2]) && layers(s[2]) == before + 2);
|
||||
assert_eq!((&c.live_summary()["access"], &c.live_summary()["readOnly"]), (&json!("view"), &json!(true)));
|
||||
assert!(c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_err());
|
||||
// A code nobody was given.
|
||||
let mut d = app("Di", &url);
|
||||
ex(&mut d, "collab.join", json!({"code": "000-000-000"}));
|
||||
pump(&mut [&mut d], "the refusal", |s| failed(s[0]).is_some());
|
||||
assert_eq!(failed(&d).unwrap(), NO_CODE);
|
||||
// Stop sharing: the others are out at once and told why; the file and its owner stay.
|
||||
assert_eq!(ex(&mut a, "collab.stop", json!({})), json!({"ended": false, "shared": false}));
|
||||
pump(&mut [&mut a, &mut b, &mut c], "the others out", |s| {
|
||||
!s[1].live().status.active() && !s[2].live().status.active() && s[0].live().peers.is_empty()
|
||||
});
|
||||
for guest in [&b, &c] {
|
||||
assert!(guest.message.as_deref().unwrap_or_default().contains("Sharing was stopped"), "{:?}", guest.message);
|
||||
assert_eq!(guest.live().status, Status::Off, "over, not failed: nothing retries");
|
||||
}
|
||||
assert_eq!(layers(&b), before + 2, "Bo keeps the copy");
|
||||
let folder = dir.path().join("files").join(&p.file);
|
||||
assert!(folder.exists() && live(&a), "the file stays on the server and Ada stays in it");
|
||||
let stopped = a.live_summary();
|
||||
assert_eq!((&stopped["share"]["shared"], &stopped["code"], &stopped["published"]["file"]), (&json!(false), &Value::Null, &json!(p.file)));
|
||||
assert_eq!(ids(dir.path()), before_stop[..2], "the owner's links are left, the shared ones are gone");
|
||||
// Neither the codes nor the links get anyone in any more.
|
||||
let mut e = app("Ed", &url);
|
||||
for old in [&edit, &view] {
|
||||
ex(&mut e, "collab.join", json!({"code": old}));
|
||||
pump(&mut [&mut e], "the stopped code", |s| failed(s[0]).is_some());
|
||||
assert_eq!(failed(&e).unwrap(), CODE_GONE);
|
||||
}
|
||||
ex(&mut e, "collab.join", json!({"link": edit_link}));
|
||||
pump(&mut [&mut e], "the stopped link", |s| failed(s[0]).is_some_and(|why| why.contains("isn't valid")));
|
||||
// The owner still edits the server's file.
|
||||
ex(&mut a, "shape.rectangle", json!({"x": 200, "y": 10, "width": 40, "height": 30}));
|
||||
pump(&mut [&mut a], "the owner's edit confirmed", |s| layers(s[0]) == before + 3 && s[0].live().client().is_some_and(|c| c.pending() == 0));
|
||||
// Sharing again: new codes and links for the same file (nothing is published twice).
|
||||
let again = ex(&mut a, "collab.share", json!({}));
|
||||
let (edit2, link2) = (again["code"].as_str().unwrap().to_string(), again["share"]["editLink"].as_str().unwrap().to_string());
|
||||
assert!(is_code(&edit2) && edit2 != edit && link2 != edit_link && again["published"]["file"] == p.file.as_str(), "{again}");
|
||||
assert_eq!(dir.path().join("files").read_dir().unwrap().count(), 1, "still one file on the server");
|
||||
assert_eq!(ids(dir.path()).len(), 4);
|
||||
ex(&mut b, "collab.join", json!({"code": edit2}));
|
||||
pump(&mut [&mut a, &mut b], "Bo back in", |s| live(s[1]) && layers(s[1]) == before + 3 && s[0].live().peers.len() == 1);
|
||||
// The old code is still gone.
|
||||
ex(&mut e, "collab.join", json!({"code": edit}));
|
||||
pump(&mut [&mut e], "the old code", |s| failed(s[0]).is_some_and(|why| why == CODE_GONE));
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_keyed_share_gets_codes_and_stops_with_the_key() {
|
||||
let dir = TempDir::new("codes-keyed");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
let url = format!("ws://{}/", server.addr());
|
||||
let mut a = session("Ada");
|
||||
ex(&mut a, "collab.share", json!({"server": format!("{url}?key={}", crate::test_util::key(dir.path())), "name": "Team file"}));
|
||||
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some());
|
||||
let shown = a.live_summary()["code"].as_str().unwrap().to_string();
|
||||
assert!(is_code(&shown));
|
||||
assert!(!a.live_summary().to_string().contains("key="), "the key is nowhere in the summary");
|
||||
let mut b = app("Bo", &url);
|
||||
ex(&mut b, "collab.join", json!({"code": shown}));
|
||||
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]));
|
||||
// No owner token: the key the address carried stops the sharing (the file is the operator's).
|
||||
assert_eq!(ex(&mut a, "collab.stop", json!({}))["shared"], false);
|
||||
pump(&mut [&mut a, &mut b], "Bo out", |s| !s[1].live().status.active());
|
||||
assert!(live(&a) && !a.live_summary().to_string().contains("key="));
|
||||
ex(&mut b, "collab.join", json!({"code": shown}));
|
||||
pump(&mut [&mut b], "the stopped code", |s| failed(s[0]).is_some_and(|why| why == CODE_GONE));
|
||||
// Leaving keeps the file there.
|
||||
ex(&mut a, "collab.leave", json!({}));
|
||||
assert_eq!(dir.path().join("files").read_dir().unwrap().count(), 1);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codes_are_hashed_replaced_revoked_and_gone_with_the_file() {
|
||||
let dir = TempDir::new("codes-store");
|
||||
let cfg = open(dir.path());
|
||||
let me = ip("192.0.2.7");
|
||||
let (file, edit_token, view_link, shown, view_code) = {
|
||||
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
let (file, edit, view, _owner) = store.publish_open(me, "Poster", &snapshot("Poster")).unwrap();
|
||||
let shown = store.set_code(&file.id, &edit.token).unwrap();
|
||||
let view_code = store.set_code(&file.id, &view.token).unwrap();
|
||||
assert_ne!(shown, view_code, "a code per link");
|
||||
assert_eq!((shown.len(), code::normalize(&shown).as_deref()), (code::ONLINE_LEN, Ok(shown.as_str())));
|
||||
// The code gives the link's token back, and the token admits with the link's access.
|
||||
assert_eq!(store.resolve_code(me, &shown).unwrap(), (file.id.clone(), edit.token.clone()));
|
||||
let (f, t) = store.resolve_code(me, &code::grouped(&view_code).to_lowercase()).unwrap();
|
||||
assert_eq!(store.admit(&f, &t).unwrap().access, figcraft_collab::Access::View);
|
||||
// A token that isn't one of the file's links gets no code.
|
||||
assert!(store.set_code(&file.id, "AAAAAAAAAAAAAAAAAAAAAA").unwrap_err().contains("isn't one of this file's links"));
|
||||
assert!(store.set_code("0000000000000000", &edit.token).is_err());
|
||||
(file.id, edit.token, view.link.id, shown, view_code)
|
||||
};
|
||||
// What is kept: the stretched code's hash and the wrapped token, nothing that is either.
|
||||
let links = std::fs::read_to_string(dir.path().join("files").join(&file).join("links.json")).unwrap();
|
||||
assert!(!links.contains(&shown) && !links.contains(&edit_token), "{links}");
|
||||
assert!(links.contains(Key::new(&shown).hash()));
|
||||
// A restart keeps the codes (they are in the data directory).
|
||||
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
assert_eq!(store.resolve_code(me, &shown).unwrap().1, edit_token);
|
||||
// A new code for the link takes the old one's place.
|
||||
let newer = store.set_code(&file, &edit_token).unwrap();
|
||||
assert_ne!(newer, shown);
|
||||
assert_eq!(store.resolve_code(me, &shown).unwrap_err(), CODE_GONE);
|
||||
assert_eq!(store.resolve_code(me, &newer).unwrap().1, edit_token);
|
||||
// Revoking a link revokes its code.
|
||||
store.revoke(&file, &view_link).unwrap();
|
||||
assert_eq!(store.resolve_code(me, &view_code).unwrap_err(), CODE_GONE);
|
||||
// The file expiring takes the rest.
|
||||
store.age(&file, 31 * 86_400);
|
||||
assert_eq!(store.sweep(), vec![file.clone()]);
|
||||
assert_eq!(store.resolve_code(me, &newer).unwrap_err(), CODE_GONE);
|
||||
// A code never handed out is unknown, not expired.
|
||||
assert_eq!(store.resolve_code(ip("192.0.2.8"), "ZZZZZZZZZ").unwrap_err(), NO_CODE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_codes_lock_an_address_out_but_not_the_others() {
|
||||
let dir = TempDir::new("codes-lockout");
|
||||
let mut store = Store::open(dir.path(), open(dir.path()).store()).unwrap();
|
||||
let (file, edit, _, _) = store.publish_open(ip("192.0.2.7"), "Poster", &snapshot("Poster")).unwrap();
|
||||
let shown = store.set_code(&file.id, &edit.token).unwrap();
|
||||
let guesser = ip("198.51.100.9");
|
||||
for n in 0..WRONG_CODES {
|
||||
assert_eq!(store.resolve_code(guesser, &code::online(u64::from(n) + 1)).unwrap_err(), NO_CODE, "guess {n}");
|
||||
}
|
||||
// Locked out: not even the right code is looked up for that address.
|
||||
for typed in [code::online(77), shown.clone()] {
|
||||
let e = store.resolve_code(guesser, &typed).unwrap_err();
|
||||
assert!(e.contains("Too many wrong codes") && e.contains("15 minutes") && e.contains("invite link"), "{e}");
|
||||
}
|
||||
// Its link still joins, and other addresses' codes still work.
|
||||
assert!(store.admit(&file.id, &edit.token).is_ok());
|
||||
assert_eq!(store.resolve_code(ip("198.51.100.10"), &shown).unwrap().0, file.id);
|
||||
// An IPv6 guesser is counted by its /64.
|
||||
for n in 0..WRONG_CODES {
|
||||
let from: IpAddr = format!("2001:db8:1:2::{n:x}").parse().unwrap();
|
||||
let _ = store.resolve_code(figcraft_collab_net::hub::ip_key(from), &code::online(1000 + u64::from(n)));
|
||||
}
|
||||
let neighbour = figcraft_collab_net::hub::ip_key(ip("2001:db8:1:2:ffff::1"));
|
||||
assert!(store.resolve_code(neighbour, &shown).unwrap_err().contains("Too many wrong codes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_limits_on_codes_over_time() {
|
||||
let t0 = Instant::now();
|
||||
let me = ip("198.51.100.9");
|
||||
let mut abuse = Abuse::new(AbuseConfig { open: false, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 });
|
||||
// A right code forgives the wrong ones before it.
|
||||
for _ in 0..WRONG_CODES - 1 {
|
||||
abuse.code_tried(me, false, t0);
|
||||
}
|
||||
abuse.code_tried(me, true, t0);
|
||||
for _ in 0..WRONG_CODES - 1 {
|
||||
abuse.code_tried(me, false, t0);
|
||||
}
|
||||
assert_eq!(abuse.code_locked(me, t0), None);
|
||||
// One more: locked for the lockout, then free again.
|
||||
abuse.code_tried(me, false, t0);
|
||||
assert_eq!(abuse.code_locked(me, t0), Some(CODE_LOCKOUT));
|
||||
assert!(abuse.code_locked(me, t0 + CODE_LOCKOUT - Duration::from_secs(1)).is_some());
|
||||
assert_eq!(abuse.code_locked(me, t0 + CODE_LOCKOUT), None);
|
||||
// Wrong codes long apart don't add up.
|
||||
let other = ip("198.51.100.10");
|
||||
for n in 0..3 * WRONG_CODES {
|
||||
abuse.code_tried(other, false, t0 + (CODE_LOCKOUT + Duration::from_secs(1)) * n);
|
||||
}
|
||||
assert_eq!(abuse.code_locked(other, t0 + CODE_LOCKOUT * 4 * WRONG_CODES), None);
|
||||
// Codes tried a minute, right or wrong (these limits hold on private servers too).
|
||||
let typist = ip("198.51.100.11");
|
||||
let tried = (0..100).filter(|_| abuse.allow(typist, Action::Code, t0)).count();
|
||||
assert_eq!(tried as f64, CODES_PER_MINUTE);
|
||||
assert!(abuse.allow(typist, Action::Code, t0 + Duration::from_secs(7)), "one more every six seconds");
|
||||
// Everyone together: many addresses guessing pause codes for all (links still join).
|
||||
let mut abuse = Abuse::new(AbuseConfig { open: true, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 });
|
||||
assert!(abuse.codes_open(t0));
|
||||
for n in 0..WRONG_CODES_PER_HOUR as u32 {
|
||||
abuse.code_tried(IpAddr::from([10, 1, (n >> 8) as u8, n as u8]), false, t0);
|
||||
}
|
||||
assert!(!abuse.codes_open(t0));
|
||||
assert!(abuse.codes_open(t0 + Duration::from_secs(60)), "it refills");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everyone_guessing_pauses_codes() {
|
||||
let dir = TempDir::new("codes-busy");
|
||||
let mut store = Store::open(dir.path(), open(dir.path()).store()).unwrap();
|
||||
let (file, edit, _, _) = store.publish_open(ip("192.0.2.7"), "Poster", &snapshot("Poster")).unwrap();
|
||||
let shown = store.set_code(&file.id, &edit.token).unwrap();
|
||||
// Junk costs no hashing, so a thousand wrong "codes" from a thousand addresses are quick.
|
||||
for n in 0..WRONG_CODES_PER_HOUR as u32 {
|
||||
assert_eq!(store.resolve_code(IpAddr::from([10, 2, (n >> 8) as u8, n as u8]), "not a code").unwrap_err(), NO_CODE);
|
||||
}
|
||||
assert_eq!(store.resolve_code(ip("192.0.2.99"), &shown).unwrap_err(), CODES_BUSY);
|
||||
assert!(store.admit(&file.id, &edit.token).is_ok(), "links are not affected");
|
||||
}
|
||||
|
||||
fn refused(raw: &mut Raw) -> String {
|
||||
match raw.next() {
|
||||
ServerMsg::Refused { reason } => reason,
|
||||
other => panic!("expected a refusal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_requests_are_refused_and_never_hurt_the_server() {
|
||||
let dir = TempDir::new("codes-junk");
|
||||
let mut server = crate::start(&open(dir.path())).unwrap();
|
||||
let addr = server.addr();
|
||||
let url = format!("ws://{addr}/");
|
||||
let mut a = app("Ada", &url);
|
||||
ex(&mut a, "collab.share", json!({}));
|
||||
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some());
|
||||
let p = a.live().published.clone().unwrap();
|
||||
let ask = |code: &str, protocol: u32| {
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&ClientMsg::Resolve(Resolve { protocol, client_version: String::new(), code: code.to_string() }));
|
||||
let why = refused(&mut raw);
|
||||
assert!(raw.closed_within(Duration::from_secs(5)), "the connection closes after the answer");
|
||||
why
|
||||
};
|
||||
// Junk in place of a code (each from this one address: after ten it is locked out).
|
||||
for junk in ["", " ", "💥💥💥", "K7M-2QX", "../../etc/passwd", "K7M2QX9TDK7M2QX9TD"] {
|
||||
assert_eq!(ask(junk, PROTOCOL), NO_CODE, "{junk:?}");
|
||||
}
|
||||
assert_eq!(ask(&"Z".repeat(4000), PROTOCOL), NO_CODE);
|
||||
assert!(ask("K7M2QX9TD", 1).contains("isn't supported"));
|
||||
// Asking for a code needs the file's owner token (or the key), and one of its link tokens.
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
|
||||
raw.send(&ClientMsg::Admin(Admin::Code { file: p.file.clone(), token: "AAAAAAAAAAAAAAAAAAAAAA".into() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("only its own"))));
|
||||
let mut raw = Raw::connect(addr);
|
||||
let owner = p.owner.clone().unwrap();
|
||||
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: p.file.clone(), owner }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
|
||||
for token in ["", "AAAAAAAAAAAAAAAAAAAAAA", &"x".repeat(5000)] {
|
||||
raw.send(&ClientMsg::Admin(Admin::Code { file: p.file.clone(), token: token.to_string() }));
|
||||
assert!(
|
||||
matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("isn't one of this file's links")))
|
||||
);
|
||||
}
|
||||
// A code in place of a link's token joins nothing: codes only resolve.
|
||||
let mut raw = Raw::connect(addr);
|
||||
let shown = a.live_summary()["code"].as_str().unwrap().replace('-', "");
|
||||
let hello = Hello {
|
||||
protocol: PROTOCOL,
|
||||
file: p.file.clone(),
|
||||
token: shown,
|
||||
name: "Mal".into(),
|
||||
color: String::new(),
|
||||
client_version: String::new(),
|
||||
resume: None,
|
||||
};
|
||||
raw.send(&ClientMsg::Hello(hello));
|
||||
assert!(refused(&mut raw).contains("isn't valid"));
|
||||
// The session went on through all of it.
|
||||
assert_eq!(a.live_summary()["status"], "live");
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clients_from_before_join_codes_are_served_as_before() {
|
||||
let dir = TempDir::new("codes-old-client");
|
||||
let mut server = crate::start(&open(dir.path())).unwrap();
|
||||
let url = format!("ws://{}/", server.addr());
|
||||
// An app that never asks for codes (`collab.publish`, what older apps do): links only, and
|
||||
// nothing about codes is kept for the file.
|
||||
let mut a = session("Ada");
|
||||
ex(&mut a, "collab.publish", json!({"server": url, "name": "Poster"}));
|
||||
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some());
|
||||
let p = a.live().published.clone().unwrap();
|
||||
assert_eq!((&a.live_summary()["code"], &a.live_summary()["share"]), (&Value::Null, &Value::Null));
|
||||
let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap();
|
||||
assert!(!links.contains("\"code\""), "{links}");
|
||||
// Its links join as ever.
|
||||
let mut b = crate::test_util::join(&p.view, "Bo");
|
||||
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]));
|
||||
server.stop();
|
||||
// A data directory from before join codes (links without the field) opens unchanged.
|
||||
let old = r#"[{"id":"0123456789abcdef","access":"edit","label":"Published (edit)","created":1,"hash":"00"}]"#;
|
||||
let path = dir.path().join("files").join(&p.file).join("links.json");
|
||||
std::fs::write(&path, old).unwrap();
|
||||
let mut store = Store::open(dir.path(), open(dir.path()).store()).unwrap();
|
||||
assert_eq!(store.links(&p.file).unwrap().len(), 1);
|
||||
assert_eq!(store.resolve_code(ip("192.0.2.1"), "K7M2QX9TD").unwrap_err(), NO_CODE);
|
||||
}
|
||||
@@ -235,6 +235,21 @@ fn a_file_cant_grow_past_its_cap_through_batches() {
|
||||
// Its snapshot holds the document and the recent batches kept for reconnecting clients (at
|
||||
// most a quarter of the cap), and the last batch let in may finish what the cap allows.
|
||||
assert!(on_disk <= max + max / 4 + 210_000, "{on_disk} bytes on disk");
|
||||
drop(raw);
|
||||
server.stop();
|
||||
// Everything confirmed opens again with the same settings, after compacting on the way out.
|
||||
let kept = client.document().nodes().count();
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let mut raw = Raw::connect(server.addr());
|
||||
raw.send(&Client::hello(&file.id, &edit.token, "Grower", ""));
|
||||
let welcome = loop {
|
||||
match raw.next() {
|
||||
w @ ServerMsg::Welcome { .. } => break w,
|
||||
ServerMsg::Refused { reason } => panic!("the file doesn't open again: {reason}"),
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
assert_eq!(Client::join(welcome).unwrap().document().nodes().count(), kept);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
@@ -785,3 +800,41 @@ fn the_server_forgets_addresses_whose_connections_are_gone() {
|
||||
assert!(server.blocks_remembered() <= 2, "{} blocks remembered", server.blocks_remembered());
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_publish_that_fails_keeps_serving_the_old_version() {
|
||||
let dir = TempDir::new("libfail");
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap();
|
||||
// Version 1, then a changed library as version 2.
|
||||
let mut s = Session::new();
|
||||
let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap();
|
||||
let c = s.execute("component.create", &json!({"ids": [f]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("library.publish", &json!({})).unwrap();
|
||||
let v1 = figcraft_engine::library_server::archive(&s.doc().unwrap().published.clone().unwrap()).unwrap();
|
||||
s.execute("node.set", &json!({"id": c, "props": {"fill": "#00ff00"}})).unwrap();
|
||||
s.execute("library.publish", &json!({})).unwrap();
|
||||
let v2 = figcraft_engine::library_server::archive(&s.doc().unwrap().published.clone().unwrap()).unwrap();
|
||||
assert_eq!(store.publish_library(&file.id, &v1).unwrap(), 1);
|
||||
let files = dir.path().join(crate::store::FILES).join(&file.id);
|
||||
// The description can't be written: a directory sits where its temporary file goes.
|
||||
let tmp = crate::disk::temp_of(&files.join(crate::disk::META));
|
||||
std::fs::create_dir(&tmp).unwrap();
|
||||
assert!(store.publish_library(&file.id, &v2).is_err());
|
||||
let fetch = |s: &mut Store| s.library(ip("198.51.100.1"), &file.id, Access::View, LibraryReq::Fetch { have: None, usage: None });
|
||||
let served = |reply: LibraryReply| match reply {
|
||||
LibraryReply::Library { version, archive } => (version, archive),
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert_eq!(served(fetch(&mut store)), (1, v1.clone()), "version 1, with its own bytes");
|
||||
// A crash right after the archive was written leaves it on disk, the description unchanged.
|
||||
std::fs::write(files.join(crate::store::library_name(2)), &v2).unwrap();
|
||||
drop(store);
|
||||
std::fs::remove_dir(&tmp).unwrap();
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
assert_eq!(served(fetch(&mut store)), (1, v1), "still version 1 after a restart");
|
||||
// Publishing again works, and serves the new version.
|
||||
assert_eq!(store.publish_library(&file.id, &v2).unwrap(), 2);
|
||||
assert_eq!(served(fetch(&mut store)), (2, v2));
|
||||
assert!(!files.join(crate::store::library_name(1)).exists(), "the old version is deleted");
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
pub mod abuse;
|
||||
pub mod cli;
|
||||
pub mod codes;
|
||||
pub mod config;
|
||||
pub mod directory;
|
||||
pub mod disk;
|
||||
@@ -90,12 +91,16 @@ mod background_tests;
|
||||
#[cfg(test)]
|
||||
mod cli_tests;
|
||||
#[cfg(test)]
|
||||
mod codes_tests;
|
||||
#[cfg(test)]
|
||||
mod directory_tests;
|
||||
#[cfg(test)]
|
||||
mod hardening_tests;
|
||||
#[cfg(test)]
|
||||
mod library_tests;
|
||||
#[cfg(test)]
|
||||
mod owned_tests;
|
||||
#[cfg(test)]
|
||||
mod public_tests;
|
||||
#[cfg(test)]
|
||||
mod server_tests;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Your online files against a real server: a file shared online is recorded with its owner
|
||||
//! token; after a restart of the app the server says what became of it (`Admin::Owned`, one
|
||||
//! request for all the files), it opens again as its owner in step with everyone who joined by
|
||||
//! code, its sharing stops and starts from the record, and removing it takes it off the server's
|
||||
//! disk. The request itself: only a file's own token learns about it, junk never hurts, and an
|
||||
//! owner's question opens nothing (a file isn't kept alive by being asked about).
|
||||
|
||||
use figcraft_collab::message::{Admin, AdminReply, ClientMsg, MAX_OWNED, OpenHosting, PROTOCOL, ServerMsg};
|
||||
use figcraft_engine::Prefs;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::test_util::{Raw, TempDir, config, live, pump, same, session};
|
||||
|
||||
fn open(dir: &std::path::Path) -> Config {
|
||||
Config { open_publish: true, ..config(dir) }
|
||||
}
|
||||
|
||||
fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
/// An open hosting connection that names no file.
|
||||
fn hosting(addr: std::net::SocketAddr) -> Raw {
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: "test".into(), file: String::new(), owner: String::new() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
|
||||
raw
|
||||
}
|
||||
|
||||
fn owned(raw: &mut Raw, files: Vec<(String, String)>) -> AdminReply {
|
||||
raw.send(&ClientMsg::Admin(Admin::Owned { files }));
|
||||
match raw.next() {
|
||||
ServerMsg::Admin(r) => *r,
|
||||
other => panic!("answered {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ada shares her open file online through the server at `url`: the app with the file live.
|
||||
fn share(url: &str) -> figcraft_engine::Session {
|
||||
let mut a = session("Ada");
|
||||
ex(&mut a, "pref.publishServer", json!({"url": url}));
|
||||
ex(&mut a, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 30}));
|
||||
ex(&mut a, "collab.share", json!({}));
|
||||
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().share.as_ref().is_some_and(|sh| sh.links.is_some()));
|
||||
a
|
||||
}
|
||||
|
||||
/// The app quits (its sessions end with it); the next run has only the preferences.
|
||||
fn restart(a: figcraft_engine::Session, name: &str) -> figcraft_engine::Session {
|
||||
let prefs: Prefs = serde_json::from_value(serde_json::to_value(&a.prefs).unwrap()).unwrap();
|
||||
drop(a);
|
||||
let mut next = session(name);
|
||||
next.prefs = prefs;
|
||||
next
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_file_is_its_owners_again_after_the_app_restarts() {
|
||||
let dir = TempDir::new("owned-restart");
|
||||
let mut server = crate::start(&open(dir.path())).unwrap();
|
||||
let url = format!("ws://{}/", server.addr());
|
||||
let a = share(&url);
|
||||
let code = a.live_summary()["code"].as_str().unwrap().to_string();
|
||||
let file = a.prefs.online_files[0].file.clone();
|
||||
let owner = a.prefs.online_files[0].owner.clone();
|
||||
assert_eq!(a.live().published.as_ref().and_then(|p| p.owner.clone()), Some(owner.clone()));
|
||||
// The app quits. The next run knows the file from its preferences alone.
|
||||
let mut a = restart(a, "Ada");
|
||||
let list = ex(&mut a, "collab.files", json!({}));
|
||||
assert_eq!((&list["files"][0]["file"], &list["files"][0]["state"], &list["files"][0]["code"]), (&json!(file), &json!("unknown"), &json!(code)));
|
||||
// The server says it is there, and still shared.
|
||||
let list = ex(&mut a, "collab.files", json!({"refresh": true}));
|
||||
assert_eq!((&list["files"][0]["state"], &list["files"][0]["shared"], &list["problems"]), (&json!("online"), &json!(true), &json!([])));
|
||||
assert!(list["files"][0]["expires"].as_u64().is_some_and(|e| e > crate::store::now()));
|
||||
assert!(!list.to_string().contains(&owner));
|
||||
// Open it: the owner is in the file, synced, in a tab of its own.
|
||||
let opened = ex(&mut a, "collab.openFile", json!({"file": file}));
|
||||
assert!(!opened.to_string().contains(&owner));
|
||||
pump(&mut [&mut a], "the owner back in", |s| live(s[0]));
|
||||
assert_eq!(a.docs.len(), 2);
|
||||
assert!(!a.live_summary().to_string().contains(&owner), "the token stays in the record");
|
||||
// Someone joins with the code given out before the restart, and sees the owner's edits.
|
||||
let mut b = session("Bo");
|
||||
ex(&mut b, "pref.publishServer", json!({"url": url}));
|
||||
ex(&mut b, "collab.join", json!({"code": code}));
|
||||
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
||||
ex(&mut a, "shape.ellipse", json!({"x": 100, "y": 100, "width": 20, "height": 20}));
|
||||
pump(&mut [&mut a, &mut b], "the edit at Bo's", |s| same(s[0].doc().unwrap(), s[1].doc().unwrap()));
|
||||
// Stop sharing from the list (no tab needed): Bo is out, the owner stays, the code is dead.
|
||||
assert_eq!(ex(&mut a, "collab.manageFile", json!({"file": file, "action": "unshare"}))["shared"], false);
|
||||
pump(&mut [&mut a, &mut b], "Bo out", |s| !s[1].live().status.active() && live(s[0]));
|
||||
assert_eq!(a.live_summary()["share"]["shared"], false, "the open tab follows");
|
||||
let mut c = session("Cy");
|
||||
ex(&mut c, "pref.publishServer", json!({"url": url}));
|
||||
ex(&mut c, "collab.join", json!({"code": code}));
|
||||
pump(&mut [&mut c], "the old code refused", |s| matches!(s[0].live().status, figcraft_engine::live::Status::Failed { .. }));
|
||||
// Start again: a new code for the same file.
|
||||
let again = ex(&mut a, "collab.share", json!({}));
|
||||
let new_code = again["code"].as_str().unwrap().to_string();
|
||||
assert_ne!(new_code, code);
|
||||
assert_eq!(ex(&mut a, "collab.files", json!({}))["files"][0]["code"], new_code);
|
||||
let mut d = session("Di");
|
||||
ex(&mut d, "pref.publishServer", json!({"url": url}));
|
||||
ex(&mut d, "collab.join", json!({"code": new_code}));
|
||||
pump(&mut [&mut a, &mut d], "Di in with the new code", |s| live(s[1]));
|
||||
// Rename, then remove: the file leaves the server's disk and the list; the tab's session ends.
|
||||
ex(&mut a, "collab.manageFile", json!({"file": file, "action": "rename", "name": "Landing"}));
|
||||
assert_eq!(ex(&mut a, "collab.files", json!({"refresh": true}))["files"][0]["name"], "Landing");
|
||||
assert!(dir.path().join("files").join(&file).exists());
|
||||
assert_eq!(ex(&mut a, "collab.manageFile", json!({"file": file, "action": "remove"}))["done"], true);
|
||||
assert!(!dir.path().join("files").join(&file).exists(), "gone from the data directory");
|
||||
assert!(a.prefs.online_files.is_empty() && !a.live().status.active() && a.docs.len() == 2, "the copy stays open");
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_expired_or_was_removed_elsewhere_shows_as_gone() {
|
||||
let dir = TempDir::new("owned-gone");
|
||||
let cfg = open(dir.path());
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let url = format!("ws://{}/", server.addr());
|
||||
let mut a = share(&url);
|
||||
let file = a.prefs.online_files[0].file.clone();
|
||||
ex(&mut a, "collab.leave", json!({}));
|
||||
let mut a = restart(a, "Ada");
|
||||
// The operator removes it.
|
||||
crate::cli::run(&crate::cli::Cli { config: cfg.clone(), command: crate::cli::Command::RemoveFile { file: file.clone() } }).unwrap();
|
||||
let list = ex(&mut a, "collab.files", json!({"refresh": true}));
|
||||
assert_eq!((&list["files"][0]["state"], &list["files"][0]["shared"]), (&json!("gone"), &json!(false)));
|
||||
assert!(a.execute("collab.openFile", &json!({"file": file})).unwrap_err().to_string().contains("no longer on its server"));
|
||||
assert_eq!(ex(&mut a, "collab.forgetFile", json!({"file": file}))["onServer"], false);
|
||||
// The server stops: the list says it couldn't be reached and keeps what it has.
|
||||
let mut b = share(&url);
|
||||
ex(&mut b, "collab.leave", json!({}));
|
||||
server.stop();
|
||||
let list = ex(&mut b, "collab.files", json!({"refresh": true}));
|
||||
assert_eq!((&list["files"][0]["state"], &list["problems"][0]["state"]), (&json!("online"), &json!("offline")));
|
||||
assert_eq!(list["files"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_files_own_token_learns_about_it() {
|
||||
let dir = TempDir::new("owned-ask");
|
||||
let cfg = open(dir.path());
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
let a = share(&format!("ws://{addr}/"));
|
||||
let (file, owner) = (a.prefs.online_files[0].file.clone(), a.prefs.online_files[0].owner.clone());
|
||||
let opened_before = std::fs::read_to_string(dir.path().join("files").join(&file).join("meta.json")).unwrap();
|
||||
let mut raw = hosting(addr);
|
||||
let pair = |f: &str, o: &str| (f.to_string(), o.to_string());
|
||||
let reply = owned(
|
||||
&mut raw,
|
||||
vec![pair(&file, &owner), pair(&file, "WrongTokenWrongToken00"), pair("nosuchfile", &owner), pair("", ""), pair("../../etc", "x")],
|
||||
);
|
||||
let AdminReply::Owned(list) = reply else { panic!("answered {reply:?}") };
|
||||
assert_eq!(list.len(), 5, "one answer per pair, in order");
|
||||
let first = list[0].as_ref().expect("its own token");
|
||||
assert_eq!(first.name, "Untitled");
|
||||
assert_eq!(first.links.len(), 4, "the owner's two links and the two made for others");
|
||||
assert!(first.expires.is_some());
|
||||
assert!(list[1..].iter().all(Option::is_none), "a wrong token and a missing file read the same");
|
||||
// Nothing asked: nothing answered; too many: refused, and the connection stays usable.
|
||||
assert_eq!(owned(&mut raw, vec![]), AdminReply::Owned(vec![]));
|
||||
let many = vec![pair(&file, &owner); MAX_OWNED + 1];
|
||||
assert!(matches!(owned(&mut raw, many), AdminReply::Failed { reason } if reason.contains("at most")));
|
||||
assert!(matches!(owned(&mut raw, vec![pair(&file, &owner); MAX_OWNED]), AdminReply::Owned(l) if l.len() == MAX_OWNED));
|
||||
// Junk ids and tokens (long, control characters, other scripts) are just not theirs.
|
||||
let junk = vec![pair(&"x".repeat(100_000), &"y".repeat(100_000)), pair("\u{0}\u{7}", "\u{1F4A5}"), pair(&file, &format!("{owner}\u{0}"))];
|
||||
assert!(matches!(owned(&mut raw, junk), AdminReply::Owned(l) if l.iter().all(Option::is_none)));
|
||||
// On that connection the token still manages nothing: it named no file when it opened.
|
||||
raw.send(&ClientMsg::Admin(Admin::Remove { file: file.clone() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { .. })));
|
||||
assert!(dir.path().join("files").join(&file).exists());
|
||||
// Asking opened nothing: when the file was last opened is as it was.
|
||||
assert_eq!(std::fs::read_to_string(dir.path().join("files").join(&file).join("meta.json")).unwrap(), opened_before);
|
||||
// A connection that manages one file with its token may ask too; the server key as well.
|
||||
let mut one = Raw::connect(addr);
|
||||
one.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: "test".into(), file: file.clone(), owner: owner.clone() }));
|
||||
assert!(matches!(one.next(), ServerMsg::Hosting(_)));
|
||||
assert!(matches!(owned(&mut one, vec![pair(&file, &owner)]), AdminReply::Owned(l) if l[0].is_some()));
|
||||
drop(a);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_isnt_open_to_the_public_answers_that_nothing_is_owned() {
|
||||
let dir = TempDir::new("owned-private");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
let mut raw = hosting(server.addr());
|
||||
assert!(matches!(owned(&mut raw, vec![("f".into(), "t".into())]), AdminReply::Owned(l) if l == [None]));
|
||||
server.stop();
|
||||
}
|
||||
@@ -60,8 +60,8 @@ fn anyone_publishes_and_the_owner_token_manages_the_file() {
|
||||
assert_eq!(info["idleDays"], 30.0);
|
||||
// Published without a key: links, an owner token shown once, and an expiry date.
|
||||
ex(&mut a, "collab.publish", json!({"server": url, "name": "Poster"}));
|
||||
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live.published.is_some() && s[0].live.hosting.is_some());
|
||||
let p = a.live.published.clone().unwrap();
|
||||
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some() && s[0].live().hosting.is_some());
|
||||
let p = a.live().published.clone().unwrap();
|
||||
let owner = p.owner.clone().expect("an owner token");
|
||||
assert_eq!(owner.len(), 22, "128 bits");
|
||||
assert!(p.expires.unwrap() > crate::store::now() + 29 * 86_400);
|
||||
@@ -73,7 +73,7 @@ fn anyone_publishes_and_the_owner_token_manages_the_file() {
|
||||
assert!(!meta.contains(&owner) && meta.contains(&crate::store::hash(&owner)) && meta.contains("127.0.0.1"), "{meta}");
|
||||
// Someone joining hears about the hosting too (with the file's expiry).
|
||||
let mut b = join(&p.edit, "Bo");
|
||||
pump(&mut [&mut a, &mut b], "Bo in, with the hosting", |s| live(s[1]) && s[1].live.hosting.as_ref().is_some_and(|h| h.expires.is_some()));
|
||||
pump(&mut [&mut a, &mut b], "Bo in, with the hosting", |s| live(s[1]) && s[1].live().hosting.as_ref().is_some_and(|h| h.expires.is_some()));
|
||||
// The owner lists, makes, revokes links and renames the file.
|
||||
let manage = |s: &mut figcraft_engine::Session, extra: Value| {
|
||||
let mut p2 = json!({"server": url, "file": p.file, "owner": owner});
|
||||
@@ -98,9 +98,9 @@ fn anyone_publishes_and_the_owner_token_manages_the_file() {
|
||||
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("only its own"))));
|
||||
raw.send(&ClientMsg::Admin(Admin::Listing));
|
||||
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("server key"))));
|
||||
// Removing it disconnects everyone in it.
|
||||
// Removing it disconnects everyone in it (whoever removed it just leaves: no failure there).
|
||||
assert_eq!(manage(&mut a, json!({"action": "remove"})).unwrap()["done"], true);
|
||||
pump(&mut [&mut a, &mut b], "everyone told", |s| s.iter().all(|x| x.live_summary()["status"] == "failed"));
|
||||
pump(&mut [&mut a, &mut b], "everyone told", |s| s[0].live_summary()["status"] == "off" && s[1].live_summary()["status"] == "failed");
|
||||
assert!(b.message.as_deref().unwrap_or_default().contains("removed"), "{:?}", b.message);
|
||||
assert!(!dir.path().join("files").join(&p.file).exists());
|
||||
server.stop();
|
||||
@@ -260,10 +260,10 @@ fn participants_report_files_to_the_operator() {
|
||||
let url = format!("ws://{}/", server.addr());
|
||||
let mut a = session("Ada");
|
||||
ex(&mut a, "collab.publish", json!({"server": url, "name": "Flyer"}));
|
||||
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
|
||||
let p = a.live.published.clone().unwrap();
|
||||
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some());
|
||||
let p = a.live().published.clone().unwrap();
|
||||
let mut b = join(&p.view, "Bo");
|
||||
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[1].live.hosting.is_some());
|
||||
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[1].live().hosting.is_some());
|
||||
assert!(b.execute("collab.report", &json!({"reason": " "})).is_err());
|
||||
ex(&mut b, "collab.report", json!({"reason": "Spam\nwith a link"}));
|
||||
pump(&mut [&mut b], "the answer", |s| s[0].message.as_deref().is_some_and(|m| m.contains("operator")));
|
||||
|
||||
@@ -34,8 +34,8 @@ fn publish_then_edit_and_view_together() {
|
||||
// Nothing changes it until it is on the server.
|
||||
let e = a.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap_err().to_string();
|
||||
assert!(e.contains("being published"), "{e}");
|
||||
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
|
||||
let p = a.live.published.clone().unwrap();
|
||||
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some());
|
||||
let p = a.live().published.clone().unwrap();
|
||||
assert_eq!(a.docs.len(), 1, "the open tab syncs; no new tab");
|
||||
assert_eq!(a.live_summary()["readOnly"], false);
|
||||
assert!(p.edit.starts_with("figcraft://join?host=127.0.0.1:") && p.view.starts_with("figcraft://join?host=127.0.0.1:"));
|
||||
@@ -43,7 +43,7 @@ fn publish_then_edit_and_view_together() {
|
||||
// Bo joins with the edit link, Cy with the view link.
|
||||
let mut b = join(&p.edit, "Bo");
|
||||
let mut c = join(&p.view, "Cy");
|
||||
pump(&mut [&mut a, &mut b, &mut c], "everyone in", |s| s.iter().all(|x| live(x)) && s[0].live.peers.len() == 2);
|
||||
pump(&mut [&mut a, &mut b, &mut c], "everyone in", |s| s.iter().all(|x| live(x)) && s[0].live().peers.len() == 2);
|
||||
assert_eq!(b.live_summary()["access"], "edit");
|
||||
assert_eq!(c.live_summary()["access"], "view");
|
||||
assert_eq!(c.live_summary()["readOnly"], true);
|
||||
@@ -64,9 +64,9 @@ fn publish_then_edit_and_view_together() {
|
||||
assert!(c.begin_interaction("Move").is_err());
|
||||
assert!(same(&before, c.doc().unwrap()));
|
||||
// Cy is still seen: presence goes both ways.
|
||||
let cy = c.live.info.as_ref().unwrap().me;
|
||||
let cy = c.live().info.as_ref().unwrap().me;
|
||||
c.execute("collab.cursor", &json!({"x": 12, "y": 34})).unwrap();
|
||||
pump(&mut [&mut a, &mut b, &mut c], "Cy's cursor at Ada", |s| s[0].live.presence.get(&cy).and_then(|p| p.cursor).is_some_and(|c| c.1 == 12.0));
|
||||
pump(&mut [&mut a, &mut b, &mut c], "Cy's cursor at Ada", |s| s[0].live().presence.get(&cy).and_then(|p| p.cursor).is_some_and(|c| c.1 == 12.0));
|
||||
// A client that skips the app's check is refused by the server.
|
||||
let view = Invite::parse(&p.view).unwrap();
|
||||
let mut raw = Raw::connect(addr);
|
||||
@@ -90,7 +90,7 @@ fn publish_then_edit_and_view_together() {
|
||||
pump(&mut [&mut a], "nothing changed", |s| s[0].doc().unwrap().node(rect).is_some_and(|n| n.name == "Card"));
|
||||
// Leaving keeps a local copy.
|
||||
c.execute("collab.leave", &json!({})).unwrap();
|
||||
assert_eq!(c.live.status, Status::Off);
|
||||
assert_eq!(c.live().status, Status::Off);
|
||||
assert!(c.doc().unwrap().contains(rect));
|
||||
assert_eq!(c.live_summary()["readOnly"], false, "the copy is yours to edit");
|
||||
c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
|
||||
@@ -106,21 +106,21 @@ fn a_restart_mid_session_loses_no_confirmed_edit() {
|
||||
let mut a = session("Ada");
|
||||
let p = publish(&mut a, dir.path(), addr);
|
||||
let mut b = join(&p.edit, "Bo");
|
||||
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live.peers.len() == 1);
|
||||
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
||||
let rect = id_of(&a.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap());
|
||||
pump(&mut [&mut a, &mut b], "the rectangle confirmed", |s| s[1].doc().unwrap().contains(rect));
|
||||
let (ada, bo) = (a.live.info.as_ref().unwrap().me, b.live.info.as_ref().unwrap().me);
|
||||
let (ada, bo) = (a.live().info.as_ref().unwrap().me, b.live().info.as_ref().unwrap().me);
|
||||
// The server stops: both reconnect, and what Ada makes meanwhile waits.
|
||||
server.stop();
|
||||
pump(&mut [&mut a, &mut b], "the drop", |s| s.iter().all(|x| matches!(x.live.status, Status::Reconnecting { .. })));
|
||||
pump(&mut [&mut a, &mut b], "the drop", |s| s.iter().all(|x| matches!(x.live().status, Status::Reconnecting { .. })));
|
||||
let offline = id_of(&a.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 30, "height": 30})).unwrap());
|
||||
cfg.listen = addr.to_string();
|
||||
let mut server = start_again(&cfg);
|
||||
pump(&mut [&mut a, &mut b], "everyone back with everything", |s| {
|
||||
s.iter().all(|x| live(x)) && s[1].doc().unwrap().contains(offline) && s[1].doc().unwrap().contains(rect)
|
||||
});
|
||||
assert_eq!(a.live.info.as_ref().unwrap().me, ada, "Ada resumed as herself");
|
||||
assert_eq!(b.live.info.as_ref().unwrap().me, bo, "so did Bo");
|
||||
assert_eq!(a.live().info.as_ref().unwrap().me, ada, "Ada resumed as herself");
|
||||
assert_eq!(b.live().info.as_ref().unwrap().me, bo, "so did Bo");
|
||||
assert!(same(a.doc().unwrap(), b.doc().unwrap()));
|
||||
// Someone new gets it all from the restarted server.
|
||||
let mut c = join(&p.view, "Cy");
|
||||
@@ -160,20 +160,20 @@ fn revoked_links_stop_working_at_once() {
|
||||
assert_eq!((files.len(), files[0].people), (1, 3));
|
||||
// Revoked: Cy is out at once, the others stay.
|
||||
assert_eq!(ask(Admin::Revoke { file: file.clone(), link: review.link.id.clone() }), AdminReply::Done);
|
||||
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| {
|
||||
matches!(&s[2].live.status, Status::Failed { reason } if reason.contains("revoked")) && s[0].live.peers.len() == 1
|
||||
});
|
||||
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| s[2].live().status == Status::Off && s[0].live().peers.len() == 1);
|
||||
// Told as when a host stops: the session is over for Cy, whose copy stays open (no retries).
|
||||
assert!(c.message.as_deref().unwrap_or_default().contains("Sharing was stopped"), "{:?}", c.message);
|
||||
assert!(live(&b));
|
||||
// The link no longer joins.
|
||||
c.execute("collab.join", &json!({"link": link})).unwrap();
|
||||
pump(&mut [&mut c], "the refusal", |s| matches!(&s[0].live.status, Status::Failed { reason } if reason.contains("isn't valid")));
|
||||
pump(&mut [&mut c], "the refusal", |s| matches!(&s[0].live().status, Status::Failed { reason } if reason.contains("isn't valid")));
|
||||
// Managing needs the key.
|
||||
let e = manage::request(&endpoint, "WrongWrongWrongWrong00", vec![Admin::Files], &NetLimits::default(), Duration::from_secs(5)).unwrap_err();
|
||||
assert!(e.contains("wrong server key"), "{e}");
|
||||
// Removing the file ends it for everyone in it.
|
||||
assert_eq!(ask(Admin::Remove { file: file.clone() }), AdminReply::Done);
|
||||
pump(&mut [&mut a, &mut b], "everyone out", |s| {
|
||||
s.iter().all(|x| matches!(&x.live.status, Status::Failed { reason } if reason.contains("removed")))
|
||||
s.iter().all(|x| matches!(&x.live().status, Status::Failed { reason } if reason.contains("removed")))
|
||||
});
|
||||
server.stop();
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
//! <data>/server.json where the running server listens (for the command line)
|
||||
//! <data>/server.key the server key: managing files (publish, links, removal)
|
||||
//! <data>/files/<id>/meta.json name, created, updated, sequence number
|
||||
//! <data>/files/<id>/links.json share links: id, access, label, created, SHA-256 of the token
|
||||
//! <data>/files/<id>/links.json share links: id, access, label, created, SHA-256 of the token,
|
||||
//! and a join code's hash and wrapped token (see `codes`)
|
||||
//! <data>/files/<id>/snapshot.bin the session state at the last compaction
|
||||
//! <data>/files/<id>/log.bin what changed since (see `disk`)
|
||||
//! <data>/files/<id>/library.bin its team library, when one was published (a `.figcraft` archive)
|
||||
//! <data>/files/<id>/library-<version>.bin its team library, when one was published (a `.figcraft`
|
||||
//! archive; `library.bin` from servers before versioned names)
|
||||
//! <data>/files/<id>/usage.json how other files use that library, as their apps report it
|
||||
//! <data>/plugins/ the plugin directory (see `directory`)
|
||||
//! <data>/blocked.json addresses the operator blocked
|
||||
@@ -28,7 +30,7 @@
|
||||
//! hashes and compared in constant time. Whatever is damaged at startup is skipped and reported,
|
||||
//! never deleted.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
use std::fs::{self, File, OpenOptions, TryLockError};
|
||||
use std::io::{Cursor, Write};
|
||||
use std::net::IpAddr;
|
||||
@@ -36,8 +38,8 @@ use std::path::{Path, PathBuf};
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use figcraft_collab::message::{
|
||||
Access, Admin, AdminReply, ClientMsg, FileInfo, FileRow, FileUsage, HostInfo, LibraryReply, LibraryReq, LinkInfo, NewLink, PluginReply,
|
||||
PluginReq, ReportInfo, Usage,
|
||||
Access, Admin, AdminReply, ClientMsg, FileInfo, FileRow, FileUsage, HostInfo, LibraryReply, LibraryReq, LinkInfo, MAX_OWNED, NewLink, OwnedFile,
|
||||
PluginReply, PluginReq, ReportInfo, Usage,
|
||||
};
|
||||
use figcraft_collab::{Change, Replica, Server, same_secret};
|
||||
use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_key};
|
||||
@@ -46,6 +48,7 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::abuse::{Abuse, AbuseConfig, Action};
|
||||
use crate::codes::{self, Sealed};
|
||||
use crate::directory::{Directory, DirectoryConfig};
|
||||
use crate::disk::{self, LIBRARY, LINKS, LOG, LOG_HEADER, META, SNAPSHOT, USAGE};
|
||||
|
||||
@@ -58,6 +61,12 @@ pub const REPORTS: &str = "reports.json";
|
||||
/// Most addresses blocked, and most reports kept (the oldest go first).
|
||||
const MAX_BLOCKED: usize = 100_000;
|
||||
const MAX_REPORTS: usize = 1_000;
|
||||
/// Most join codes remembered as no longer valid.
|
||||
const MAX_GONE_CODES: usize = 10_000;
|
||||
/// What typing a join code that opens nothing is answered with, by why.
|
||||
pub const NO_CODE: &str = "No session with that code";
|
||||
pub const CODE_GONE: &str = "That code has expired or its sharing was stopped";
|
||||
pub const CODES_BUSY: &str = "Join codes are paused on this server for a while (too many wrong ones were tried): join with the invite link";
|
||||
/// Longest report reason kept (characters).
|
||||
const MAX_REASON: usize = 500;
|
||||
/// How often idle files are looked for (seconds), besides at startup.
|
||||
@@ -171,7 +180,7 @@ impl Meta {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a file's team library is (`library.bin` holds it).
|
||||
/// What a file's team library is ([`library_file`] holds it).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
struct LibraryMeta {
|
||||
id: String,
|
||||
@@ -188,6 +197,9 @@ struct Link {
|
||||
created: u64,
|
||||
/// SHA-256 of the token, hex.
|
||||
hash: String,
|
||||
/// Its join code, when one was handed out (never the code itself).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
code: Option<Sealed>,
|
||||
}
|
||||
|
||||
impl Link {
|
||||
@@ -245,10 +257,18 @@ pub struct Store {
|
||||
usage: BTreeMap<String, UsageCache>,
|
||||
blocked: BTreeSet<IpAddr>,
|
||||
reports: Vec<ReportInfo>,
|
||||
/// Hashes of join codes that no longer open anything (their link was revoked, their file
|
||||
/// removed or expired, or a newer code took their place): typing one says so.
|
||||
// shortcut: in memory only, so after a restart such a code reads as unknown; keep them in the
|
||||
// data directory if people ask why their code "never existed".
|
||||
gone_codes: VecDeque<String>,
|
||||
/// When idle files are looked for next (seconds since 1970).
|
||||
next_sweep: u64,
|
||||
/// Compactions so far (tests count them).
|
||||
compactions: u64,
|
||||
/// Open files closed by a compaction cut short (their snapshot replaced, their log not):
|
||||
/// reported at the next flush, so nothing appended after it is confirmed.
|
||||
cut_short: Vec<String>,
|
||||
/// The free space last seen on the data directory's disk, and when.
|
||||
free: Option<(Instant, Option<u64>)>,
|
||||
_lock: File,
|
||||
@@ -327,25 +347,30 @@ impl Store {
|
||||
let key = load_key(root)?;
|
||||
let dir = root.join(FILES);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
|
||||
let (directory, problems) = Directory::open(root, cfg.directory);
|
||||
let (directory, startup_problems) = Directory::open(root, cfg.directory);
|
||||
let abuse = Abuse::new(cfg.hosting.abuse);
|
||||
let mut store = Store {
|
||||
root: root.to_path_buf(),
|
||||
cfg,
|
||||
key,
|
||||
files: BTreeMap::new(),
|
||||
problems,
|
||||
problems: Vec::new(),
|
||||
directory,
|
||||
abuse,
|
||||
served: figcraft_collab_net::net::Bucket::new(LIBRARY_RATE, LIBRARY_BURST),
|
||||
usage: BTreeMap::new(),
|
||||
blocked: BTreeSet::new(),
|
||||
reports: Vec::new(),
|
||||
gone_codes: VecDeque::new(),
|
||||
next_sweep: 0,
|
||||
compactions: 0,
|
||||
cut_short: Vec::new(),
|
||||
free: None,
|
||||
_lock: lock,
|
||||
};
|
||||
for problem in startup_problems {
|
||||
store.note(problem);
|
||||
}
|
||||
store.scan(&dir);
|
||||
store.blocked = store.read_list::<String>(BLOCKED).iter().filter_map(|s| s.parse().ok()).map(ip_key).collect();
|
||||
store.reports = store.read_list(REPORTS);
|
||||
@@ -363,7 +388,7 @@ impl Store {
|
||||
match disk::read_capped(&path, 16 << 20).and_then(|b| serde_json::from_slice(&b).map_err(|e| format!("{name} is damaged ({e})"))) {
|
||||
Ok(list) => list,
|
||||
Err(e) => {
|
||||
self.problems.push(e);
|
||||
self.note(e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -372,7 +397,7 @@ impl Store {
|
||||
fn scan(&mut self, dir: &Path) {
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return self.problems.push(format!("can't list {}: {e}", dir.display())),
|
||||
Err(e) => return self.note(format!("can't list {}: {e}", dir.display())),
|
||||
};
|
||||
let mut ids: Vec<(String, PathBuf)> = entries.flatten().map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect();
|
||||
ids.sort();
|
||||
@@ -381,18 +406,18 @@ impl Store {
|
||||
continue;
|
||||
}
|
||||
if !valid_id(&id) {
|
||||
self.problems.push(format!("skipped {}: not a file folder of this server", path.display()));
|
||||
self.note(format!("skipped {}: not a file folder of this server", path.display()));
|
||||
continue;
|
||||
}
|
||||
if self.files.len() >= self.cfg.max_files {
|
||||
self.problems.push(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files));
|
||||
self.note(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files));
|
||||
continue;
|
||||
}
|
||||
match self.check(&path) {
|
||||
Ok(entry) => {
|
||||
self.files.insert(id, entry);
|
||||
}
|
||||
Err(why) => self.problems.push(format!("skipped file {id}: {why}")),
|
||||
Err(why) => self.note(format!("skipped file {id}: {why}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -409,7 +434,7 @@ impl Store {
|
||||
let meta: Meta = serde_json::from_slice(&disk::read_capped(&path.join(META), 1 << 20)?).map_err(|e| format!("{META} is damaged ({e})"))?;
|
||||
let links: Vec<Link> =
|
||||
serde_json::from_slice(&disk::read_capped(&path.join(LINKS), 16 << 20)?).map_err(|e| format!("{LINKS} is damaged ({e})"))?;
|
||||
let generation = disk::check_snapshot(&disk::read_capped(&path.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
|
||||
let generation = disk::check_snapshot(&disk::read_capped(&path.join(SNAPSHOT), self.snapshot_cap())?)?;
|
||||
// A damaged log only loses its end; it is reported when the file opens.
|
||||
if let Ok(bytes) = disk::read_capped(&path.join(LOG), self.cfg.max_file_bytes)
|
||||
&& let Ok(log) = disk::read_log(&bytes)
|
||||
@@ -604,6 +629,26 @@ impl Store {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Owner token `owner` manages file `file` (one published without the key).
|
||||
fn owns(&self, file: &str, owner: &str) -> bool {
|
||||
let presented = hash(owner);
|
||||
self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h))
|
||||
}
|
||||
|
||||
/// What became of the files these `(file id, owner token)` pairs own: each one's name, links
|
||||
/// and expiry, or `None` when that token manages no file of that id here (which doesn't say
|
||||
/// whether the id exists). Asking opens nothing: it doesn't count as using a file.
|
||||
pub fn owned(&self, files: &[(String, String)]) -> AdminReply {
|
||||
if files.len() > MAX_OWNED {
|
||||
return AdminReply::Failed { reason: format!("ask about at most {MAX_OWNED} files at a time") };
|
||||
}
|
||||
let one = |(file, owner): &(String, String)| {
|
||||
let e = self.files.get(file).filter(|_| self.owns(file, owner))?;
|
||||
Some(OwnedFile { name: e.meta.name.clone(), links: e.links.iter().map(|l| l.id.clone()).collect(), expires: self.expires(&e.meta) })
|
||||
};
|
||||
AdminReply::Owned(files.iter().map(one).collect())
|
||||
}
|
||||
|
||||
/// Rename a file (its listing; the document inside keeps its own name).
|
||||
pub fn rename(&mut self, file: &str, name: &str) -> Result<(), String> {
|
||||
let name = clean_name(name);
|
||||
@@ -755,6 +800,101 @@ impl Store {
|
||||
Ok(NewLink { link: link.info(file), token })
|
||||
}
|
||||
|
||||
/// The link of `entry` whose token `token` is (every link is compared, in constant time, so
|
||||
/// timing doesn't tell which one matched).
|
||||
fn link_of(entry: &Entry, token: &str) -> Option<usize> {
|
||||
let presented = hash(token);
|
||||
let mut found = None;
|
||||
for (i, l) in entry.links.iter().enumerate() {
|
||||
if same_secret(&presented, &l.hash) {
|
||||
found = Some(i);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// These join codes no longer open anything: typing one says so.
|
||||
fn forget_codes(&mut self, codes: impl IntoIterator<Item = Sealed>) {
|
||||
self.gone_codes.extend(codes.into_iter().map(|c| c.hash));
|
||||
let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES);
|
||||
self.gone_codes.drain(..extra);
|
||||
}
|
||||
|
||||
/// Where the link with the join code `key` is: its file, its place among the file's links
|
||||
/// and its token (every code is compared, so timing doesn't tell which one matched).
|
||||
fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> {
|
||||
let mut found = None;
|
||||
for (id, e) in &self.files {
|
||||
for (i, l) in e.links.iter().enumerate() {
|
||||
if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) {
|
||||
found = Some((id.clone(), i, token));
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// A new join code for the link of `file` whose token is `token` (it takes the place of the
|
||||
/// link's earlier code): the code, shown this once.
|
||||
pub fn set_code(&mut self, file: &str, token: &str) -> Result<String, String> {
|
||||
let at = Self::link_of(self.entry(file)?, token).ok_or("that token isn't one of this file's links (it may have been revoked)")?;
|
||||
// 45 random bits: one already in use is all but impossible, and never handed out twice.
|
||||
let (code, key) = loop {
|
||||
let code = codes::new_code()?;
|
||||
let key = codes::Key::new(&code);
|
||||
if self.find_code(&key).is_none() {
|
||||
break (code, key);
|
||||
}
|
||||
};
|
||||
let sealed = key.seal(token)?;
|
||||
let path = self.dir(file).join(LINKS);
|
||||
let entry = self.entry(file)?;
|
||||
let link = entry.links.get_mut(at).ok_or("that file has no such link")?;
|
||||
let before = link.code.replace(sealed);
|
||||
let id = link.id.clone();
|
||||
if let Err(e) = write_json(&path, &entry.links) {
|
||||
if let Some(l) = entry.links.get_mut(at) {
|
||||
l.code = before;
|
||||
}
|
||||
return Err(format!("couldn't keep the join code: {e}"));
|
||||
}
|
||||
self.forget_codes(before);
|
||||
log::info!("event=code file={file} link={id}");
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// The file a join code typed at `ip` opens, and its link's token; wrong codes count against
|
||||
/// the address (see `abuse`), and nothing is looked up for an address that is locked out.
|
||||
pub fn resolve_code(&mut self, ip: IpAddr, typed: &str) -> Result<(String, String), String> {
|
||||
let now = Instant::now();
|
||||
if let Some(left) = self.abuse.code_locked(ip, now) {
|
||||
return Err(format!(
|
||||
"Too many wrong codes from your address: try again in {} minutes, or join with the invite link",
|
||||
left.as_secs() / 60 + 1
|
||||
));
|
||||
}
|
||||
if !self.abuse.allow(ip, Action::Code, now) {
|
||||
return Err("Too many codes tried from your address: wait a minute and try again".into());
|
||||
}
|
||||
if !self.abuse.codes_open(now) {
|
||||
return Err(CODES_BUSY.into());
|
||||
}
|
||||
// Anything that isn't a code's symbols is a wrong code too (and costs no hashing).
|
||||
let key = figcraft_collab::code::normalize(typed).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN).map(|c| codes::Key::new(&c));
|
||||
let found = key.as_ref().and_then(|k| self.find_code(k));
|
||||
self.abuse.code_tried(ip, found.is_some(), now);
|
||||
match found {
|
||||
Some((file, _, token)) => {
|
||||
log::info!("event=code_used file={file} ip={ip}");
|
||||
Ok((file, token))
|
||||
}
|
||||
None => {
|
||||
let gone = key.is_some_and(|k| self.gone_codes.iter().any(|g| g == k.hash()));
|
||||
Err(if gone { CODE_GONE } else { NO_CODE }.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Revoke a link.
|
||||
pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> {
|
||||
let path = self.dir(file).join(LINKS);
|
||||
@@ -766,6 +906,7 @@ impl Store {
|
||||
return Err(format!("couldn't revoke the link: {e}"));
|
||||
}
|
||||
log::info!("revoked link {link} of file {file}");
|
||||
self.forget_codes(gone.code);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -774,6 +915,7 @@ impl Store {
|
||||
pub fn remove(&mut self, file: &str) -> Result<(), String> {
|
||||
// Closed first: an open log would keep the folder from being deleted on Windows.
|
||||
let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?;
|
||||
self.forget_codes(entry.links.iter().filter_map(|l| l.code.clone()));
|
||||
drop(entry);
|
||||
let dir = self.dir(file);
|
||||
fs::remove_dir_all(&dir)
|
||||
@@ -785,11 +927,12 @@ impl Store {
|
||||
/// A file's team library for `ip`, unless its version is `have`; within what one address and
|
||||
/// everyone may download (the size is known before anything is read).
|
||||
fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option<u64>) -> Result<LibraryReply, String> {
|
||||
let (path, max) = (self.dir(file).join(LIBRARY), self.cfg.max_file_bytes);
|
||||
let (dir, max) = (self.dir(file), self.cfg.max_file_bytes);
|
||||
let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) };
|
||||
if have == Some(lib.version) {
|
||||
return Ok(LibraryReply::Unchanged { version: lib.version });
|
||||
}
|
||||
let path = library_file(&dir, lib.version);
|
||||
let size = fs::metadata(&path).map_or(0, |m| m.len());
|
||||
let now = Instant::now();
|
||||
if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) {
|
||||
@@ -831,14 +974,24 @@ impl Store {
|
||||
return Err(format!("version {} is on the server already: publish again to follow it", old.version));
|
||||
}
|
||||
}
|
||||
// The archive first: a description never names a version whose library isn't there (a
|
||||
// failed description write leaves the older number, which the next publish follows).
|
||||
disk::write_atomic(&dir.join(LIBRARY), archive).map_err(|e| format!("couldn't keep the library: {e}"))?;
|
||||
// Each version has a file of its own, written before the description names it: a failed
|
||||
// description write or a crash in between leaves the previous version served whole, never
|
||||
// the new bytes under the old number.
|
||||
let path = dir.join(library_name(m.version));
|
||||
disk::write_atomic(&path, archive).map_err(|e| format!("couldn't keep the library: {e}"))?;
|
||||
let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version });
|
||||
if let Err(e) = write_json(&dir.join(META), &entry.meta) {
|
||||
// Never served (the description doesn't name it); the next publish of it replaces it.
|
||||
if let Err(r) = fs::remove_file(&path) {
|
||||
log::warn!("file {file}: an unpublished library version couldn't be deleted: {r}");
|
||||
}
|
||||
entry.meta.library = before;
|
||||
return Err(format!("couldn't keep the library: {e}"));
|
||||
}
|
||||
// The previous version isn't served any more.
|
||||
if let Some(Err(e)) = before.map(|old| fs::remove_file(library_file(&dir, old.version))) {
|
||||
log::warn!("file {file}: the previous library version couldn't be deleted: {e}");
|
||||
}
|
||||
log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len());
|
||||
Ok(m.version)
|
||||
}
|
||||
@@ -952,7 +1105,7 @@ impl Store {
|
||||
fn load(&mut self, file: &str) -> Result<Server, String> {
|
||||
self.touch(file);
|
||||
let dir = self.dir(file);
|
||||
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
|
||||
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?;
|
||||
let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?;
|
||||
let mut issues = Vec::new();
|
||||
let mut fresh_log = true;
|
||||
@@ -990,23 +1143,37 @@ impl Store {
|
||||
Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 }
|
||||
}
|
||||
// A new snapshot and log of the next generation, so appends start clean.
|
||||
_ => self.rewrite(file, generation + 1, &server)?,
|
||||
_ => self.rewrite(file, generation + 1, &server).map_err(|e| e.why)?,
|
||||
};
|
||||
self.entry(file)?.log = Some(log);
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
/// The largest snapshot read back: a file's cap plus room for what its state keeps beside the
|
||||
/// document (recent batches for reconnecting clients, deletions), which may take it over.
|
||||
fn snapshot_cap(&self) -> u64 {
|
||||
self.cfg.max_file_bytes.saturating_mul(2)
|
||||
}
|
||||
|
||||
/// Write `server`'s state as snapshot `generation` and start an empty log of it.
|
||||
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, String> {
|
||||
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, RewriteError> {
|
||||
let dir = self.dir(file);
|
||||
let state = server.state().map_err(|e| e.to_string())?;
|
||||
let bytes = disk::encode_snapshot(generation, &state)?;
|
||||
let kept = |why: String| RewriteError { why, snapshot_replaced: false };
|
||||
let state = server.state().map_err(|e| kept(e.to_string()))?;
|
||||
let bytes = disk::encode_snapshot(generation, &state).map_err(kept)?;
|
||||
// Never replace a readable snapshot with one that can't be opened again: the old snapshot
|
||||
// and log still hold everything.
|
||||
if bytes.len() as u64 > self.snapshot_cap() {
|
||||
return Err(kept(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20)));
|
||||
}
|
||||
if bytes.len() as u64 > self.cfg.max_file_bytes {
|
||||
log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20);
|
||||
}
|
||||
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| format!("couldn't write the snapshot: {e}"))?;
|
||||
disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| format!("couldn't start the log: {e}"))?;
|
||||
let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| format!("couldn't open the log: {e}"))?;
|
||||
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| kept(format!("couldn't write the snapshot: {e}")))?;
|
||||
// From here the old log no longer goes with the snapshot on disk.
|
||||
let replaced = |why: String| RewriteError { why, snapshot_replaced: true };
|
||||
disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| replaced(format!("couldn't start the log: {e}")))?;
|
||||
let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| replaced(format!("couldn't open the log: {e}")))?;
|
||||
if let Ok(e) = self.entry(file) {
|
||||
e.bytes = (bytes.len() + LOG_HEADER) as u64;
|
||||
e.meta.seq = server.seq();
|
||||
@@ -1059,8 +1226,19 @@ impl Store {
|
||||
}
|
||||
log::debug!("compacted file {file}");
|
||||
}
|
||||
// The new snapshot holds everything, but a restart would drop whatever is appended to
|
||||
// the old log now (its generation no longer matches): the file closes, the hub hears
|
||||
// it at the next flush, and it opens again from the new snapshot.
|
||||
Err(RewriteError { why, snapshot_replaced: true }) => {
|
||||
self.note(format!("file {file}: compaction was cut short ({why}); it was closed"));
|
||||
log::error!("file {file}: compaction was cut short: {why}");
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.log = None;
|
||||
}
|
||||
self.cut_short.push(file.to_string());
|
||||
}
|
||||
// The old snapshot and log still hold everything: carry on appending.
|
||||
Err(e) => log::error!("file {file}: compaction failed: {e}"),
|
||||
Err(RewriteError { why, .. }) => log::error!("file {file}: compaction failed: {why}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1068,7 +1246,7 @@ impl Store {
|
||||
/// Those are closed here (their pending records dropped: nobody was told of them) and open
|
||||
/// again from what is on disk.
|
||||
fn flush_all(&mut self) -> Vec<String> {
|
||||
let mut failed = Vec::new();
|
||||
let mut failed = std::mem::take(&mut self.cut_short);
|
||||
for (id, e) in &mut self.files {
|
||||
if let Some(log) = e.log.as_mut() {
|
||||
match flush_log(id, log) {
|
||||
@@ -1084,6 +1262,13 @@ impl Store {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a snapshot and log couldn't be rewritten, and whether the snapshot on disk was replaced
|
||||
/// before it failed (then the old log no longer goes with it).
|
||||
struct RewriteError {
|
||||
why: String,
|
||||
snapshot_replaced: bool,
|
||||
}
|
||||
|
||||
/// Write and sync a log's pending records: the bytes written, or `Err` when that failed (the log
|
||||
/// can't be trusted to hold them).
|
||||
fn flush_log(id: &str, log: &mut Log) -> Result<u64, ()> {
|
||||
@@ -1104,6 +1289,18 @@ fn flush_log(id: &str, log: &mut Log) -> Result<u64, ()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of the file holding version `version` of a file's team library.
|
||||
pub fn library_name(version: u64) -> String {
|
||||
format!("library-{version}.bin")
|
||||
}
|
||||
|
||||
/// Where version `version` of the team library in `dir` is: its own file, or `library.bin` for a
|
||||
/// version published before each version had one.
|
||||
fn library_file(dir: &Path, version: u64) -> PathBuf {
|
||||
let path = dir.join(library_name(version));
|
||||
if path.exists() { path } else { dir.join(LIBRARY) }
|
||||
}
|
||||
|
||||
fn write_json<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<(), String> {
|
||||
let text = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
|
||||
disk::write_atomic(path, &text).map_err(|e| e.to_string())
|
||||
@@ -1112,21 +1309,15 @@ fn write_json<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<(), Strin
|
||||
/// A new link and its token.
|
||||
fn new_link(access: Access, label: &str, created: u64) -> Result<(Link, String), String> {
|
||||
let token = token::secret()?;
|
||||
Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token) }, token))
|
||||
Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token), code: None }, token))
|
||||
}
|
||||
|
||||
impl Backend for Store {
|
||||
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String> {
|
||||
let entry = self.entry(file)?;
|
||||
let presented = hash(token);
|
||||
// Every link is compared (in constant time), so timing doesn't tell which one matched.
|
||||
let mut found = None;
|
||||
for l in &entry.links {
|
||||
if same_secret(&presented, &l.hash) {
|
||||
found = Some(Admission { access: l.access, link: Some(l.id.clone()) });
|
||||
}
|
||||
}
|
||||
found.ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string())
|
||||
let link = Self::link_of(entry, token).and_then(|i| entry.links.get(i));
|
||||
link.map(|l| Admission { access: l.access, link: Some(l.id.clone()) })
|
||||
.ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string())
|
||||
}
|
||||
|
||||
fn open(&mut self, file: &str) -> Result<Server, String> {
|
||||
@@ -1177,6 +1368,8 @@ impl Backend for Store {
|
||||
if dirty {
|
||||
self.compact(file, server);
|
||||
}
|
||||
// It closes anyway, everything in its snapshot: a compaction cut short needs no report.
|
||||
self.cut_short.retain(|f| f != file);
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.log = None;
|
||||
}
|
||||
@@ -1234,9 +1427,7 @@ impl Backend for Store {
|
||||
if file.is_empty() && owner.is_empty() {
|
||||
return Ok(self.host_info(None));
|
||||
}
|
||||
let presented = hash(owner);
|
||||
let owns = self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h));
|
||||
if !owns {
|
||||
if !self.owns(file, owner) {
|
||||
return Err("that owner token doesn't manage a file of that id here (it may have been removed or expired)".into());
|
||||
}
|
||||
Ok(self.host_info(Some(file)))
|
||||
@@ -1257,10 +1448,12 @@ impl Backend for Store {
|
||||
}
|
||||
};
|
||||
}
|
||||
Admin::Owned { files } => return self.owned(files).into(),
|
||||
Admin::Links { file }
|
||||
| Admin::CreateLink { file, .. }
|
||||
| Admin::Revoke { file, .. }
|
||||
| Admin::Rename { file, .. }
|
||||
| Admin::Code { file, .. }
|
||||
| Admin::Remove { file } => file,
|
||||
_ => return failed("that needs the server key"),
|
||||
};
|
||||
@@ -1297,6 +1490,10 @@ impl Backend for Store {
|
||||
},
|
||||
Admin::Unblock { ip } => self.block(&ip, false).map_or_else(failed, |_| AdminReply::Done.into()),
|
||||
Admin::Blocked => AdminReply::Blocked(self.blocked()).into(),
|
||||
Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()),
|
||||
Admin::Owned { files } => self.owned(&files).into(),
|
||||
// The hub removes people itself (it knows who is connected); it never asks the store.
|
||||
Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()),
|
||||
Admin::Remove { file } => {
|
||||
if !self.files.contains_key(&file) {
|
||||
return failed("this file isn't on the server (it may have been removed)".into());
|
||||
@@ -1342,6 +1539,10 @@ impl Backend for Store {
|
||||
self.directory.handle(publisher, req)
|
||||
}
|
||||
|
||||
fn resolve(&mut self, ip: IpAddr, code: &str) -> Result<(String, String), String> {
|
||||
self.resolve_code(ip, code)
|
||||
}
|
||||
|
||||
fn closing(&self) -> Closing {
|
||||
Closing::Restarting
|
||||
}
|
||||
|
||||
@@ -269,3 +269,47 @@ fn random_bytes_never_panic_the_readers() {
|
||||
let _ = disk::read_log(&bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// The first page's name in `server`'s document.
|
||||
fn page_name(server: &Server) -> String {
|
||||
let d = server.replica().document();
|
||||
d.node(d.first_page().unwrap()).unwrap().name.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() {
|
||||
let dir = TempDir::new("cutshort");
|
||||
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
|
||||
let folder = dir.path().join(FILES);
|
||||
let mut store = Store::open(dir.path(), c.clone()).unwrap();
|
||||
let (id, _, _) = publish(&mut store, "Busy");
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let mut a = join(&mut server, Access::Edit);
|
||||
store.changed(&id, &mut server);
|
||||
// The next compaction writes its snapshot, then can't start its log.
|
||||
let blocker = disk::temp_of(&folder.join(&id).join(LOG));
|
||||
fs::create_dir(&blocker).unwrap();
|
||||
// What the hub would confirm: every change whose flush didn't report the file.
|
||||
let mut confirmed = String::new();
|
||||
let mut failed = false;
|
||||
let names = ["P0", "P1", "P2", "After 1", "After 2"];
|
||||
for name in names {
|
||||
rename(&mut server, &mut a, name);
|
||||
store.changed(&id, &mut server);
|
||||
if store.flush().contains(&id) {
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
confirmed = name.to_string();
|
||||
}
|
||||
assert!(failed, "the hub hears the file can't be kept; confirmed {confirmed:?}");
|
||||
// A crash, then the disk recovers.
|
||||
drop(store);
|
||||
fs::remove_dir(&blocker).unwrap();
|
||||
let mut store = Store::open(dir.path(), c).unwrap();
|
||||
let restored = store.open(&id).unwrap();
|
||||
// Every confirmed change is there (the snapshot may also hold the one that was cut short).
|
||||
let at = |n: &str| names.iter().position(|x| *x == n);
|
||||
let name = page_name(&restored);
|
||||
assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}");
|
||||
}
|
||||
@@ -91,7 +91,7 @@ pub fn key(dir: &Path) -> String {
|
||||
}
|
||||
|
||||
pub fn live(s: &Session) -> bool {
|
||||
s.live.status == Status::Live && s.live.syncing()
|
||||
s.live().status == Status::Live && s.live().syncing()
|
||||
}
|
||||
|
||||
/// `a` publishes its open file to the server at `addr`; the links.
|
||||
@@ -99,8 +99,8 @@ pub fn publish(a: &mut Session, dir: &Path, addr: SocketAddr) -> Published {
|
||||
let r = a.execute("collab.publish", &json!({"server": format!("ws://{addr}/?key={}", key(dir)), "name": "Team file"})).unwrap();
|
||||
assert_eq!(r["status"], "connecting");
|
||||
assert_eq!(r["readOnly"], true, "read-only while it is on its way");
|
||||
pump(&mut [a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
|
||||
a.live.published.clone().unwrap()
|
||||
pump(&mut [a], "the publish", |s| live(s[0]) && s[0].live().published.is_some());
|
||||
a.live().published.clone().unwrap()
|
||||
}
|
||||
|
||||
pub fn join(link: &str, name: &str) -> Session {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
use figcraft_server::config::Config;
|
||||
use figcraft_server::store::{FILES, Store};
|
||||
|
||||
#[test]
|
||||
fn startup_problem_retention() {
|
||||
let root = std::env::temp_dir().join(format!("figcraft-problem-cap-{}", std::process::id()));
|
||||
assert!(!root.exists());
|
||||
let result = (|| -> Result<(), String> {
|
||||
for count in [3u32, 200, 201] {
|
||||
let data = root.join(count.to_string());
|
||||
for i in 0..count {
|
||||
std::fs::create_dir_all(data.join(FILES).join(format!("{i:016x}"))).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let cfg = Config { open_publish: true, data: data.clone(), ..Config::default() };
|
||||
cfg.check()?;
|
||||
let store = Store::open(&data, cfg.store())?;
|
||||
let expected = (count as usize).min(200);
|
||||
let problems = store.problems().to_vec();
|
||||
let no_loaded_files = store.is_empty();
|
||||
drop(store);
|
||||
if !problems.iter().all(|p| p.contains("meta.json")) || !no_loaded_files {
|
||||
return Err("unexpected diagnostic or loaded file".into());
|
||||
}
|
||||
if problems.len() != expected {
|
||||
return Err(format!("expected {expected} retained problems, got {}", problems.len()));
|
||||
}
|
||||
// The latest problems stay, in scan order: the first folder's is the one dropped.
|
||||
let first_kept = count as usize - expected;
|
||||
for (n, p) in problems.iter().enumerate() {
|
||||
let id = format!("{:016x}", first_kept + n);
|
||||
if !p.contains(&id) {
|
||||
return Err(format!("problem {n} should be about {id}: {p}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(result.is_ok(), "{}", result.unwrap_err());
|
||||
}
|
||||
@@ -24,5 +24,6 @@ log = { workspace = true }
|
||||
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
|
||||
@@ -40,7 +40,7 @@ pub fn start() {
|
||||
// on the socket wakes the page for a frame.
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
|
||||
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion }));
|
||||
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
|
||||
Ok(Box::new(WebShell { fig }))
|
||||
}),
|
||||
)
|
||||
@@ -69,14 +69,35 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser services. File dialogs return nothing for now (the trait's defaults); the clipboard
|
||||
/// goes through egui's own web clipboard handling.
|
||||
/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved
|
||||
/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste
|
||||
/// events with their text (a page can't read the clipboard when it likes).
|
||||
struct WebServices {
|
||||
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
|
||||
reduce_motion: Option<web_sys::MediaQueryList>,
|
||||
ctx: egui::Context,
|
||||
}
|
||||
|
||||
impl Services for WebServices {
|
||||
fn downloads(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// A Blob behind a temporary `<a download>` link, clicked and removed.
|
||||
fn download(&mut self, name: &str, bytes: &[u8]) -> bool {
|
||||
match download(name, bytes) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::error!("couldn't download {name}: {e:?}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
fn clipboard_set_text(&mut self, s: &str) {
|
||||
figcraft_ui_egui::egui_copy_text(&self.ctx, s);
|
||||
}
|
||||
fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) {
|
||||
figcraft_ui_egui::egui_copy_png(&self.ctx, w, h, rgba);
|
||||
}
|
||||
fn open_url(&mut self, url: &str) {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
if let Err(e) = window.open_with_url_and_target(url, "_blank") {
|
||||
@@ -108,3 +129,32 @@ impl Services for WebServices {
|
||||
held
|
||||
}
|
||||
}
|
||||
|
||||
fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let body = document.body().ok_or("no body")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &{
|
||||
let o = web_sys::BlobPropertyBag::new();
|
||||
o.set_type("application/octet-stream");
|
||||
o
|
||||
})?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob)?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a")?.dyn_into()?;
|
||||
a.set_href(&url);
|
||||
a.set_download(name);
|
||||
body.append_child(&a)?;
|
||||
a.click();
|
||||
a.remove();
|
||||
// Revoked later, not at once: some browsers cancel a download whose URL goes before it starts.
|
||||
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
// Without the timer the URL just stays until the page closes.
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), REVOKE_DOWNLOAD_MS);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long a download's object URL is kept (milliseconds).
|
||||
const REVOKE_DOWNLOAD_MS: i32 = 40_000;
|
||||
@@ -31,6 +31,13 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
|
||||
|
||||
# Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a
|
||||
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by GridCraft,
|
||||
# EffectCraft and PhotoCraft) registers the handler behind a safe API (Objective-C interop needs
|
||||
# unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
fmv-macos-events = "=0.1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
wat = { workspace = true }
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Windows: embed the icon and version info in figcraft.exe (no-op elsewhere).
|
||||
//! Windows: embed the icon and version info in figcraft.exe and size its main thread's stack
|
||||
//! (no-op elsewhere).
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico");
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
@@ -14,6 +15,10 @@ fn main() {
|
||||
if let Err(e) = res.compile() {
|
||||
println!("cargo::warning=winresource: {e}");
|
||||
}
|
||||
// Windows starts the main thread with 1 MB of stack, Linux and macOS with 8 MB. Layers
|
||||
// nest up to 256 deep in an export, and that recursion needs the same room everywhere.
|
||||
let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
|
||||
println!("cargo::rustc-link-arg-bins={}", if msvc { "/STACK:8388608" } else { "-Wl,--stack,8388608" });
|
||||
}
|
||||
let sha = std::env::var("FIGCRAFT_BUILD_SHA").unwrap_or_default();
|
||||
println!("cargo::rustc-env=FIGCRAFT_BUILD_SHA={sha}");
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! macOS open-documents and quit Apple events (#289).
|
||||
//!
|
||||
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a FigCraft file.figcraft`
|
||||
//! don't pass paths on the command line: LaunchServices sends the running (or just-launched) app a
|
||||
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
|
||||
//! `NSApplicationDelegate`, so AppKit answered "FigCraft cannot open files in the FigCraft Document
|
||||
//! format". Handling it ourselves needs Objective-C class declarations, i.e. `unsafe`, which this
|
||||
//! workspace forbids. The `fmv-macos-events` crate (also used by GridCraft, EffectCraft and
|
||||
//! PhotoCraft) wraps exactly that, an `NSAppleEventManager` handler registered before Finder's
|
||||
//! launch event that leaves winit's delegate alone, behind a safe main-thread API.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use figcraft_ui_egui::FigApp;
|
||||
use fmv_macos_events::{Event, Inbox, Registration};
|
||||
use serde_json::json;
|
||||
|
||||
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
|
||||
pub struct AppleEvents {
|
||||
_registration: Registration,
|
||||
inbox: Inbox,
|
||||
}
|
||||
|
||||
impl AppleEvents {
|
||||
/// Register the handlers. Call on the main thread before the event loop starts, so the event
|
||||
/// that launched the app (a Finder double-click) is caught too.
|
||||
pub fn install() -> Self {
|
||||
let (registration, inbox) = Registration::install();
|
||||
Self { _registration: registration, inbox }
|
||||
}
|
||||
|
||||
/// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`.
|
||||
pub fn connect(&self, ctx: &egui::Context) -> Inbox {
|
||||
let ctx = ctx.clone();
|
||||
self.inbox.set_wake(move || ctx.request_repaint());
|
||||
self.inbox.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the documents and act on the quit requests that arrived since the last frame.
|
||||
pub fn poll(inbox: &Inbox, fig: &mut FigApp, ctx: &egui::Context) {
|
||||
for e in inbox.drain() {
|
||||
match e {
|
||||
Event::Open(paths) => open_documents(fig, paths),
|
||||
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open `paths` as File › Open does; a file that fails shows its error and the others still open.
|
||||
pub fn open_documents(fig: &mut FigApp, paths: Vec<PathBuf>) {
|
||||
for p in paths {
|
||||
if fig.run_user("file.open", json!({"path": p.to_string_lossy()})).is_some() {
|
||||
fig.ui.home = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Documents macOS hands over in an open-documents Apple event (a Finder double-click, Open With,
|
||||
//! a drop on the Dock icon) open as File › Open opens them (#289).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_ui_egui::{FigApp, NoServices};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::apple_events::open_documents;
|
||||
|
||||
#[test]
|
||||
fn documents_from_finder_open_and_leave_the_home_screen() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-apple-events-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("BLA-001.figcraft");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.saveAs", &json!({"path": file.to_string_lossy()})).unwrap();
|
||||
|
||||
let mut app = FigApp::new(Session::empty(), Box::new(NoServices));
|
||||
app.ui.home = true;
|
||||
// A file that can't be read is reported and doesn't keep the others from opening.
|
||||
open_documents(&mut app, vec![dir.join("Missing.figcraft"), file.clone()]);
|
||||
let paths: Vec<_> = app.session.docs.iter().map(|d| d.path.clone().map(PathBuf::from)).collect();
|
||||
assert_eq!(paths, [Some(file)]);
|
||||
assert!(!app.ui.home, "the opened document shows");
|
||||
assert!(app.ui.last_error.as_deref().is_some_and(|e| e.contains("Missing.figcraft")), "{:?}", app.ui.last_error);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Localhost JSON-lines control server: each line `{"id", "method", "params"}` is queued for the UI
|
||||
//! thread; the reply line `{"id", "ok", "result" | "error"}` is written when the frame handled it.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
@@ -12,6 +12,20 @@ use serde_json::{Value, json};
|
||||
type Queue = Arc<Mutex<Vec<ControlRequest>>>;
|
||||
type CtxSlot = Arc<Mutex<Option<egui::Context>>>;
|
||||
|
||||
const MAX_LINE: usize = 32 * 1024 * 1024;
|
||||
const MAX_CONNECTIONS: usize = 8;
|
||||
const MAX_QUEUED_REQUESTS: usize = 8;
|
||||
const TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Deferred requests for a hidden window must not make the UI's queue grow without a bound.
|
||||
pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
|
||||
target.retain(|request| !request.is_cancelled());
|
||||
let mut queue = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
queue.retain(|request| !request.is_cancelled());
|
||||
let count = queue.len().min(MAX_QUEUED_REQUESTS.saturating_sub(target.len()));
|
||||
target.extend(queue.drain(..count));
|
||||
}
|
||||
|
||||
pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
@@ -23,9 +37,9 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
|
||||
};
|
||||
eprintln!("FigCraft control server listening on 127.0.0.1:{port}");
|
||||
let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || {
|
||||
let mut workers = Vec::new();
|
||||
for stream in listener.incoming().flatten() {
|
||||
let (q, c) = (queue.clone(), ctx.clone());
|
||||
let _ = std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || serve(stream, q, c));
|
||||
spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT);
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
@@ -33,37 +47,83 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, queue: Queue, ctx: CtxSlot) {
|
||||
let Ok(read_half) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let reader = BufReader::new(read_half);
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec<std::thread::JoinHandle<()>>, timeout: Duration) {
|
||||
workers.retain(|worker| !worker.is_finished());
|
||||
if workers.len() >= MAX_CONNECTIONS {
|
||||
return;
|
||||
}
|
||||
match std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || {
|
||||
let _ = serve(stream, queue, ctx, timeout);
|
||||
}) {
|
||||
Ok(worker) => workers.push(worker),
|
||||
Err(e) => log::error!("control connection thread: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
|
||||
out.set_read_timeout(Some(timeout))?;
|
||||
out.set_write_timeout(Some(timeout))?;
|
||||
let mut reader = BufReader::new(out.try_clone()?);
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
line.clear();
|
||||
if (&mut reader).take((MAX_LINE + 1) as u64).read_until(b'\n', &mut line)? == 0 {
|
||||
break;
|
||||
}
|
||||
if line.last() == Some(&b'\n') {
|
||||
line.pop();
|
||||
}
|
||||
if line.len() > MAX_LINE {
|
||||
writeln!(out, "{}", json!({"id": null, "ok": false, "error": "message too large"}))?;
|
||||
break;
|
||||
}
|
||||
let Ok(line) = std::str::from_utf8(&line) else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
let reply = match serde_json::from_str::<Value>(line) {
|
||||
Ok(v) => {
|
||||
let id = v.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = v.get("params").cloned().unwrap_or(json!({}));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(ControlRequest { id: id.clone(), method, params, reply: tx });
|
||||
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
pending.retain(|request| !request.is_cancelled());
|
||||
if pending.len() >= MAX_QUEUED_REQUESTS {
|
||||
drop(pending);
|
||||
writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;
|
||||
continue;
|
||||
}
|
||||
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() });
|
||||
drop(pending);
|
||||
if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_secs(60)) {
|
||||
match rx.recv_timeout(timeout) {
|
||||
Ok(r) => match r.result {
|
||||
Ok(res) => json!({"id": r.id, "ok": true, "result": res}),
|
||||
Err(e) => json!({"id": r.id, "ok": false, "error": e}),
|
||||
},
|
||||
Err(_) => json!({"id": id, "ok": false, "error": "timed out waiting for the UI (is the window visible?)"}),
|
||||
Err(_) => {
|
||||
cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"})
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}),
|
||||
Err(e) => {
|
||||
// A line that isn't JSON ends the connection: a web page can make the browser send
|
||||
// an HTTP request here, and its header lines must not let the JSON lines of its body
|
||||
// through as commands.
|
||||
writeln!(out, "{}", json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}))?;
|
||||
break;
|
||||
}
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
writeln!(out, "{reply}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "control_server_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,278 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use std::io::Read;
|
||||
use std::net::Shutdown;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Instant;
|
||||
|
||||
fn pair() -> (TcpStream, TcpStream) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
|
||||
client.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
client.set_write_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
(client, listener.accept().unwrap().0)
|
||||
}
|
||||
|
||||
fn queue() -> Queue {
|
||||
Arc::new(Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
fn context() -> CtxSlot {
|
||||
Arc::new(Mutex::new(None))
|
||||
}
|
||||
|
||||
fn reply(reader: &mut BufReader<TcpStream>) -> Value {
|
||||
let mut line = String::new();
|
||||
assert!(reader.read_line(&mut line).unwrap() > 0);
|
||||
serde_json::from_str(&line).unwrap()
|
||||
}
|
||||
|
||||
fn request() -> ControlRequest {
|
||||
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, cancelled: Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_an_oversized_unterminated_frame_without_dispatch() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
|
||||
// Generate bytes as they are written; no giant input string and no terminating newline.
|
||||
std::io::copy(&mut std::io::repeat(b' ').take((MAX_LINE + 1) as u64), &mut client).unwrap();
|
||||
let mut reader = BufReader::new(client);
|
||||
assert_eq!(reply(&mut reader), json!({"id": null, "ok": false, "error": "message too large"}));
|
||||
assert_eq!(reader.read(&mut [0]).unwrap(), 0);
|
||||
worker.join().unwrap().unwrap();
|
||||
assert!(pending.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_frames_keep_their_order_ids_and_parameters() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
|
||||
client.write_all(b"\r\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap();
|
||||
let mut reader = BufReader::new(client);
|
||||
let start = Instant::now();
|
||||
let r = loop {
|
||||
if let Some(r) = pending.lock().unwrap().pop() {
|
||||
break r;
|
||||
}
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
};
|
||||
assert_eq!(r.id, "test");
|
||||
assert_eq!(r.method, "engine.execute");
|
||||
assert_eq!(r.params, json!({"command": "shape.rectangle"}));
|
||||
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!({"id": 42})) }).unwrap();
|
||||
assert_eq!(reply(&mut reader), json!({"id": "test", "ok": true, "result": {"id": 42}}));
|
||||
reader.get_ref().shutdown(Shutdown::Write).unwrap();
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_that_is_not_json_ends_the_connection_before_anything_after_it_runs() {
|
||||
// What a browser sends when a web page posts to the port: the JSON line in its body never runs.
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
|
||||
let body = "{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.save\"}}\n";
|
||||
let http = format!("POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{body}", body.len());
|
||||
client.write_all(http.as_bytes()).unwrap();
|
||||
let mut reader = BufReader::new(client);
|
||||
assert_eq!(reply(&mut reader)["ok"], false);
|
||||
assert_eq!(reader.read(&mut [0]).unwrap(), 0, "the connection is closed");
|
||||
worker.join().unwrap().unwrap();
|
||||
assert!(pending.lock().unwrap().is_empty(), "nothing was dispatched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| request()));
|
||||
let mut ui_queue = (0..MAX_QUEUED_REQUESTS - 1).map(|_| request()).collect();
|
||||
drain_queue(&pending, &mut ui_queue);
|
||||
assert_eq!(ui_queue.len(), MAX_QUEUED_REQUESTS);
|
||||
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS - 1);
|
||||
pending.lock().unwrap().push(request());
|
||||
drain_queue(&pending, &mut ui_queue);
|
||||
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS);
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
|
||||
client.write_all(b"{\"id\":1,\"method\":\"engine.execute\"}\n").unwrap();
|
||||
let mut reader = BufReader::new(client);
|
||||
assert_eq!(reply(&mut reader), json!({"id": 1, "ok": false, "error": "control request queue is full"}));
|
||||
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS);
|
||||
ui_queue.clear();
|
||||
drain_queue(&pending, &mut ui_queue);
|
||||
assert_eq!(ui_queue.len(), MAX_QUEUED_REQUESTS);
|
||||
assert!(pending.lock().unwrap().is_empty());
|
||||
reader.get_ref().shutdown(Shutdown::Write).unwrap();
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_and_partial_frame_connections_expire() {
|
||||
for bytes in [b"".as_slice(), b"{\"id\":"] {
|
||||
let (mut client, server) = pair();
|
||||
let (done, finished) = mpsc::channel();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || done.send(serve(server, q, context(), Duration::from_millis(50))));
|
||||
client.write_all(bytes).unwrap();
|
||||
let err = finished.recv_timeout(Duration::from_secs(5)).unwrap().unwrap_err();
|
||||
assert!(matches!(err.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut));
|
||||
assert!(pending.lock().unwrap().is_empty());
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
|
||||
let pending = queue();
|
||||
let ctx = context();
|
||||
let mut workers = Vec::new();
|
||||
let mut clients = Vec::new();
|
||||
for _ in 0..MAX_CONNECTIONS {
|
||||
let (client, server) = pair();
|
||||
spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
|
||||
clients.push(client);
|
||||
}
|
||||
assert_eq!(workers.len(), MAX_CONNECTIONS);
|
||||
let (mut extra, server) = pair();
|
||||
spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
|
||||
assert_eq!(extra.read(&mut [0]).unwrap(), 0);
|
||||
assert_eq!(workers.len(), MAX_CONNECTIONS);
|
||||
for client in clients {
|
||||
client.shutdown(Shutdown::Both).unwrap();
|
||||
}
|
||||
let start = Instant::now();
|
||||
while workers.iter().any(|worker| !worker.is_finished()) {
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
let (mut client, server) = pair();
|
||||
spawn_connection(server, pending, ctx, &mut workers, Duration::from_secs(5));
|
||||
assert_eq!(workers.len(), 1);
|
||||
client.write_all(b"invalid\n").unwrap();
|
||||
assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false);
|
||||
client.shutdown(Shutdown::Both).unwrap();
|
||||
workers.pop().unwrap().join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_client_that_stops_reading_cannot_hold_a_worker() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let (done, finished) = mpsc::channel();
|
||||
let worker = std::thread::spawn(move || done.send(serve(server, q, context(), Duration::from_millis(100))));
|
||||
client.write_all(b"{\"id\":1,\"method\":\"document.inspect\"}\n").unwrap();
|
||||
let start = Instant::now();
|
||||
let r = loop {
|
||||
if let Some(r) = pending.lock().unwrap().pop() {
|
||||
break r;
|
||||
}
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
};
|
||||
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!("x".repeat(8 * 1024 * 1024))) }).unwrap();
|
||||
let err = finished.recv_timeout(Duration::from_secs(5)).unwrap().unwrap_err();
|
||||
assert!(matches!(err.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut));
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_limit_sized_frame_is_accepted_at_eof() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
|
||||
let message = b"{\"id\":1,\"method\":\"ui.inspect\"}";
|
||||
client.write_all(message).unwrap();
|
||||
std::io::copy(&mut std::io::repeat(b' ').take((MAX_LINE - message.len()) as u64), &mut client).unwrap();
|
||||
client.shutdown(Shutdown::Write).unwrap();
|
||||
let start = Instant::now();
|
||||
let r = loop {
|
||||
if let Some(r) = pending.lock().unwrap().pop() {
|
||||
break r;
|
||||
}
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
};
|
||||
assert_eq!(r.id, 1);
|
||||
assert_eq!(r.method, "ui.inspect");
|
||||
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!({})) }).unwrap();
|
||||
assert_eq!(reply(&mut BufReader::new(client)), json!({"id": 1, "ok": true, "result": {}}));
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timed_out_requests_release_a_full_deferred_ui_queue() {
|
||||
use figcraft_ui_egui::{FigApp, NoServices, control, windows};
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_embed_viewports(false);
|
||||
let mut app = FigApp::new(figcraft_engine::Session::new(), Box::new(NoServices));
|
||||
let window = windows::open(&mut app, &ctx, Some(0), None, None).unwrap();
|
||||
let pending = queue();
|
||||
let mut clients = Vec::new();
|
||||
let mut workers = Vec::new();
|
||||
for id in 0..MAX_QUEUED_REQUESTS {
|
||||
let (mut client, server) = pair();
|
||||
let q = pending.clone();
|
||||
workers.push(std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(1))));
|
||||
writeln!(client, "{}", json!({"id": id, "method": "engine.execute", "params": {"window": window, "command": "file.new"}})).unwrap();
|
||||
clients.push(BufReader::new(client));
|
||||
}
|
||||
let start = Instant::now();
|
||||
while pending.lock().unwrap().len() < MAX_QUEUED_REQUESTS {
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
drain_queue(&pending, &mut app.control_queue);
|
||||
control::drain(&mut app, &ctx);
|
||||
assert_eq!(app.control_queue.len(), MAX_QUEUED_REQUESTS, "the other window has not drawn");
|
||||
for client in &mut clients {
|
||||
assert!(reply(client)["error"].as_str().unwrap().contains("timed out"));
|
||||
client.get_ref().shutdown(Shutdown::Write).unwrap();
|
||||
}
|
||||
assert!(app.control_queue.iter().all(ControlRequest::is_cancelled));
|
||||
let expired = app.control_queue.pop().unwrap();
|
||||
// Include expired requests in the socket queue, too: admission must reclaim their slots.
|
||||
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| {
|
||||
let r = request();
|
||||
r.cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
r
|
||||
}));
|
||||
let (mut client, server) = pair();
|
||||
let q = pending.clone();
|
||||
workers.push(std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5))));
|
||||
client.write_all(b"{\"id\":100,\"method\":\"window.list\",\"params\":{\"window\":0}}\n").unwrap();
|
||||
let start = Instant::now();
|
||||
while !pending.lock().unwrap().iter().any(|r| r.id == 100) {
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
drain_queue(&pending, &mut app.control_queue);
|
||||
assert_eq!(app.control_queue.len(), 1, "expired UI requests no longer occupy the cap");
|
||||
control::drain(&mut app, &ctx);
|
||||
let mut client = BufReader::new(client);
|
||||
assert_eq!(reply(&mut client)["ok"], true);
|
||||
client.get_ref().shutdown(Shutdown::Write).unwrap();
|
||||
assert!(app.control_queue.is_empty());
|
||||
// A window that finally draws must discard its expired command before dispatch, too.
|
||||
let documents = app.session.docs.len();
|
||||
app.windows.current = window;
|
||||
app.control_queue.push(expired);
|
||||
control::drain(&mut app, &ctx);
|
||||
assert!(app.control_queue.is_empty());
|
||||
assert_eq!(app.session.docs.len(), documents);
|
||||
for worker in workers {
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Which graphics API draws the window.
|
||||
//!
|
||||
//! eframe offers wgpu every backend it was built with, and between equal GPUs wgpu takes Vulkan
|
||||
//! first, on Windows too. A Vulkan driver that faults there takes the app down as the window opens:
|
||||
//! an access violation inside the vendor's driver, which no guard of ours can catch (seen on a laptop
|
||||
//! with two AMD GPUs whose drivers were years apart). Direct3D 12 is part of Windows, so on Windows
|
||||
//! wgpu is offered Direct3D 12, and OpenGL for hardware without a Direct3D 12 driver.
|
||||
//!
|
||||
//! `WGPU_BACKEND` (`dx12`, `vulkan`, `gl`, `metal`) still names the backend on every system, and
|
||||
//! systems other than Windows keep eframe's defaults.
|
||||
|
||||
use eframe::egui_wgpu::{WgpuConfiguration, WgpuSetup};
|
||||
use eframe::wgpu::Backends;
|
||||
|
||||
/// Direct3D 12, then OpenGL: wgpu ranks a hardware GPU on either ahead of Direct3D's software one.
|
||||
const WINDOWS: Backends = Backends::DX12.union(Backends::GL);
|
||||
|
||||
/// The backends wgpu chooses from: the ones `WGPU_BACKEND` asks for (`requested`), else [`WINDOWS`]
|
||||
/// on Windows, else eframe's `defaults`.
|
||||
pub fn backends(requested: Option<Backends>, windows: bool, defaults: Backends) -> Backends {
|
||||
match requested {
|
||||
Some(requested) => requested,
|
||||
None if windows => WINDOWS,
|
||||
None => defaults,
|
||||
}
|
||||
}
|
||||
|
||||
/// Offer wgpu [`backends`] instead of eframe's defaults.
|
||||
pub fn configure(options: &mut WgpuConfiguration) {
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_setup {
|
||||
let offered = &mut create.instance_descriptor.backends;
|
||||
*offered = backends(Backends::from_env(), cfg!(windows), *offered);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! The backends offered to wgpu: no Vulkan on Windows unless it is asked for (a faulting Vulkan
|
||||
//! driver crashed the app as its window opened), and nothing changed anywhere else.
|
||||
|
||||
use eframe::wgpu::Backends;
|
||||
|
||||
use crate::graphics::backends;
|
||||
|
||||
/// What eframe offers when `WGPU_BACKEND` isn't set.
|
||||
const DEFAULTS: Backends = Backends::PRIMARY.union(Backends::GL);
|
||||
|
||||
#[test]
|
||||
fn windows_is_offered_direct3d_and_not_vulkan() {
|
||||
let offered = backends(None, true, DEFAULTS);
|
||||
assert!(offered.contains(Backends::DX12), "Direct3D 12 draws the window");
|
||||
assert!(offered.contains(Backends::GL), "OpenGL is there for hardware without a Direct3D 12 driver");
|
||||
assert!(!offered.contains(Backends::VULKAN), "a Vulkan driver that faults can't be picked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wgpu_backend_names_the_backend_on_every_system() {
|
||||
for windows in [true, false] {
|
||||
for asked in [Backends::VULKAN, Backends::DX12, Backends::GL, Backends::VULKAN | Backends::GL] {
|
||||
assert_eq!(backends(Some(asked), windows, DEFAULTS), asked, "windows: {windows}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_systems_keep_eframes_defaults() {
|
||||
assert_eq!(backends(None, false, DEFAULTS), DEFAULTS);
|
||||
assert_eq!(backends(None, false, Backends::METAL), Backends::METAL);
|
||||
}
|
||||
+68
-11
@@ -9,18 +9,30 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod args;
|
||||
mod control_server;
|
||||
mod gamepad;
|
||||
mod graphics;
|
||||
mod recovery;
|
||||
mod services;
|
||||
mod startup;
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod apple_events_tests;
|
||||
#[cfg(test)]
|
||||
mod args_tests;
|
||||
#[cfg(test)]
|
||||
mod graphics_tests;
|
||||
#[cfg(test)]
|
||||
mod recovery_online_tests;
|
||||
#[cfg(test)]
|
||||
mod recovery_tests;
|
||||
#[cfg(test)]
|
||||
mod services_tests;
|
||||
#[cfg(test)]
|
||||
mod startup_tests;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -38,6 +50,9 @@ struct App {
|
||||
title: String,
|
||||
/// Shared with the services, which write the copies after a panic.
|
||||
recovery: recovery::Shared,
|
||||
/// Documents and quit requests from Finder, the Dock and Open With.
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events: fmv_macos_events::Inbox,
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
@@ -49,9 +64,10 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
let title = with_app(|fig| {
|
||||
let mut q = self.queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
fig.control_queue.extend(q.drain(..));
|
||||
drop(q);
|
||||
control_server::drain_queue(&self.queue, &mut fig.control_queue);
|
||||
figcraft_ui_egui::control::drain_hidden(fig, ctx);
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events::poll(&self.apple_events, fig, ctx);
|
||||
// Closing the window asks about unsaved changes (and keeps the app running while other
|
||||
// windows are open).
|
||||
figcraft_ui_egui::windows::on_close_request(fig, ctx);
|
||||
@@ -78,9 +94,9 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
let ctx = ui.ctx().clone();
|
||||
with_app(|fig| figcraft_ui_egui::windows::guard(fig, &ctx, |fig| fig.ui(ui)));
|
||||
services::parented(frame, || with_app(|fig| figcraft_ui_egui::windows::guard(fig, &ctx, |fig| fig.ui(ui))));
|
||||
}
|
||||
|
||||
fn on_exit(&mut self) {
|
||||
@@ -129,24 +145,37 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
// Documents a crashed or killed run left unsaved come back; this run keeps its own copies.
|
||||
let recovery_root = services::config_dir().map(|d| d.join("Recovery"));
|
||||
let recovered = recovery_root.as_deref().map_or(0, |r| recovery::recover(r, &mut session));
|
||||
let recovery: recovery::Shared = Arc::new(Mutex::new(recovery_root.as_deref().and_then(recovery::Recovery::start)));
|
||||
// The preferences first: they hold the online files a recovered document may be one of.
|
||||
let recovery = recovery::Shared::default();
|
||||
let svc = services::Desktop::load(recovery.clone());
|
||||
if let Some(prefs) = svc.prefs.clone() {
|
||||
session.prefs = prefs;
|
||||
}
|
||||
let recovered = recovery_root.as_deref().map(|r| recovery::recover_all(r, &mut session)).unwrap_or_default();
|
||||
let mut copies = recovery_root.as_deref().and_then(recovery::Recovery::start);
|
||||
// Copies of online files stay until their files arrive from their servers.
|
||||
if let Some(r) = copies.as_mut() {
|
||||
r.adopt(recovered.waiting);
|
||||
}
|
||||
*recovery::lock(&recovery) = copies;
|
||||
// Plugins installed from a server's plugin directory live with the user's settings.
|
||||
session.plugins.install_dir = services::config_dir().map(|d| d.join("Plugins"));
|
||||
// Invite links open this app (Windows registers per user at run time; installed builds by
|
||||
// default, never a build from source unless asked).
|
||||
session.live.links = figcraft_collab_net::links::system();
|
||||
session.invite_links_startup();
|
||||
if session.docs.is_empty() {
|
||||
// A first run (no settings kept yet, nothing to open) starts on the home screen, with its
|
||||
// tips and templates; later runs start in a new file.
|
||||
let first_run = svc.prefs.is_none() && args.join.is_none();
|
||||
if session.docs.is_empty() && !first_run {
|
||||
let _ = session.execute("file.new", &json!({}));
|
||||
}
|
||||
// The interface language from the first frame on, and for the message when the window can't
|
||||
// open (no frame then).
|
||||
figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref());
|
||||
let mut fig = FigApp::new(session, Box::new(svc));
|
||||
fig.ui.home = false;
|
||||
fig.note_recovered(recovered);
|
||||
fig.note_recovered(recovered.copies);
|
||||
if let Some(link) = &args.join {
|
||||
figcraft_ui_egui::live::open_invite(&mut fig, link);
|
||||
}
|
||||
@@ -155,8 +184,11 @@ fn main() -> eframe::Result {
|
||||
control_server::start(p, queue.clone(), ctx_slot.clone());
|
||||
}
|
||||
let icon = services::window_icon();
|
||||
// The app id is the Wayland app_id and the X11 window class on Linux: the desktop entry's name
|
||||
// and StartupWMClass (FreeBSD's package matches the binary's name instead).
|
||||
let mut viewport = egui::ViewportBuilder::default()
|
||||
.with_title("FigCraft")
|
||||
.with_app_id("ai.storyteller.figcraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([800.0, 500.0])
|
||||
.with_drag_and_drop(true);
|
||||
@@ -172,7 +204,32 @@ fn main() -> eframe::Result {
|
||||
if let Some(icon) = icon {
|
||||
viewport = viewport.with_icon(icon);
|
||||
}
|
||||
let options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
|
||||
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
|
||||
graphics::configure(&mut options.wgpu_options);
|
||||
figcraft_ui_egui::windows::install(fig);
|
||||
eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery }))))
|
||||
// Registered before the event loop starts, so it catches the Finder event that launched the app
|
||||
// as well as later ones. Lives until the event loop returns; the app creator only borrows it.
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = apple_events::AppleEvents::install();
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = &apple_events;
|
||||
let result = eframe::run_native(
|
||||
"FigCraft",
|
||||
options,
|
||||
Box::new(move |_cc| {
|
||||
Ok(Box::new(App {
|
||||
queue,
|
||||
ctx_slot,
|
||||
title: String::new(),
|
||||
recovery,
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events: apple_events.connect(&_cc.egui_ctx),
|
||||
}))
|
||||
}),
|
||||
);
|
||||
// Say why instead of quitting silently: release builds on Windows have no console.
|
||||
if let Err(e) = &result {
|
||||
startup::failed(e, port.is_some());
|
||||
}
|
||||
result
|
||||
}
|
||||
+276
-52
@@ -4,7 +4,20 @@
|
||||
//! in the config folder, in the background, at most every [`INTERVAL`] seconds, and the copy goes
|
||||
//! once the document is saved or closed. The app holds a lock on its folder. At startup, the folders
|
||||
//! whose lock is free belong to runs that ended without closing: their copies open again as
|
||||
//! unsaved documents (`file.recover`) and the folder goes. Closing the app normally removes its own.
|
||||
//! unsaved documents (`file.recover`). Each such copy **stays until this run has its own copy of
|
||||
//! the document** (or it is saved or closed): until then it is the only copy on disk, and a run
|
||||
//! that can't keep copies of its own leaves it for the next start. Then it goes, and its folder
|
||||
//! with its last copy. Closing the app normally removes its own.
|
||||
//!
|
||||
//! A document that is one of your online files has a note beside its copy (`<uid>.online`, written
|
||||
//! after the copy it describes): the server, the file's id, and who the app was in the file with
|
||||
//! the last change of the server's it had; never a token. The next start joins that file again
|
||||
//! as its owner (`collab.recoverFile`) and **keeps the copy until the outcome is known**: the
|
||||
//! server has everything in it (the copy goes), the copy's own unconfirmed changes were put on
|
||||
//! top of the server's file and sent, the copy was opened beside the file because both had
|
||||
//! changed, or the file couldn't be joined and the copy was opened as any other. Whatever a copy
|
||||
//! became is copied into this run's folder before the old copy is removed. Without the note, or
|
||||
//! when the file is no longer recorded, the copy opens as any other.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
@@ -12,7 +25,8 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::{DocState, Session};
|
||||
use figcraft_format::{Branch, Version};
|
||||
use serde_json::json;
|
||||
|
||||
/// Seconds between copies of a document that keeps changing.
|
||||
@@ -31,77 +45,219 @@ pub struct Recovery {
|
||||
dir: PathBuf,
|
||||
/// Held while the app runs: a free lock means the run that wrote the folder is gone.
|
||||
_lock: File,
|
||||
/// The revision each document was last copied at, and the write in flight.
|
||||
written: HashMap<u64, (u64, Option<JoinHandle<()>>)>,
|
||||
/// What was copied of each document, and the write in flight.
|
||||
written: HashMap<u64, Copy>,
|
||||
/// When (UI clock) the next copies are due.
|
||||
next: f64,
|
||||
/// Copies of online files from earlier runs, kept until their outcome is known.
|
||||
waiting: Vec<Waiting>,
|
||||
}
|
||||
|
||||
/// The state of the copy of one document: only a write that succeeded counts as a copy.
|
||||
#[derive(Default)]
|
||||
struct Copy {
|
||||
/// The [`stamp`] whose copy was written.
|
||||
done: Option<Vec<u64>>,
|
||||
/// The write in flight: the stamp it copies, and whether it succeeded once it ends.
|
||||
flight: Option<(Vec<u64>, JoinHandle<bool>)>,
|
||||
}
|
||||
|
||||
impl Copy {
|
||||
/// Take in the result of the write in flight once it ended; a failed one leaves its revision uncopied.
|
||||
fn reap(&mut self) {
|
||||
if self.flight.as_ref().is_some_and(|f| f.1.is_finished()) {
|
||||
self.join();
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the write in flight and take in its result.
|
||||
fn join(&mut self) {
|
||||
if let Some((stamp, h)) = self.flight.take()
|
||||
&& h.join().unwrap_or(false)
|
||||
{
|
||||
self.done = Some(stamp);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `stamp` has neither been copied nor is being copied.
|
||||
fn pending(&self, stamp: &[u64]) -> bool {
|
||||
self.done.as_deref() != Some(stamp) && self.flight.as_ref().is_none_or(|f| f.0 != stamp)
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy from an earlier run, kept until what it became is safe.
|
||||
pub struct Waiting {
|
||||
until: Until,
|
||||
copy: PathBuf,
|
||||
/// The lock of the folder it is in: no other start takes the copy meanwhile (none for a copy
|
||||
/// left directly in the root by older versions).
|
||||
_lock: Option<Arc<File>>,
|
||||
}
|
||||
|
||||
/// What a kept copy waits for.
|
||||
enum Until {
|
||||
/// One of your online files, joined again as this session: its outcome.
|
||||
Session(u64),
|
||||
/// Opened as document `uid`: this run's own copy of it (or its save or close).
|
||||
Copied(u64),
|
||||
}
|
||||
|
||||
/// What [`recover_all`] brought back.
|
||||
#[derive(Default)]
|
||||
pub struct Recovered {
|
||||
/// Documents opened from their copies.
|
||||
pub copies: usize,
|
||||
/// The copies kept until what they became is safe ([`Recovery::adopt`]).
|
||||
pub waiting: Vec<Waiting>,
|
||||
}
|
||||
|
||||
impl Recovery {
|
||||
/// Take this run's folder under `root` (`None` when it can't be created or locked: no copies).
|
||||
/// A crashed run with the same process id may still hold its folder (its copies kept): then
|
||||
/// another name is taken.
|
||||
pub fn start(root: &Path) -> Option<Self> {
|
||||
let dir = root.join(std::process::id().to_string());
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let lock = File::create(dir.join(LOCK)).ok()?;
|
||||
lock.try_lock().ok()?;
|
||||
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0 })
|
||||
let pid = std::process::id();
|
||||
(0..10).find_map(|n| {
|
||||
let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") });
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).ok()?;
|
||||
lock.try_lock().ok()?;
|
||||
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
|
||||
})
|
||||
}
|
||||
|
||||
/// Keep earlier runs' copies until what they became is safe.
|
||||
pub fn adopt(&mut self, waiting: Vec<Waiting>) {
|
||||
self.waiting.extend(waiting);
|
||||
}
|
||||
|
||||
/// Copy the documents that changed since their last copy and drop the copies of documents that
|
||||
/// were saved or closed. Returns how long until it needs to run again (`None`: nothing pending,
|
||||
/// an idle window can sleep).
|
||||
pub fn tick(&mut self, s: &Session, now: f64) -> Option<f64> {
|
||||
let pending = s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.0 != d.revision));
|
||||
self.written.values_mut().for_each(Copy::reap);
|
||||
self.settle(s);
|
||||
// A join on its way settles within its timeouts: look again soon. A copy waiting for this
|
||||
// run's own is looked at again with the next copies.
|
||||
let joining = self.waiting.iter().any(|w| matches!(w.until, Until::Session(_)));
|
||||
let settling = if joining { Some(0.5) } else { (!self.waiting.is_empty()).then_some(INTERVAL) };
|
||||
if now < self.next {
|
||||
return pending.then_some(self.next - now);
|
||||
return self.pending(s).then_some(self.next - now).or(settling);
|
||||
}
|
||||
self.write(s, false);
|
||||
self.next = now + INTERVAL;
|
||||
None
|
||||
// A copy that failed is tried again at the next turn, without another edit.
|
||||
settling.or(self.pending(s).then_some(INTERVAL))
|
||||
}
|
||||
|
||||
/// Whether a document with unsaved changes has a revision that is neither copied nor being copied.
|
||||
fn pending(&self, s: &Session) -> bool {
|
||||
s.docs.iter().any(|d| needs_copy(s, d) && self.written.get(&d.uid).is_none_or(|w| w.pending(&stamp(s, d))))
|
||||
}
|
||||
|
||||
/// Remove earlier runs' copies once what they became is safe: copied here (the write
|
||||
/// succeeded), saved or closed. An online file's copy goes once its outcome is known and every
|
||||
/// document is safe, since which of them it became isn't known.
|
||||
pub fn settle(&mut self, s: &Session) {
|
||||
let joined = |w: &Waiting| matches!(w.until, Until::Session(id) if !s.live.recovering(id));
|
||||
if self.waiting.iter().any(joined) {
|
||||
self.write(s, true);
|
||||
}
|
||||
let all_safe = s.docs.iter().all(|d| self.safe(s, d));
|
||||
let (done, waiting): (Vec<Waiting>, Vec<Waiting>) = std::mem::take(&mut self.waiting).into_iter().partition(|w| match w.until {
|
||||
Until::Session(_) => joined(w) && all_safe,
|
||||
Until::Copied(uid) => s.docs.iter().find(|d| d.uid == uid).is_none_or(|d| self.safe(s, d)),
|
||||
});
|
||||
self.waiting = waiting;
|
||||
done.into_iter().for_each(remove_kept);
|
||||
}
|
||||
|
||||
/// Whether document `d`'s work is on disk: saved (or nothing to keep), or copied here (a write
|
||||
/// that succeeded) with no write in flight.
|
||||
fn safe(&self, s: &Session, d: &DocState) -> bool {
|
||||
!needs_copy(s, d) || self.written.get(&d.uid).is_some_and(|w| w.done.is_some() && w.flight.is_none())
|
||||
}
|
||||
|
||||
/// Copy every document with unsaved changes; `wait`: before returning (after a panic).
|
||||
pub fn write(&mut self, s: &Session, wait: bool) {
|
||||
for d in &s.docs {
|
||||
let path = self.dir.join(format!("{}.figcraft", d.uid));
|
||||
if !needs_copy(d) {
|
||||
if self.written.remove(&d.uid).is_some() {
|
||||
let note = path.with_extension(ONLINE);
|
||||
if !needs_copy(s, d) {
|
||||
if let Some(mut w) = self.written.remove(&d.uid) {
|
||||
// A write still running would bring the copy back after it was removed.
|
||||
w.join();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(¬e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let entry = self.written.entry(d.uid).or_insert((0, None));
|
||||
let busy = entry.1.as_ref().is_some_and(|h| !h.is_finished());
|
||||
if entry.0 == d.revision || busy {
|
||||
let entry = self.written.entry(d.uid).or_default();
|
||||
// An emergency save must copy the newest revision: let the write in flight finish first.
|
||||
if wait {
|
||||
entry.join();
|
||||
} else {
|
||||
entry.reap();
|
||||
}
|
||||
// A write still in flight (for any revision) is waited for; a failed one is tried again.
|
||||
let stamp = stamp(s, d);
|
||||
if entry.done.as_ref() == Some(&stamp) || entry.flight.is_some() {
|
||||
continue;
|
||||
}
|
||||
let doc = d.doc.clone();
|
||||
let job = move || write_atomic(&path, &doc);
|
||||
let (versions, branches) = (d.versions.clone(), figcraft_engine::branches::current(s, d.uid));
|
||||
// Which online file it is and what of the server's this very copy has (a line each:
|
||||
// the server, the file's id, then the session, client id and last change), while it
|
||||
// is one. Written after the copy, never before: a note must not promise more of the
|
||||
// server's changes than its copy holds.
|
||||
let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq));
|
||||
let job = move || {
|
||||
if !write_atomic(&path, &doc, &versions, &branches) {
|
||||
return false;
|
||||
}
|
||||
let _ = match online {
|
||||
Some(text) => std::fs::write(¬e, text),
|
||||
None => std::fs::remove_file(¬e),
|
||||
};
|
||||
true
|
||||
};
|
||||
// The stamp counts as copied only once its write succeeded.
|
||||
if wait {
|
||||
job();
|
||||
entry.1 = None;
|
||||
if job() {
|
||||
entry.done = Some(stamp);
|
||||
}
|
||||
} else {
|
||||
entry.1 = std::thread::Builder::new().name("recovery".into()).spawn(job).ok();
|
||||
entry.flight = std::thread::Builder::new().name("recovery".into()).spawn(job).ok().map(|h| (stamp, h));
|
||||
}
|
||||
entry.0 = d.revision;
|
||||
}
|
||||
// Closed documents.
|
||||
let open: Vec<u64> = s.docs.iter().map(|d| d.uid).collect();
|
||||
let dir = &self.dir;
|
||||
self.written.retain(|uid, _| {
|
||||
self.written.retain(|uid, w| {
|
||||
let keep = open.contains(uid);
|
||||
if !keep {
|
||||
w.join();
|
||||
let _ = std::fs::remove_file(dir.join(format!("{uid}.figcraft")));
|
||||
let _ = std::fs::remove_file(dir.join(format!("{uid}.{ONLINE}")));
|
||||
}
|
||||
keep
|
||||
});
|
||||
}
|
||||
|
||||
/// A normal quit: the documents were saved or their changes discarded on purpose.
|
||||
/// Test hook: pretend a write of `uid` at `revision` is in flight in `writer`.
|
||||
#[cfg(test)]
|
||||
pub fn hold(&mut self, uid: u64, revision: u64, writer: JoinHandle<bool>) {
|
||||
self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) });
|
||||
}
|
||||
|
||||
/// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier
|
||||
/// runs' copies still waiting for their outcome stay for the next start.)
|
||||
pub fn finish(self) {
|
||||
let Self { dir, _lock, written, .. } = self;
|
||||
let Self { dir, _lock, written, waiting, .. } = self;
|
||||
// The documents earlier runs' copies became were saved or discarded too.
|
||||
waiting.into_iter().filter(|w| matches!(w.until, Until::Copied(_))).for_each(remove_kept);
|
||||
// Windows removes a folder only once nothing in it is open: copies being written first.
|
||||
for h in written.into_values().filter_map(|w| w.1) {
|
||||
for (_, h) in written.into_values().filter_map(|w| w.flight) {
|
||||
let _ = h.join();
|
||||
}
|
||||
drop(_lock);
|
||||
@@ -109,35 +265,96 @@ impl Recovery {
|
||||
}
|
||||
}
|
||||
|
||||
/// A document whose changes would be lost (a branch tab's edits are in its main file).
|
||||
fn needs_copy(d: &figcraft_engine::DocState) -> bool {
|
||||
d.is_dirty() && d.branch_of.is_none()
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document) {
|
||||
let Ok(bytes) = figcraft_format::save_bytes(doc, None) else { return };
|
||||
let tmp = path.with_extension("tmp");
|
||||
if std::fs::write(&tmp, bytes).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, path);
|
||||
/// Remove a kept copy and its note, and its folder with its last copy (while another is kept
|
||||
/// there, its lock is held).
|
||||
fn remove_kept(w: Waiting) {
|
||||
let _ = std::fs::remove_file(&w.copy);
|
||||
let _ = std::fs::remove_file(w.copy.with_extension(ONLINE));
|
||||
let last = w._lock.as_ref().is_some_and(|l| Arc::strong_count(l) == 1);
|
||||
let dir = w.copy.parent().map(Path::to_path_buf).filter(|_| last);
|
||||
drop(w);
|
||||
if let Some(dir) = dir {
|
||||
let _ = std::fs::remove_file(dir.join(LOCK));
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the copies left by runs that ended without closing (folders under `root` whose lock is
|
||||
/// free, and copies directly in `root` from older versions) as unsaved documents, then remove them.
|
||||
/// A copy that can't be read stays where it is. Returns how many documents came back.
|
||||
/// The extension of the note beside the copy of a document that is one of your online files.
|
||||
const ONLINE: &str = "online";
|
||||
|
||||
/// The copy at `file` is of one of your online files: join that file again as its owner, keeping
|
||||
/// the copy to compare with what arrives. The session, or `None` when the copy isn't one (or its
|
||||
/// file isn't recorded any more, or is open already): then it opens as any other copy.
|
||||
fn rejoin_online(file: &Path, s: &mut Session) -> Option<u64> {
|
||||
let note = std::fs::read_to_string(file.with_extension(ONLINE)).ok()?;
|
||||
let mut lines = note.lines().map(str::trim);
|
||||
let (server, id, at) = (lines.next()?, lines.next()?, lines.next()?);
|
||||
let mut at = at.split(' ').map(|n| n.parse::<u64>().ok());
|
||||
let (session, client, seq) = (at.next()??, at.next()??, at.next()??);
|
||||
let params = json!({"path": file.to_string_lossy(), "server": server, "file": id, "session": session, "client": client, "seq": seq});
|
||||
s.execute("collab.recoverFile", ¶ms).ok()?.get("session")?.as_u64()
|
||||
}
|
||||
|
||||
/// A file whose changes would be lost: in its document, its history or one of its branch tabs
|
||||
/// (a branch tab's edits are saved with its main file, so the tab has no copy of its own).
|
||||
fn needs_copy(s: &Session, d: &DocState) -> bool {
|
||||
d.branch_of.is_none() && (d.is_dirty() || branch_tabs(s, d.uid).any(DocState::is_dirty))
|
||||
}
|
||||
|
||||
/// The tabs showing branches of the file in tab `uid`.
|
||||
fn branch_tabs(s: &Session, uid: u64) -> impl Iterator<Item = &DocState> {
|
||||
s.docs.iter().filter(move |t| t.branch_of.as_ref().is_some_and(|(main, _)| *main == uid))
|
||||
}
|
||||
|
||||
/// The revisions a copy of `d` is of: its own and its branch tabs' (a branch tab's edits change
|
||||
/// only its own revision). A new copy is due when they change.
|
||||
fn stamp(s: &Session, d: &DocState) -> Vec<u64> {
|
||||
std::iter::once(d.revision).chain(branch_tabs(s, d.uid).map(|t| t.revision)).collect()
|
||||
}
|
||||
|
||||
/// Write `doc` with its version history and branches to `path` through a temporary file; false
|
||||
/// when it couldn't be written (the copy before it is still there).
|
||||
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool {
|
||||
let Ok(bytes) = figcraft_format::save_bytes_with_history(doc, None, versions, branches) else { return false };
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes).is_ok() && std::fs::rename(&tmp, path).is_ok()
|
||||
}
|
||||
|
||||
/// [`recover_all`], for a start that keeps nothing waiting: how many documents came back as
|
||||
/// unsaved copies.
|
||||
#[cfg(test)]
|
||||
pub fn recover(root: &Path, s: &mut Session) -> usize {
|
||||
let Ok(entries) = std::fs::read_dir(root) else { return 0 };
|
||||
let mut n = 0;
|
||||
let mut open = |file: &Path, s: &mut Session| {
|
||||
if file.extension().is_some_and(|e| e == "figcraft") && s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
|
||||
n += 1;
|
||||
let _ = std::fs::remove_file(file);
|
||||
recover_all(root, s).copies
|
||||
}
|
||||
|
||||
/// Open the copies left by runs that ended without closing (folders under `root` whose lock is
|
||||
/// free, and copies directly in `root` from older versions) as unsaved documents. Each is kept
|
||||
/// until this run has its own copy of the document ([`Recovered::waiting`]); a copy of one of your
|
||||
/// online files, while its file is joined again. A copy that can't be read stays where it is.
|
||||
pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
|
||||
let mut out = Recovered::default();
|
||||
let Ok(entries) = std::fs::read_dir(root) else { return out };
|
||||
let mut open = |file: &Path, lock: Option<&Arc<File>>, s: &mut Session| {
|
||||
if file.extension().is_none_or(|e| e != "figcraft") {
|
||||
return;
|
||||
}
|
||||
// One of your online files comes back from its server, its copy kept until then.
|
||||
if let Some((session, lock)) = lock.and_then(|l| Some((rejoin_online(file, s)?, l))) {
|
||||
out.waiting.push(Waiting { until: Until::Session(session), copy: file.to_path_buf(), _lock: Some(lock.clone()) });
|
||||
return;
|
||||
}
|
||||
let before: Vec<u64> = s.docs.iter().map(|d| d.uid).collect();
|
||||
if s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
|
||||
out.copies += 1;
|
||||
if let Some(uid) = s.docs.iter().map(|d| d.uid).find(|u| !before.contains(u)) {
|
||||
out.waiting.push(Waiting { until: Until::Copied(uid), copy: file.to_path_buf(), _lock: lock.cloned() });
|
||||
}
|
||||
}
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
if !path.is_dir() {
|
||||
open(&path, s);
|
||||
open(&path, None, s);
|
||||
continue;
|
||||
}
|
||||
// Still running (another window of the app), or not ours to take.
|
||||
@@ -145,19 +362,26 @@ pub fn recover(root: &Path, s: &mut Session) -> usize {
|
||||
if lock.try_lock().is_err() {
|
||||
continue;
|
||||
}
|
||||
let lock = Arc::new(lock);
|
||||
let files: Vec<PathBuf> = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect();
|
||||
for f in &files {
|
||||
if f.extension().is_some_and(|e| e == "tmp") {
|
||||
// A copy cut off mid-write; the one before it is still there.
|
||||
let _ = std::fs::remove_file(f);
|
||||
} else {
|
||||
open(f, s);
|
||||
open(f, Some(&lock), s);
|
||||
}
|
||||
}
|
||||
drop(lock);
|
||||
// Empty now unless a copy couldn't be read (kept for the next start).
|
||||
let _ = std::fs::remove_file(path.join(LOCK));
|
||||
let _ = std::fs::remove_dir(&path);
|
||||
// The notes go with their copies (one whose copy stays, stays with it).
|
||||
for f in files.iter().filter(|f| f.extension().is_some_and(|e| e == ONLINE) && !f.with_extension("figcraft").exists()) {
|
||||
let _ = std::fs::remove_file(f);
|
||||
}
|
||||
// Empty now unless a copy couldn't be read, or is kept (its lock held).
|
||||
if Arc::strong_count(&lock) == 1 {
|
||||
drop(lock);
|
||||
let _ = std::fs::remove_file(path.join(LOCK));
|
||||
let _ = std::fs::remove_dir(&path);
|
||||
}
|
||||
}
|
||||
n
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
//! Recovery of your online files never loses an edit. The copy of a document that was one of them
|
||||
//! when a run ended without closing has a note beside it (the server, the file, who the app was
|
||||
//! there and the server's last change it had; never a token). The next start joins the file again
|
||||
//! as its owner and keeps the copy until the outcome is known: the copy is ahead of the server
|
||||
//! (its changes are applied on top and sent, as the client the app was), the same (it goes), both
|
||||
//! changed (it opens beside the file as a recovered copy), or the server can't be reached (it
|
||||
//! opens as any other copy). Without the record the copy opens as any other.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::figcraft_collab::message::{ClientMsg, ServerMsg};
|
||||
use figcraft_engine::figcraft_collab::{Client, Limits, Outgoing, Server};
|
||||
use figcraft_engine::live::{Access, Event, HostOptions, LiveLink, Presence, PublishOptions, Published, Resume, Role, SessionInfo, Status};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::recovery::{Recovery, recover, recover_all};
|
||||
|
||||
const SERVER: &str = "wss://share.example/";
|
||||
const OWNER: &str = "OwnerSecretOwnerSecret";
|
||||
const EDIT: &str = "figcraft://join?server=wss://share.example/&file=f1&token=EditSecretEditSecret00";
|
||||
|
||||
/// What the link was asked, what the app sent, and the events handed out next (as the session
|
||||
/// started last).
|
||||
#[derive(Default)]
|
||||
struct Script {
|
||||
joined: Vec<(String, Option<Resume>)>,
|
||||
sent: Vec<ClientMsg>,
|
||||
events: Vec<Event>,
|
||||
session: u64,
|
||||
}
|
||||
|
||||
struct Link(Arc<Mutex<Script>>);
|
||||
|
||||
impl LiveLink for Link {
|
||||
fn host(&mut self, _: u64, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
Err("not in this test".into())
|
||||
}
|
||||
fn join(&mut self, session: u64, link: &str, name: &str) -> Result<(), String> {
|
||||
self.join_resuming(session, link, name, None)
|
||||
}
|
||||
fn join_resuming(&mut self, session: u64, link: &str, _: &str, resume: Option<Resume>) -> Result<(), String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
s.joined.push((link.to_string(), resume));
|
||||
Ok(())
|
||||
}
|
||||
fn publish(&mut self, session: u64, _: PublishOptions) -> Result<(), String> {
|
||||
self.0.lock().unwrap().session = session;
|
||||
Ok(())
|
||||
}
|
||||
fn leave(&mut self, _: u64) {}
|
||||
fn presence(&mut self, _: u64, _: &Presence) {}
|
||||
fn send(&mut self, _: u64, msg: ClientMsg) {
|
||||
self.0.lock().unwrap().sent.push(msg);
|
||||
}
|
||||
fn poll(&mut self) -> Vec<(u64, Event)> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
let session = s.session;
|
||||
std::mem::take(&mut s.events).into_iter().map(|e| (session, e)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
type Shared = Arc<Mutex<Script>>;
|
||||
|
||||
fn session() -> (Session, Shared) {
|
||||
let mut s = Session::new();
|
||||
let script = Shared::default();
|
||||
s.live.install(Box::new(Link(script.clone())));
|
||||
(s, script)
|
||||
}
|
||||
|
||||
fn root(name: &str) -> PathBuf {
|
||||
let r = std::env::temp_dir().join(format!("figcraft-recovery-online-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&r);
|
||||
r
|
||||
}
|
||||
|
||||
fn info() -> SessionInfo {
|
||||
SessionInfo {
|
||||
role: Role::Guest,
|
||||
me: 1,
|
||||
name: "Me".into(),
|
||||
color: "#0D99FF".into(),
|
||||
file_name: "Homepage".into(),
|
||||
pages: vec![],
|
||||
link: EDIT.into(),
|
||||
url: String::new(),
|
||||
address: SERVER.into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
}
|
||||
}
|
||||
|
||||
/// Someone joins `server` (as the client `resume` names, if it knows it): their id and welcome.
|
||||
fn join(server: &mut Server, resume: Option<Resume>) -> (u32, ServerMsg) {
|
||||
let ClientMsg::Hello(mut hello) = Client::hello("f1", "", "Me", "") else { panic!("no hello") };
|
||||
hello.resume = resume;
|
||||
let (id, outs) = server.join(hello).unwrap();
|
||||
let welcome = outs.into_iter().find_map(|o| if let Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap();
|
||||
(id, welcome)
|
||||
}
|
||||
|
||||
/// What the app sent since the last call reaches the server; whether every batch was accepted.
|
||||
/// The server's confirmations wait for the app's next poll.
|
||||
fn deliver(script: &Shared, server: &mut Server, from: u32) -> bool {
|
||||
let sent = std::mem::take(&mut script.lock().unwrap().sent);
|
||||
let mut accepted = true;
|
||||
for m in sent {
|
||||
for o in server.receive(from, m) {
|
||||
match o {
|
||||
Outgoing::To(_, ServerMsg::Reject { .. }) => accepted = false,
|
||||
Outgoing::All(m @ ServerMsg::Changes { .. }) => script.lock().unwrap().events.push(Event::Sync(m)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
accepted
|
||||
}
|
||||
|
||||
fn layers(doc: &figcraft_engine::doc::Document) -> usize {
|
||||
doc.node(doc.pages[0]).map_or(0, |p| p.children.len())
|
||||
}
|
||||
|
||||
/// A run whose open file was shared online: the app, what it sent, the server and the app's id
|
||||
/// there. One rectangle is on the server.
|
||||
fn shared() -> (Session, Shared, Server, u32) {
|
||||
let (mut s, script) = session();
|
||||
s.execute("pref.publishServer", &json!({"url": SERVER})).unwrap();
|
||||
s.execute("file.rename", &json!({"name": "Homepage"})).unwrap();
|
||||
s.execute("collab.share", &json!({})).unwrap();
|
||||
let published = Published {
|
||||
server: "share.example".into(),
|
||||
file: "f1".into(),
|
||||
name: "Homepage".into(),
|
||||
edit: EDIT.into(),
|
||||
view: String::new(),
|
||||
owner: Some(OWNER.into()),
|
||||
expires: None,
|
||||
};
|
||||
let mut server = Server::new(s.doc().unwrap().clone(), "", Limits::default());
|
||||
let (me, welcome) = join(&mut server, None);
|
||||
script.lock().unwrap().events.extend([Event::Published(published), Event::Welcome { info: info(), peers: vec![] }, Event::Sync(welcome)]);
|
||||
s.live_poll();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
assert!(deliver(&script, &mut server, me));
|
||||
s.live_poll();
|
||||
assert_eq!(layers(server.document()), 1);
|
||||
(s, script, server, me)
|
||||
}
|
||||
|
||||
/// The run ends without closing, its copies written; the folder it leaves (as another process's).
|
||||
fn crash(s: &Session, root: &Path) -> PathBuf {
|
||||
let mut r = Recovery::start(root).unwrap();
|
||||
r.write(s, true);
|
||||
drop(r);
|
||||
let left = root.join("crashed");
|
||||
std::fs::rename(root.join(std::process::id().to_string()), &left).unwrap();
|
||||
left
|
||||
}
|
||||
|
||||
fn copies(dir: &Path, ext: &str) -> usize {
|
||||
std::fs::read_dir(dir).map_or(0, |d| d.flatten().filter(|f| f.path().extension().is_some_and(|e| e == ext)).count())
|
||||
}
|
||||
|
||||
/// The next run: only the preferences survive. Its copies of online files wait for their files.
|
||||
fn restart(crashed: &Session, root: &Path) -> (Session, Shared, Recovery, usize) {
|
||||
let (mut next, script) = session();
|
||||
next.prefs = serde_json::from_value(serde_json::to_value(&crashed.prefs).unwrap()).unwrap();
|
||||
let recovered = recover_all(root, &mut next);
|
||||
let mut r = Recovery::start(root).unwrap();
|
||||
r.adopt(recovered.waiting);
|
||||
(next, script, r, recovered.copies)
|
||||
}
|
||||
|
||||
/// The file arrives in the next run from `server`, as the join asked.
|
||||
fn arrive(next: &mut Session, script: &Shared, server: &mut Server) -> u32 {
|
||||
let resume = script.lock().unwrap().joined.last().and_then(|j| j.1);
|
||||
let (id, welcome) = join(server, resume);
|
||||
script.lock().unwrap().events.extend([Event::Welcome { info: info(), peers: vec![] }, Event::Sync(welcome)]);
|
||||
next.live_poll();
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_ahead_of_the_server_has_its_edits_applied_and_sent() {
|
||||
let root = root("ahead");
|
||||
let (mut s, script, mut server, me) = shared();
|
||||
// An edit the server never got (the connection was down, or the app died first).
|
||||
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
script.lock().unwrap().sent.clear();
|
||||
let left = crash(&s, &root);
|
||||
let note = std::fs::read_to_string(left.join(format!("{}.online", s.docs[0].uid))).unwrap();
|
||||
let at = s.online_of(s.docs[0].uid).unwrap().2;
|
||||
assert_eq!(note, format!("{SERVER}\nf1\n{} {} {}", at.session, at.client_id, at.seq), "which file, who the app was, what it had");
|
||||
assert!(!note.contains(OWNER) && !note.contains("token="), "never a secret");
|
||||
let (mut next, script, mut r, opened) = restart(&s, &root);
|
||||
assert_eq!(opened, 0, "nothing comes back as a plain copy");
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
let [(link, resume)] = sc.joined.as_slice() else { panic!("joined {}", sc.joined.len()) };
|
||||
assert_eq!(link, EDIT, "joined again as its owner");
|
||||
assert_eq!(*resume, Some(Resume { seq: u64::MAX, ..at }), "as the client it was, asking for the whole file");
|
||||
}
|
||||
// The copy stays until the outcome is known.
|
||||
assert!(r.tick(&next, 0.0).is_some(), "it looks again while the file is on its way");
|
||||
assert_eq!((copies(&left, "figcraft"), copies(&left, "online")), (1, 1));
|
||||
// The file arrives: the server lacks the ellipse; it goes on top as one edit and is sent.
|
||||
let id = arrive(&mut next, &script, &mut server);
|
||||
assert_eq!(id, me, "the server gave the id back");
|
||||
assert_eq!(next.message.take().as_deref(), Some("Recovered your changes to Homepage that hadn't reached the server"));
|
||||
assert_eq!((next.docs.len(), layers(&next.docs[1].doc)), (2, 2), "in the file's own tab, no copy beside it");
|
||||
assert!(deliver(&script, &mut server, id), "the server takes it (new layers are that client's to create)");
|
||||
assert_eq!(layers(server.document()), 2, "everyone gets the edit");
|
||||
assert_eq!(next.execute("collab.peers", &json!({})).unwrap()["status"], "live");
|
||||
// Only now the old copy goes, after this run has copied what it became.
|
||||
r.tick(&next, 1.0);
|
||||
assert!(!left.exists(), "the crashed run's folder is gone");
|
||||
let mine = root.join(std::process::id().to_string());
|
||||
assert_eq!((copies(&mine, "figcraft"), copies(&mine, "online")), (1, 1), "covered by this run's own copy");
|
||||
drop(r);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_online_copy_stays_while_what_it_became_cant_be_copied() {
|
||||
let root = root("ahead-blocked");
|
||||
let (mut s, script, mut server, _) = shared();
|
||||
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
script.lock().unwrap().sent.clear();
|
||||
let left = crash(&s, &root);
|
||||
let (mut next, script, mut r, _) = restart(&s, &root);
|
||||
arrive(&mut next, &script, &mut server);
|
||||
assert!(next.docs[1].is_dirty(), "the copy's edit is on top, not saved anywhere yet");
|
||||
// Something is in the way of this run's copy: writing it fails.
|
||||
let mine = root.join(std::process::id().to_string()).join(format!("{}.figcraft", next.docs[1].uid));
|
||||
std::fs::create_dir_all(&mine).unwrap();
|
||||
r.tick(&next, 1.0);
|
||||
assert_eq!((copies(&left, "figcraft"), copies(&left, "online")), (1, 1), "kept: the work is nowhere else on disk");
|
||||
// Once it can be written, the old copy goes.
|
||||
std::fs::remove_dir(&mine).unwrap();
|
||||
next.execute("shape.rectangle", &json!({"x": 50, "y": 0, "width": 4, "height": 4})).unwrap();
|
||||
r.tick(&next, 2.0);
|
||||
assert!(!left.exists());
|
||||
drop(r);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_the_server_already_has_is_discarded() {
|
||||
let root = root("equal");
|
||||
let (mut s, script, mut server, me) = shared();
|
||||
// Sent and accepted, but the app died before it heard so.
|
||||
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
assert!(deliver(&script, &mut server, me));
|
||||
let left = crash(&s, &root);
|
||||
let (mut next, script, mut r, _) = restart(&s, &root);
|
||||
assert_eq!(copies(&left, "figcraft"), 1);
|
||||
arrive(&mut next, &script, &mut server);
|
||||
assert_eq!((next.docs.len(), layers(&next.docs[1].doc)), (2, 2), "the file as the server has it");
|
||||
assert!(script.lock().unwrap().sent.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })), "nothing to send");
|
||||
assert!(!next.docs[1].is_dirty());
|
||||
r.tick(&next, 1.0);
|
||||
assert!(!left.exists());
|
||||
drop(r);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_that_cant_be_merged_opens_beside_the_file() {
|
||||
let root = root("conflict");
|
||||
let (mut s, script, mut server, _) = shared();
|
||||
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
script.lock().unwrap().sent.clear();
|
||||
let left = crash(&s, &root);
|
||||
// Meanwhile someone else changes the file on the server.
|
||||
let (other, welcome) = join(&mut server, None);
|
||||
let mut them = Client::join(welcome).unwrap();
|
||||
let mut doc = them.document().clone();
|
||||
let page = doc.pages[0];
|
||||
doc.create(page, figcraft_engine::doc::NodeKind::Rectangle, "Theirs", 50.0, 50.0, 5.0, 5.0).unwrap();
|
||||
let batch = them.edit(&doc).unwrap().unwrap();
|
||||
assert!(!server.receive(other, batch).iter().any(|o| matches!(o, Outgoing::To(_, ServerMsg::Reject { .. }))));
|
||||
let (mut next, script, mut r, _) = restart(&s, &root);
|
||||
arrive(&mut next, &script, &mut server);
|
||||
// The server's file is untouched; the copy is a tab of its own, named as what it is.
|
||||
let names: Vec<&str> = next.docs.iter().map(|d| d.doc.name.as_str()).collect();
|
||||
assert_eq!(names, ["Untitled", "Homepage", "Homepage (recovered copy)"]);
|
||||
assert_eq!(next.active, 1, "the file stays in front");
|
||||
assert_eq!(layers(&next.docs[1].doc), 2, "the rectangle and theirs");
|
||||
assert_eq!(layers(&next.docs[2].doc), 2, "the rectangle and the ellipse that never got there");
|
||||
assert!(next.docs[2].is_dirty() && next.docs[2].path.is_none());
|
||||
assert_eq!(
|
||||
next.message.take().as_deref(),
|
||||
Some("Homepage changed on the server before your last changes reached it: they are open beside it as “Homepage (recovered copy)”")
|
||||
);
|
||||
assert!(script.lock().unwrap().sent.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })), "nothing is forced onto the server");
|
||||
r.tick(&next, 1.0);
|
||||
assert!(!left.exists());
|
||||
assert_eq!(copies(&root.join(std::process::id().to_string()), "figcraft"), 1, "the recovered tab has this run's copy");
|
||||
drop(r);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_whose_server_cant_be_reached_opens_as_any_other() {
|
||||
let root = root("offline");
|
||||
let (mut s, script, _, _) = shared();
|
||||
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
script.lock().unwrap().sent.clear();
|
||||
let left = crash(&s, &root);
|
||||
let (mut next, script, mut r, _) = restart(&s, &root);
|
||||
r.tick(&next, 0.0);
|
||||
assert_eq!(copies(&left, "figcraft"), 1, "kept while the join is on its way");
|
||||
script.lock().unwrap().events.push(Event::Status(Status::Failed { reason: "Couldn't reach the server (timed out)".into() }));
|
||||
next.live_poll();
|
||||
let ds = next.docs.last().unwrap();
|
||||
assert_eq!((ds.doc.name.as_str(), layers(&ds.doc), ds.is_dirty(), ds.path.is_none()), ("Homepage", 2, true, true));
|
||||
assert_eq!(next.active, next.docs.len() - 1);
|
||||
assert!(next.message.take().unwrap_or_default().contains("your recovered copy is open"));
|
||||
assert_eq!(next.prefs.online_files.len(), 1, "the record stays: the file is still yours on the server");
|
||||
r.tick(&next, 1.0);
|
||||
assert!(!left.exists());
|
||||
assert_eq!(copies(&root.join(std::process::id().to_string()), "figcraft"), 1);
|
||||
drop(r);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_forgot_the_client_gets_no_layers_it_would_refuse() {
|
||||
let root = root("forgot");
|
||||
let (mut s, script, _, _) = shared();
|
||||
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
script.lock().unwrap().sent.clear();
|
||||
crash(&s, &root);
|
||||
// The server lost its session (restored from a backup): the same file, another session, so
|
||||
// the app joins as someone new and can't create the layers of who it was.
|
||||
let mut restored = Server::new(Session::new().doc().unwrap().clone(), "", Limits::default());
|
||||
let (mut next, script, r, _) = restart(&s, &root);
|
||||
arrive(&mut next, &script, &mut restored);
|
||||
assert_eq!(next.docs.last().map(|d| d.doc.name.as_str()), Some("Homepage (recovered copy)"));
|
||||
assert!(script.lock().unwrap().sent.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })));
|
||||
drop(r);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_its_record_the_copy_opens_as_any_other() {
|
||||
let root = root("fallback");
|
||||
let (s, _, _, _) = shared();
|
||||
crash(&s, &root);
|
||||
// The record was forgotten (or the preferences lost): the copy is what there is.
|
||||
let (mut next, script) = session();
|
||||
next.docs.clear();
|
||||
assert_eq!(recover(&root, &mut next), 1);
|
||||
assert!(script.lock().unwrap().joined.is_empty());
|
||||
assert!(next.docs.first().is_some_and(|d| d.is_dirty() && d.path.is_none()));
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_some(), "the copy stays until a run has its own");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_note_follows_its_copy_and_goes_when_the_file_leaves_its_session() {
|
||||
let root = root("note");
|
||||
let (mut s, _, _, _) = shared();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
r.write(&s, true);
|
||||
let dir = root.join(std::process::id().to_string());
|
||||
assert_eq!((copies(&dir, "figcraft"), copies(&dir, "online")), (1, 1));
|
||||
// Left: the copy is an ordinary one from the next write on.
|
||||
s.execute("collab.leave", &json!({})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
r.write(&s, true);
|
||||
assert_eq!((copies(&dir, "figcraft"), copies(&dir, "online")), (1, 0));
|
||||
r.finish();
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_none());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::recovery::{Recovery, recover};
|
||||
use crate::recovery::{Recovery, recover, recover_all};
|
||||
|
||||
fn root(name: &str) -> std::path::PathBuf {
|
||||
let r = std::env::temp_dir().join(format!("figcraft-recovery-{name}-{}", std::process::id()));
|
||||
@@ -34,7 +34,7 @@ fn a_crashed_runs_unsaved_documents_come_back() {
|
||||
let ds = next.docs.first().unwrap();
|
||||
assert!(ds.is_dirty() && ds.path.is_none());
|
||||
assert_eq!(next.doc().unwrap().node(next.doc().unwrap().pages[0]).map(|p| p.children.len()), Some(1));
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "the folder is gone");
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_some(), "the copy stays until a run has its own");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -62,3 +62,289 @@ fn copies_follow_saves_and_a_normal_quit_leaves_nothing() {
|
||||
assert!(!dir.exists(), "a normal quit removes the copies");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A run that ended without closing, leaving a copy of `s`'s unsaved document: its folder (as
|
||||
/// another process's).
|
||||
fn crashed(s: &Session, root: &std::path::Path) -> std::path::PathBuf {
|
||||
let mut r = Recovery::start(root).unwrap();
|
||||
r.write(s, true);
|
||||
drop(r);
|
||||
let left = root.join("crashed");
|
||||
std::fs::rename(root.join(std::process::id().to_string()), &left).unwrap();
|
||||
left
|
||||
}
|
||||
|
||||
fn copies_in(d: &std::path::Path) -> usize {
|
||||
std::fs::read_dir(d).map_or(0, |d| d.flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count())
|
||||
}
|
||||
|
||||
/// The next start: the copies come back, and this run's recovery keeps them until it covers them.
|
||||
fn restart(root: &std::path::Path) -> (Session, Recovery) {
|
||||
let mut next = Session::empty();
|
||||
let recovered = recover_all(root, &mut next);
|
||||
assert_eq!(recovered.copies, 1);
|
||||
let mut r = Recovery::start(root).unwrap();
|
||||
r.adopt(recovered.waiting);
|
||||
(next, r)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recovered_copy_stays_until_this_run_has_its_own_copy() {
|
||||
let root = root("handoff");
|
||||
let left = crashed(&edited(), &root);
|
||||
let (next, mut r) = restart(&root);
|
||||
assert_eq!(copies_in(&left), 1, "the only copy on disk until this run writes one");
|
||||
r.write(&next, true);
|
||||
r.tick(&next, 100.0);
|
||||
assert!(!left.exists(), "covered by this run's copy, the old one goes");
|
||||
assert_eq!(copies_in(&root.join(std::process::id().to_string())), 1);
|
||||
r.finish();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recovered_copy_stays_while_its_replacement_cant_be_written() {
|
||||
let root = root("blocked");
|
||||
let left = crashed(&edited(), &root);
|
||||
let (next, mut r) = restart(&root);
|
||||
// Something is in the way of this run's copy: writing it fails.
|
||||
let mine = root.join(std::process::id().to_string()).join(format!("{}.figcraft", next.docs[0].uid));
|
||||
std::fs::create_dir_all(&mine).unwrap();
|
||||
r.write(&next, true);
|
||||
r.tick(&next, 100.0);
|
||||
assert_eq!(copies_in(&left), 1, "kept: the work is nowhere else on disk");
|
||||
// Once it can be written, the old copy goes.
|
||||
std::fs::remove_dir(&mine).unwrap();
|
||||
let mut next = next;
|
||||
next.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 2, "height": 2})).unwrap();
|
||||
r.write(&next, true);
|
||||
r.tick(&next, 200.0);
|
||||
assert!(!left.exists());
|
||||
r.finish();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_recovery_of_its_own_the_copy_stays_for_the_next_start() {
|
||||
let root = root("nocopies");
|
||||
crashed(&edited(), &root);
|
||||
let mut next = Session::empty();
|
||||
let recovered = recover_all(&root, &mut next);
|
||||
assert_eq!(recovered.copies, 1);
|
||||
// This run couldn't take a folder of its own: it ends (or crashes) with nothing copied.
|
||||
drop(recovered);
|
||||
let mut after = Session::empty();
|
||||
assert_eq!(recover(&root, &mut after), 1, "the work comes back again");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recovered_copy_goes_once_its_document_is_saved_or_closed() {
|
||||
for close in [false, true] {
|
||||
let root = root(if close { "closed" } else { "saved" });
|
||||
let left = crashed(&edited(), &root);
|
||||
let (mut next, mut r) = restart(&root);
|
||||
if close {
|
||||
next.docs.clear();
|
||||
} else {
|
||||
next.ds_mut().unwrap().mark_saved();
|
||||
}
|
||||
r.tick(&next, 0.0);
|
||||
assert!(!left.exists(), "close {close}");
|
||||
r.finish();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_normal_quit_removes_the_recovered_copies_it_took_over() {
|
||||
let root = root("quitafter");
|
||||
let left = crashed(&edited(), &root);
|
||||
let (_, r) = restart(&root);
|
||||
// Quitting asked to save or discard every document.
|
||||
r.finish();
|
||||
assert!(!left.exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_whose_folder_name_is_still_held_takes_another() {
|
||||
let root = root("pidreuse");
|
||||
// A crashed run with this process's id whose folder the start still holds (its copy waits).
|
||||
let held = root.join(std::process::id().to_string());
|
||||
std::fs::create_dir_all(&held).unwrap();
|
||||
let lock = std::fs::File::create(held.join("lock")).unwrap();
|
||||
lock.try_lock().unwrap();
|
||||
let mut r = Recovery::start(&root).expect("a folder of its own");
|
||||
let s = edited();
|
||||
r.write(&s, true);
|
||||
assert_eq!(copies_in(&held), 0);
|
||||
let dirs = std::fs::read_dir(&root).unwrap().flatten().filter(|e| e.path() != held).count();
|
||||
assert_eq!(dirs, 1);
|
||||
r.finish();
|
||||
drop(lock);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// The rectangles in `doc`.
|
||||
fn rectangles(doc: &figcraft_engine::doc::Document) -> usize {
|
||||
doc.nodes().filter(|n| matches!(n.kind, figcraft_engine::doc::NodeKind::Rectangle)).count()
|
||||
}
|
||||
|
||||
/// The documents a crashed run's copies under `root` open as.
|
||||
fn crash_and_recover(r: Recovery, root: &std::path::Path) -> Session {
|
||||
drop(r);
|
||||
let mut next = Session::empty();
|
||||
assert_eq!(recover(root, &mut next), 1);
|
||||
next
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_keep_the_version_history_and_branches() {
|
||||
// #185: a copy has what a save would write, the latest edits on a branch tab included.
|
||||
let root = root("history");
|
||||
let mut s = edited();
|
||||
s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap();
|
||||
s.execute("branch.create", &json!({"name": "Explore"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
r.write(&s, true);
|
||||
let next = crash_and_recover(r, &root);
|
||||
let ds = next.docs.first().unwrap();
|
||||
assert_eq!(ds.versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["v1"]);
|
||||
assert_eq!(ds.branches.iter().map(|b| (b.name.as_str(), rectangles(&b.head))).collect::<Vec<_>>(), [("Explore", 2)]);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// The one copy under `root` (the run's folder), read back.
|
||||
fn copy(root: &std::path::Path) -> figcraft_format::Loaded {
|
||||
let dir = std::fs::read_dir(root).unwrap().flatten().next().unwrap().path();
|
||||
let file = std::fs::read_dir(dir).unwrap().flatten().map(|f| f.path()).find(|f| f.extension().is_some_and(|e| e == "figcraft")).unwrap();
|
||||
figcraft_format::load_bytes_with_versions(&std::fs::read(file).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_only_on_a_branch_tab_or_to_the_history_is_copied() {
|
||||
let root = root("history-only");
|
||||
let mut s = edited();
|
||||
s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap();
|
||||
s.execute("branch.create", &json!({"name": "Explore"})).unwrap();
|
||||
for d in &mut s.docs {
|
||||
d.mark_saved();
|
||||
}
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
// An edit on the branch tab only: the main file has no changes of its own yet.
|
||||
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
r.write(&s, true);
|
||||
assert_eq!(copy(&root).branches.iter().map(|b| rectangles(&b.head)).collect::<Vec<_>>(), [2]);
|
||||
s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
r.write(&s, true);
|
||||
assert_eq!(copy(&root).branches.iter().map(|b| rectangles(&b.head)).collect::<Vec<_>>(), [3]);
|
||||
// A version renamed on the main file leaves its document as it was.
|
||||
s.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
r.write(&s, true);
|
||||
let id = s.docs[0].versions[0].id.clone();
|
||||
s.execute("version.rename", &json!({"id": id, "name": "v1 (renamed)"})).unwrap();
|
||||
r.write(&s, true);
|
||||
assert_eq!(copy(&root).versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["v1 (renamed)"]);
|
||||
r.finish();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
fn slow_write(path: std::path::PathBuf) -> std::thread::JoinHandle<bool> {
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
std::fs::write(path, b"stale").is_ok()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_or_closing_waits_for_a_slow_write_so_the_copy_does_not_come_back() {
|
||||
let root = root("slow-cleanup");
|
||||
let mut s = edited();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path();
|
||||
let uid = s.docs[0].uid;
|
||||
let copy = dir.join(format!("{uid}.figcraft"));
|
||||
r.hold(uid, 1, slow_write(copy.clone()));
|
||||
s.ds_mut().unwrap().mark_saved();
|
||||
r.write(&s, false);
|
||||
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||
assert!(!copy.exists(), "a saved document's copy stays removed");
|
||||
// Closed: the same.
|
||||
let s = edited();
|
||||
let uid = s.docs[0].uid;
|
||||
let copy = dir.join(format!("{uid}.figcraft"));
|
||||
r.hold(uid, 1, slow_write(copy.clone()));
|
||||
r.write(&Session::empty(), false);
|
||||
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||
assert!(!copy.exists(), "a closed document's copy stays removed");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_emergency_save_waits_for_the_write_in_flight_and_copies_the_newest_revision() {
|
||||
let root = root("slow-emergency");
|
||||
let s = edited();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path();
|
||||
let uid = s.docs[0].uid;
|
||||
let copy = dir.join(format!("{uid}.figcraft"));
|
||||
r.hold(uid, 0, slow_write(copy.clone()));
|
||||
r.write(&s, true);
|
||||
assert!(std::fs::read(©).is_ok_and(|b| b != b"stale"), "the newest revision replaced the slow write");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// The files named `*.figcraft` in the run folder under `root`.
|
||||
fn copy_count(root: &std::path::Path) -> usize {
|
||||
let Some(dir) = std::fs::read_dir(root).unwrap().flatten().next().map(|e| e.path()) else { return 0 };
|
||||
std::fs::read_dir(dir).unwrap().flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count()
|
||||
}
|
||||
|
||||
/// A folder at the path of `s`'s first document's copy in the run folder under `root`.
|
||||
fn blocked_copy(root: &std::path::Path, s: &Session) -> std::path::PathBuf {
|
||||
let dir = std::fs::read_dir(root).unwrap().flatten().next().unwrap().path();
|
||||
let blocker = dir.join(format!("{}.figcraft", s.docs[0].uid));
|
||||
std::fs::create_dir(&blocker).unwrap();
|
||||
blocker
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_that_failed_is_tried_again_without_another_edit() {
|
||||
let root = root("retry");
|
||||
let s = edited();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
// A folder where the copy goes makes its write fail on every platform (renaming the run folder
|
||||
// away fails on Windows, where the run's lock file keeps it open).
|
||||
let blocker = blocked_copy(&root, &s);
|
||||
r.write(&s, true);
|
||||
std::fs::remove_dir(&blocker).unwrap();
|
||||
// Nothing was edited since: the same revision still has to be copied.
|
||||
r.write(&s, true);
|
||||
assert_eq!(copy_count(&root), 1, "the failed revision was copied again");
|
||||
r.finish();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_background_copy_that_failed_is_tried_again_without_another_edit() {
|
||||
let root = root("retry-bg");
|
||||
let s = edited();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
let blocker = blocked_copy(&root, &s);
|
||||
r.write(&s, false);
|
||||
// Let the failing write end before the way is clear again.
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
std::fs::remove_dir(&blocker).unwrap();
|
||||
for _ in 0..50 {
|
||||
r.write(&s, false);
|
||||
if copy_count(&root) == 1 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
assert_eq!(copy_count(&root), 1, "the failed revision was copied again");
|
||||
r.finish();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
+127
-12
@@ -1,11 +1,12 @@
|
||||
//! Desktop services: native file dialogs, clipboard, URLs, persisted preferences.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use figcraft_engine::{Prefs, Session};
|
||||
use figcraft_ui_egui::{FigApp, Services};
|
||||
use figcraft_ui_egui::{DialogFuture, FigApp, Services};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub struct Desktop {
|
||||
@@ -59,12 +60,16 @@ impl Desktop {
|
||||
|
||||
/// The system's preferred interface languages, most preferred first, comma-separated
|
||||
/// ("nl-NL,de-DE,en-US"): Windows' language list, macOS's AppleLanguages, `LANGUAGE` and the
|
||||
/// locale variables elsewhere. The UI shows the first one it has a catalog for.
|
||||
/// locale variables elsewhere. The UI shows the first language in the list that has a catalog.
|
||||
/// Windows keeps only its first preferred language (and English when it has no catalog): that
|
||||
/// list is also the input-language list, so a language added for a keyboard (Deutsch on a Polish
|
||||
/// system) must not decide the interface language.
|
||||
fn system_language() -> Option<String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::System::UserProfile::GlobalizationPreferences::Languages()
|
||||
.map(|l| l.into_iter().map(|s| s.to_string()).collect::<Vec<_>>().join(","))
|
||||
.and_then(|l| l.GetAt(0))
|
||||
.map(|s| s.to_string())
|
||||
.inspect_err(|e| log::info!("language: can't read the system languages: {e}"))
|
||||
.ok()
|
||||
}
|
||||
@@ -131,6 +136,34 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool {
|
||||
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want)
|
||||
}
|
||||
|
||||
/// Show `path` in the file manager: selected in Finder and Explorer, its folder elsewhere.
|
||||
fn reveal(path: &Path) -> std::io::Result<()> {
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut command = {
|
||||
let mut c = std::process::Command::new("open");
|
||||
c.arg("-R").arg(path);
|
||||
c
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let mut command = {
|
||||
let mut arg = std::ffi::OsString::from("/select,");
|
||||
arg.push(path);
|
||||
let mut c = std::process::Command::new("explorer");
|
||||
c.arg(arg);
|
||||
c
|
||||
};
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
let mut command = {
|
||||
let mut c = std::process::Command::new("xdg-open");
|
||||
c.arg(path.parent().unwrap_or(path));
|
||||
c
|
||||
};
|
||||
// The file manager keeps running; reap it off this thread so it leaves no zombie.
|
||||
let mut child = command.spawn()?;
|
||||
let _ = std::thread::Builder::new().name("figcraft-reveal".into()).spawn(move || child.wait());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The preferences' file in the config folder.
|
||||
const STATE: &str = "state.json";
|
||||
|
||||
@@ -141,26 +174,77 @@ pub fn save_state(app: &FigApp) {
|
||||
}
|
||||
|
||||
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
|
||||
/// either version whole).
|
||||
fn write_prefs(dir: &Path, prefs: &Prefs) {
|
||||
/// either version whole). They hold online owner tokens, so on Unix only the owner can read them.
|
||||
pub(crate) fn write_prefs(dir: &Path, prefs: &Prefs) {
|
||||
let tmp = dir.join("state.json.tmp");
|
||||
let written = std::fs::create_dir_all(dir)
|
||||
.and_then(|()| std::fs::write(&tmp, json!({"prefs": prefs}).to_string()))
|
||||
.and_then(|()| write_private(&tmp, json!({"prefs": prefs}).to_string().as_bytes()))
|
||||
.and_then(|()| std::fs::rename(&tmp, dir.join(STATE)));
|
||||
if let Err(e) = written {
|
||||
log::warn!("preferences: couldn't save them in {}: {e}", dir.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path`, owner read/write only on Unix from before the first byte (a leftover
|
||||
/// file from a crash is narrowed too: the mode given at open only applies to new files).
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let mut o = std::fs::OpenOptions::new();
|
||||
o.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600);
|
||||
let mut f = o.open(path)?;
|
||||
#[cfg(unix)]
|
||||
f.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
|
||||
f.write_all(bytes)
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// While the main window draws: a dialog parented to it, so a dialog opened from it stays in
|
||||
/// front of it (a sheet on macOS). eframe gives other windows no handle: theirs have no parent.
|
||||
static PARENT: RefCell<Option<rfd::AsyncFileDialog>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Run the main window's frame `f` with the dialogs it opens parented to `frame`'s window.
|
||||
pub fn parented<R>(frame: &eframe::Frame, f: impl FnOnce() -> R) -> R {
|
||||
PARENT.with(|p| p.replace(Some(rfd::AsyncFileDialog::new().set_parent(frame))));
|
||||
let r = f();
|
||||
PARENT.with(|p| p.replace(None));
|
||||
r
|
||||
}
|
||||
|
||||
/// A dialog with `title`, parented to the main window while it draws.
|
||||
fn dialog(title: &str) -> rfd::AsyncFileDialog {
|
||||
PARENT.with(|p| p.borrow().clone()).unwrap_or_default().set_title(title)
|
||||
}
|
||||
|
||||
/// The dialog opens now; its future gives the path picked.
|
||||
fn picked(pick: impl Future<Output = Option<rfd::FileHandle>> + 'static) -> DialogFuture {
|
||||
Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) })
|
||||
}
|
||||
|
||||
/// Native Save as filters; other callers keep their existing combined filter.
|
||||
fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> {
|
||||
if exts == ["figcraft", "fig"] {
|
||||
vec![("FigCraft (.figcraft)", &exts[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &exts[1..])]
|
||||
} else {
|
||||
vec![("File", exts)]
|
||||
}
|
||||
}
|
||||
|
||||
impl Services for Desktop {
|
||||
fn pick_open(&mut self, title: &str, exts: &[&str]) -> Option<PathBuf> {
|
||||
rfd::FileDialog::new().set_title(title).add_filter("Supported files", exts).pick_file()
|
||||
fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture {
|
||||
picked(dialog(title).add_filter("Supported files", exts).pick_file())
|
||||
}
|
||||
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> Option<PathBuf> {
|
||||
rfd::FileDialog::new().set_title(title).set_file_name(name).add_filter("File", exts).save_file()
|
||||
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture {
|
||||
let mut dialog = dialog(title).set_file_name(name);
|
||||
for (label, extensions) in save_filters(exts) {
|
||||
dialog = dialog.add_filter(label, extensions);
|
||||
}
|
||||
picked(dialog.save_file())
|
||||
}
|
||||
fn pick_folder(&mut self, title: &str) -> Option<PathBuf> {
|
||||
rfd::FileDialog::new().set_title(title).pick_folder()
|
||||
fn pick_folder(&mut self, title: &str) -> DialogFuture {
|
||||
picked(dialog(title).pick_folder())
|
||||
}
|
||||
fn clipboard_set_text(&mut self, s: &str) {
|
||||
if let Some(c) = self.clipboard.as_mut() {
|
||||
@@ -170,6 +254,9 @@ impl Services for Desktop {
|
||||
fn clipboard_get_text(&mut self) -> Option<String> {
|
||||
self.clipboard.as_mut()?.get_text().ok()
|
||||
}
|
||||
fn clipboard_get_html(&mut self) -> Option<String> {
|
||||
self.clipboard.as_mut()?.get().html().ok()
|
||||
}
|
||||
fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) {
|
||||
if let Some(c) = self.clipboard.as_mut() {
|
||||
let _ = c.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(rgba) });
|
||||
@@ -187,6 +274,11 @@ impl Services for Desktop {
|
||||
fn open_url(&mut self, url: &str) {
|
||||
let _ = webbrowser::open(url);
|
||||
}
|
||||
fn reveal_path(&mut self, path: &Path) {
|
||||
if let Err(e) = reveal(path) {
|
||||
log::warn!("show in folder: {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
fn save_prefs(&mut self, prefs: &Prefs) {
|
||||
if let Some(dir) = &self.dir {
|
||||
write_prefs(dir, prefs);
|
||||
@@ -195,6 +287,8 @@ impl Services for Desktop {
|
||||
fn emergency_save(&mut self, session: &Session) {
|
||||
if let Some(r) = crate::recovery::lock(&self.recovery).as_mut() {
|
||||
r.write(session, true);
|
||||
// Earlier runs' copies now covered go, so the next start doesn't open them twice.
|
||||
r.settle(session);
|
||||
}
|
||||
}
|
||||
fn reduce_motion(&self) -> bool {
|
||||
@@ -219,3 +313,24 @@ pub fn window_icon() -> Option<egui::IconData> {
|
||||
let (w, h) = img.dimensions();
|
||||
Some(egui::IconData { rgba: img.into_raw(), width: w, height: h })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod save_filter_tests {
|
||||
use super::save_filters;
|
||||
|
||||
#[test]
|
||||
fn native_and_fig_save_filters_are_separate_with_native_first() {
|
||||
let extensions = ["figcraft", "fig"];
|
||||
assert_eq!(
|
||||
save_filters(&extensions),
|
||||
vec![("FigCraft (.figcraft)", &extensions[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &extensions[1..])]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_save_filters_keep_one_combined_group() {
|
||||
for extensions in [&["figcraft"][..], &["fig"][..], &["png"][..], &["png", "jpg", "webp"][..], &[][..]] {
|
||||
assert_eq!(save_filters(extensions), vec![("File", extensions)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,3 +66,19 @@ fn plugins_are_kept_when_the_app_is_killed() {
|
||||
assert!(!dir.join("state.json.tmp").exists(), "no temporary file is left behind");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn preferences_are_readable_by_their_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = root("private");
|
||||
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
// An older world-readable file and a leftover temporary one from a crash.
|
||||
for name in ["state.json", "state.json.tmp"] {
|
||||
std::fs::write(dir.join(name), "{}").unwrap();
|
||||
std::fs::set_permissions(dir.join(name), std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
}
|
||||
crate::services::write_prefs(&dir, &figcraft_engine::Prefs::default());
|
||||
assert_eq!(mode(&dir.join("state.json")), 0o600);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! When the window can't open.
|
||||
//!
|
||||
//! Release builds on Windows have no console, so an error from `eframe::run_native` (no usable
|
||||
//! graphics adapter, a surface or device the driver refuses) went to a standard error nobody sees
|
||||
//! and the app quit without a word (#261). Now it is logged and shown in a message box.
|
||||
|
||||
/// Log the failure and, unless `quiet` (started for an agent with `--control`: a modal box would
|
||||
/// keep the caller waiting, and it reads standard error), show it in a message box.
|
||||
pub fn failed(error: &eframe::Error, quiet: bool) {
|
||||
// In the interface language: the text and its catalog entries are the UI crate's.
|
||||
let text = figcraft_ui_egui::dialogs::window_failed(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some());
|
||||
// Standard error even when RUST_LOG turned errors off.
|
||||
if log::log_enabled!(log::Level::Error) {
|
||||
log::error!("{text}");
|
||||
} else {
|
||||
eprintln!("figcraft: {text}");
|
||||
}
|
||||
if quiet {
|
||||
return;
|
||||
}
|
||||
let _ = rfd::MessageDialog::new()
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.set_title("FigCraft")
|
||||
.set_description(text)
|
||||
.set_buttons(rfd::MessageButtons::Ok)
|
||||
.show();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! The message shown when the window can't open: it carries the error, a next step and where to
|
||||
//! report it, instead of the app quitting silently (#261).
|
||||
|
||||
use figcraft_ui_egui::dialogs::window_failed as message;
|
||||
|
||||
const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}";
|
||||
|
||||
#[test]
|
||||
fn the_message_carries_the_error_and_where_to_report_it() {
|
||||
let text = message(ERROR, false);
|
||||
assert!(text.contains(ERROR), "the error itself: {text}");
|
||||
assert!(text.contains("https://github.com/storytold/figcraft/issues"), "where to report it: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() {
|
||||
let text = message(ERROR, false);
|
||||
assert!(text.contains("graphics driver"), "{text}");
|
||||
assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}");
|
||||
assert!(!text.contains("unsetting"), "{text}");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn on_windows_it_shows_how_to_start_with_opengl() {
|
||||
let text = message(ERROR, false);
|
||||
assert!(text.contains("set WGPU_BACKEND=gl"), "a release build has no console to look this up from: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_wgpu_backend_set_it_suggests_unsetting_it() {
|
||||
let text = message(ERROR, true);
|
||||
assert!(text.contains("unsetting"), "{text}");
|
||||
assert!(!text.contains("graphics driver"), "the chosen backend is the likelier cause: {text}");
|
||||
}
|
||||
@@ -16,3 +16,31 @@
|
||||
#
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.VanTan666]
|
||||
display_name = "VanTan"
|
||||
|
||||
[people.tylerjenningsw]
|
||||
real_name = "Tyler Jennings"
|
||||
display_name = "tylerjenningsw"
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.yyamandevrim]
|
||||
real_name = "Yaman Devrim"
|
||||
sync_github_name = true
|
||||
|
||||
[people.justinjohn0306]
|
||||
real_name = "Justin John"
|
||||
|
||||
[people.Losminek]
|
||||
|
||||
[people.RevDonz]
|
||||
|
||||
[people.Nael-Nathanael]
|
||||
sync_github_name = true
|
||||
|
||||
[people.volkovskey]
|
||||
real_name = "Andrii Volkov"
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Addresses removed from a file are kept only while its session lasts, so a long-running server
|
||||
//! doesn't collect them for every file it ever had open.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
use figcraft_collab::{Limits, Server};
|
||||
use figcraft_doc::Document;
|
||||
|
||||
use super::{Admission, Backend, Closing, Hub, InFlight, Room};
|
||||
|
||||
/// A backend that closes a file as soon as everyone left it.
|
||||
struct Closes;
|
||||
|
||||
impl Backend for Closes {
|
||||
fn admit(&mut self, _file: &str, _token: &str) -> Result<Admission, String> {
|
||||
Err("no".into())
|
||||
}
|
||||
|
||||
fn open(&mut self, _file: &str) -> Result<Server, String> {
|
||||
Err("no".into())
|
||||
}
|
||||
|
||||
fn idle(&mut self, _file: &str, _server: &mut Server) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn closing(&self) -> Closing {
|
||||
Closing::Restarting
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_file_forgets_whom_it_barred() {
|
||||
let mut doc = Document::new("File");
|
||||
doc.repair();
|
||||
let mut hub = Hub {
|
||||
in_flight: InFlight(Default::default()),
|
||||
backend: Closes,
|
||||
rooms: HashMap::from([("f".to_string(), Room::new(Server::new(doc, "", Limits::default())))]),
|
||||
conns: HashMap::new(),
|
||||
local: None,
|
||||
outbox: Vec::new(),
|
||||
barred: HashMap::new(),
|
||||
};
|
||||
let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 7));
|
||||
hub.barred.entry("f".into()).or_default().insert(ip);
|
||||
hub.barred.entry("other".into()).or_default().insert(ip);
|
||||
hub.close_if_empty("f");
|
||||
assert!(!hub.rooms.contains_key("f"), "everyone left: the file closed");
|
||||
assert!(!hub.barred.contains_key("f"), "and its barred addresses went with it");
|
||||
assert!(hub.barred.contains_key("other"), "another file's stay");
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! Join codes over the network: a file shared on the local network joined by its code (two
|
||||
//! engine sessions over loopback), what the browser's driver does with an online code, a server
|
||||
//! older than join codes, and the links that name a code. (A `figcraft-server` handing codes out
|
||||
//! and resolving them is tested in its own crate, with the same link.)
|
||||
|
||||
use std::net::{Ipv4Addr, TcpListener};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::code::{self, Code, Local};
|
||||
use figcraft_collab::message::{self, ClientMsg, PROTOCOL, ServerMsg};
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::live::{Event, Status};
|
||||
use serde_json::json;
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
use crate::driver::{Driver, Socket};
|
||||
use crate::guest::GuestConfig;
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
use crate::loopback_tests::{fast_limits, quick};
|
||||
use crate::{NetLink, manage};
|
||||
|
||||
fn session(name: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.live.install(Box::new(NetLink::new(Arc::new(|| {})).with_guest_config(quick)));
|
||||
s.execute("pref.userName", &json!({"name": name})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Pump the sessions (as their windows would every frame) until `f` holds.
|
||||
fn pump(all: &mut [&mut Session], what: &str, f: impl Fn(&[&mut Session]) -> bool) {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
for s in all.iter_mut() {
|
||||
s.live_poll();
|
||||
s.live_sync();
|
||||
}
|
||||
if f(all) {
|
||||
return;
|
||||
}
|
||||
let states: Vec<String> = all.iter().map(|s| s.live_summary().to_string()).collect();
|
||||
assert!(start.elapsed() < Duration::from_secs(15), "timed out waiting for {what}: {states:#?}");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
fn live(s: &Session) -> bool {
|
||||
s.live().status == Status::Live && s.live().syncing()
|
||||
}
|
||||
|
||||
fn layers(s: &Session) -> usize {
|
||||
s.doc().map_or(0, |d| d.node_count())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_share_is_joined_by_its_code_and_edits_sync() {
|
||||
let (mut host, mut guest) = (session("Hana"), session("Ada"));
|
||||
// This machine only, so the test needs no network; the code says where and the secret.
|
||||
let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local"})).unwrap();
|
||||
assert_eq!((&shared["status"], &shared["share"]["via"], &shared["share"]["access"]), (&json!("live"), &json!("local"), &json!("edit")));
|
||||
let shown = shared["code"].as_str().unwrap().to_string();
|
||||
assert_eq!(shown.len(), 23, "twenty symbols in fives: {shown}");
|
||||
let Code::Local(local) = code::parse(&shown).unwrap() else { panic!("not a local code") };
|
||||
assert_eq!(local.ip, Ipv4Addr::LOCALHOST);
|
||||
assert_eq!(shared["address"], format!("127.0.0.1:{}", local.port));
|
||||
assert_eq!(shared["link"], local.link(), "the code and the invite link name the same session");
|
||||
assert!(shared["share"]["viewCode"].is_null(), "everyone edits on the local network");
|
||||
// Typed the way people type: lower case, spaces for the dashes.
|
||||
let typed = shown.to_lowercase().replace('-', " ");
|
||||
assert_eq!(guest.execute("collab.join", &json!({"code": typed})).unwrap()["status"], "connecting");
|
||||
pump(&mut [&mut host, &mut guest], "the join", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
||||
assert_eq!(guest.live_summary()["access"], "edit");
|
||||
// Edits go both ways.
|
||||
let before = layers(&host);
|
||||
host.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
|
||||
pump(&mut [&mut host, &mut guest], "the host's edit", |s| layers(s[1]) == before + 1);
|
||||
guest.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap();
|
||||
pump(&mut [&mut host, &mut guest], "the guest's edit", |s| layers(s[0]) == before + 2);
|
||||
// The link joins the same session (an older app, or a paste).
|
||||
let mut third = session("Bo");
|
||||
third.execute("collab.join", &json!({"link": shared["link"]})).unwrap();
|
||||
pump(&mut [&mut host, &mut guest, &mut third], "the link's join", |s| live(s[2]) && layers(s[2]) == before + 2);
|
||||
// Stopping ends it: the code no longer joins.
|
||||
assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true);
|
||||
pump(&mut [&mut host, &mut guest, &mut third], "the end", |s| !s[1].live().status.active() && !s[2].live().status.active());
|
||||
let mut late = session("Cy");
|
||||
late.execute("collab.join", &json!({"code": shown})).unwrap();
|
||||
pump(&mut [&mut late], "the refusal", |s| matches!(s[0].live().status, Status::Failed { .. }));
|
||||
assert!(late.live().status.label().contains("Couldn't join"), "{:?}", late.live().status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() {
|
||||
let (mut host, mut guest) = (session("Hana"), session("Ada"));
|
||||
let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local"})).unwrap();
|
||||
let Code::Local(local) = code::parse(shared["code"].as_str().unwrap()).unwrap() else { panic!("not a local code") };
|
||||
// A code for the same address with another secret: well formed, refused by the host.
|
||||
let guess = Local::new(local.ip, local.port, local.secret ^ 1).code();
|
||||
guest.execute("collab.join", &json!({"code": guess})).unwrap();
|
||||
pump(&mut [&mut host, &mut guest], "the refusal", |s| matches!(s[1].live().status, Status::Failed { .. }));
|
||||
assert!(guest.live().status.label().contains("wrong session token"), "{:?}", guest.live().status);
|
||||
assert!(host.live().peers.is_empty());
|
||||
// A typing mistake is caught before anything connects.
|
||||
let mut typo: Vec<char> = local.code().chars().collect();
|
||||
typo[7] = if typo[7] == 'A' { 'B' } else { 'A' };
|
||||
let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::<String>()})).unwrap_err().to_string();
|
||||
assert!(e.contains("typing mistake"), "{e}");
|
||||
// A view-only code doesn't exist here, and says where it does.
|
||||
let e = host.execute("collab.share", &json!({"access": "view"})).unwrap_err().to_string();
|
||||
assert!(e.contains("share it online"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_can_name_a_join_code() {
|
||||
let (invite, found) = Invite::parse_join("figcraft://join?server=wss://share.example/&code=k7m-2qx-9td").unwrap();
|
||||
assert_eq!((invite.server.url(), invite.file.as_str(), invite.token.as_str()), ("wss://share.example:443/".to_string(), "", ""));
|
||||
assert_eq!(found.as_deref(), Some("K7M2QX9TD"));
|
||||
let (invite, found) = Invite::parse_join(" figcraft://join?host=127.0.0.1:47900&code=K7M2QX9TD ").unwrap();
|
||||
assert_eq!((invite.server, found.as_deref()), (Endpoint::plain("127.0.0.1:47900"), Some("K7M2QX9TD")));
|
||||
// A link with a file and a token is read as before.
|
||||
let link = "figcraft://join?host=127.0.0.1:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
|
||||
assert_eq!(Invite::parse_join(link).unwrap(), (Invite::parse(link).unwrap(), None));
|
||||
for bad in [
|
||||
"figcraft://join?server=wss://share.example/&code=",
|
||||
"figcraft://join?server=wss://share.example/&code=K7M",
|
||||
"figcraft://join?code=K7M2QX9TD",
|
||||
] {
|
||||
assert!(Invite::parse_join(bad).is_err(), "{bad}");
|
||||
}
|
||||
// Where a file's link is needed (team libraries), a code isn't one.
|
||||
let e = Invite::parse("figcraft://join?server=wss://share.example/&code=K7M2QX9TD").unwrap_err();
|
||||
assert!(e.contains("join code") && !e.contains("K7M"), "{e}");
|
||||
}
|
||||
|
||||
/// A server from before join codes: whatever it is asked first, it can't read it (what its
|
||||
/// decoder says about a message it doesn't know).
|
||||
fn old_server() -> (Endpoint, std::thread::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let at = Endpoint::plain(listener.local_addr().unwrap().to_string());
|
||||
let t = std::thread::spawn(move || {
|
||||
for stream in listener.incoming().take(2).flatten() {
|
||||
let Ok(mut ws) = tungstenite::accept(stream) else { continue };
|
||||
let _ = ws.read();
|
||||
let refused = ServerMsg::Refused { reason: "that message couldn't be read".into() };
|
||||
let _ = ws.send(Message::Binary(Bytes::from(message::encode(&refused).unwrap())));
|
||||
let _ = ws.close(None);
|
||||
while ws.read().is_ok() {}
|
||||
}
|
||||
});
|
||||
(at, t)
|
||||
}
|
||||
|
||||
/// A server from before join codes that manages a file's links as it always did: two connections,
|
||||
/// the first makes links, the second (asked for codes) can't read the request.
|
||||
fn old_server_with_links() -> (Endpoint, std::thread::JoinHandle<()>) {
|
||||
use figcraft_collab::message::{Admin, AdminReply, LinkInfo, NewLink};
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let at = Endpoint::plain(listener.local_addr().unwrap().to_string());
|
||||
let t = std::thread::spawn(move || {
|
||||
let mut made = 0;
|
||||
for stream in listener.incoming().take(2).flatten() {
|
||||
let Ok(mut ws) = tungstenite::accept(stream) else { continue };
|
||||
let say = |ws: &mut tungstenite::WebSocket<std::net::TcpStream>, m: ServerMsg| {
|
||||
let _ = ws.send(Message::Binary(Bytes::from(message::encode(&m).unwrap())));
|
||||
};
|
||||
while let Ok(Message::Binary(b)) = ws.read() {
|
||||
match message::decode::<ClientMsg>(&b, usize::MAX) {
|
||||
Ok(ClientMsg::OpenHosting(_)) => say(&mut ws, ServerMsg::Hosting(Box::default())),
|
||||
Ok(ClientMsg::Admin(Admin::CreateLink { file, access, label })) => {
|
||||
made += 1;
|
||||
let link = LinkInfo { id: format!("link{made}"), file, access, label, created: 0 };
|
||||
say(&mut ws, ServerMsg::Admin(Box::new(AdminReply::Link(NewLink { link, token: format!("TokenTokenTokenToken0{made}") }))));
|
||||
}
|
||||
_ => {
|
||||
say(&mut ws, ServerMsg::Refused { reason: "that message couldn't be read".into() });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = ws.close(None);
|
||||
while ws.read().is_ok() {}
|
||||
}
|
||||
});
|
||||
(at, t)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_older_than_join_codes_says_so_and_the_links_still_work() {
|
||||
let (server, thread) = old_server();
|
||||
let limits = fast_limits();
|
||||
// Asking it about a code: the join fails with what to do instead.
|
||||
let e = manage::resolve(&server, "K7M2QX9TD", &limits, Duration::from_secs(5)).unwrap_err();
|
||||
assert!(e.starts_with(manage::REFUSED) && e.contains("couldn't be read"), "{e}");
|
||||
let mut guest = session("Ada");
|
||||
guest.execute("pref.publishServer", &json!({"url": server.url()})).unwrap();
|
||||
guest.execute("collab.join", &json!({"code": "K7M-2QX-9TD"})).unwrap();
|
||||
pump(&mut [&mut guest], "the answer", |s| matches!(s[0].live().status, Status::Failed { .. }));
|
||||
let why = guest.live().status.label();
|
||||
assert!(why.contains("doesn't know join codes yet") && why.contains("invite link"), "{why}");
|
||||
thread.join().unwrap();
|
||||
// Sharing a file there: it makes the links (it always could) but no codes, without an error:
|
||||
// the app shows the links.
|
||||
let (server, thread) = old_server_with_links();
|
||||
let shared =
|
||||
manage::share(&server, None, "0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv", &[], true, &limits, Duration::from_secs(5)).unwrap().unwrap();
|
||||
assert!(shared.edit.code.is_none() && shared.view.as_ref().is_some_and(|v| v.code.is_none()));
|
||||
assert!(shared.edit.link.contains("&file=0123456789abcdef&token=TokenTokenTokenToken01") && shared.edit.id == "link1");
|
||||
thread.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_cant_be_reached_says_so() {
|
||||
// A port nothing listens on.
|
||||
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
let mut guest = session("Ada");
|
||||
guest.execute("pref.publishServer", &json!({"url": format!("ws://127.0.0.1:{port}/")})).unwrap();
|
||||
guest.execute("collab.join", &json!({"code": "K7M-2QX-9TD"})).unwrap();
|
||||
pump(&mut [&mut guest], "the failure", |s| matches!(s[0].live().status, Status::Failed { .. }));
|
||||
assert!(guest.live().status.label().starts_with("Couldn't reach the server"), "{:?}", guest.live().status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_app_hosted_session_knows_no_online_codes() {
|
||||
let mut host = session("Hana");
|
||||
let shared = host.execute("collab.host", &json!({"port": 0})).unwrap();
|
||||
let server = Endpoint::plain(shared["address"].as_str().unwrap());
|
||||
let e = manage::resolve(&server, "K7M2QX9TD", &fast_limits(), Duration::from_secs(5)).unwrap_err();
|
||||
assert!(e.contains("hosted by an app"), "{e}");
|
||||
// Junk in place of a code is refused the same way, and the session goes on.
|
||||
for junk in ["", "💥", &"Z".repeat(4000)] {
|
||||
assert!(manage::resolve(&server, junk, &fast_limits(), Duration::from_secs(5)).is_err());
|
||||
}
|
||||
assert_eq!(host.live_summary()["status"], "live");
|
||||
}
|
||||
|
||||
/// A socket that records what the driver does (the browser's, scripted).
|
||||
#[derive(Default)]
|
||||
struct Script {
|
||||
opened: Vec<String>,
|
||||
sent: Vec<ClientMsg>,
|
||||
closes: usize,
|
||||
}
|
||||
|
||||
impl Socket for Script {
|
||||
fn open(&mut self, url: &str) -> Result<(), String> {
|
||||
self.opened.push(url.to_string());
|
||||
Ok(())
|
||||
}
|
||||
fn send(&mut self, bytes: &[u8]) -> bool {
|
||||
self.sent.push(message::decode(bytes, usize::MAX).unwrap());
|
||||
true
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.closes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser's driver joining with a code at a server.
|
||||
fn driver(t0: web_time::Instant) -> (Driver<Script>, Inbox) {
|
||||
let (invite, found) = Invite::parse_join("figcraft://join?server=ws://127.0.0.1:47900/&code=K7M2QX9TD").unwrap();
|
||||
let inbox = Inbox::new(Arc::new(|| {}));
|
||||
let cfg = GuestConfig { code: found, ..GuestConfig::new(invite, "Ada".into()) };
|
||||
(Driver::start(Script::default(), cfg, inbox.clone(), t0), inbox)
|
||||
}
|
||||
|
||||
fn failure(inbox: &Inbox) -> String {
|
||||
inbox.drain().into_iter().find_map(|e| if let Event::Status(Status::Failed { reason }) = e { Some(reason) } else { None }).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_asks_about_the_code_then_joins_the_file() {
|
||||
let t0 = web_time::Instant::now();
|
||||
let (mut d, inbox) = driver(t0);
|
||||
d.opened(t0);
|
||||
let [ClientMsg::Resolve(ask)] = d.sock.sent.as_slice() else { panic!("asked {:?}", d.sock.sent) };
|
||||
assert_eq!((ask.protocol, ask.code.as_str()), (PROTOCOL, "K7M2QX9TD"));
|
||||
// The answer: a new connection to the same server, and a hello for the file.
|
||||
let answer = ServerMsg::Resolved { file: "0123456789abcdef".into(), token: "AbCdEfGhIjKlMnOpQrStUv".into() };
|
||||
d.message(&message::encode(&answer).unwrap(), t0);
|
||||
assert_eq!((d.sock.opened.len(), d.sock.closes), (2, 1));
|
||||
assert!(!d.done());
|
||||
d.opened(t0);
|
||||
let Some(ClientMsg::Hello(hello)) = d.sock.sent.last() else { panic!("sent {:?}", d.sock.sent) };
|
||||
assert_eq!((hello.file.as_str(), hello.token.as_str(), hello.name.as_str()), ("0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv", "Ada"));
|
||||
assert!(failure(&inbox).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_shows_why_a_code_didnt_join() {
|
||||
let t0 = web_time::Instant::now();
|
||||
// The server's own words for a code it doesn't know.
|
||||
let (mut d, inbox) = driver(t0);
|
||||
d.opened(t0);
|
||||
d.message(&message::encode(&ServerMsg::Refused { reason: "No session with that code".into() }).unwrap(), t0);
|
||||
assert!(d.done());
|
||||
assert_eq!(failure(&inbox), "No session with that code");
|
||||
// An older server.
|
||||
let (mut d, inbox) = driver(t0);
|
||||
d.opened(t0);
|
||||
d.message(&message::encode(&ServerMsg::Refused { reason: "that message couldn't be read".into() }).unwrap(), t0);
|
||||
assert!(failure(&inbox).contains("doesn't know join codes yet"));
|
||||
// Nobody answers: no retries while still asking.
|
||||
let (mut d, inbox) = driver(t0);
|
||||
d.tick(t0 + Duration::from_secs(30));
|
||||
assert!(d.done());
|
||||
assert!(failure(&inbox).starts_with("Couldn't reach the server"));
|
||||
// An answer that isn't a file.
|
||||
let (mut d, inbox) = driver(t0);
|
||||
d.opened(t0);
|
||||
d.message(&message::encode(&ServerMsg::Resolved { file: "../etc".into(), token: "short".into() }).unwrap(), t0);
|
||||
assert!(d.done());
|
||||
assert!(failure(&inbox).contains("isn't a file"));
|
||||
// Leaving while asking is just leaving.
|
||||
let (mut d, inbox) = driver(t0);
|
||||
d.leave(t0);
|
||||
assert!(d.done() && failure(&inbox).is_empty());
|
||||
}
|
||||
@@ -5,14 +5,18 @@
|
||||
//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping when quiet, a connection that goes silent counted as
|
||||
//! dropped, and reconnects with growing pauses after a live connection drops. The browser layer
|
||||
//! (`web.rs`) feeds it the WebSocket's events and wakes it at [`Driver::deadline`].
|
||||
//!
|
||||
//! A join by code ([`GuestConfig::code`]) first asks the server which file the code opens on a
|
||||
//! connection of its own, then joins that file as with a link; a server that doesn't know the
|
||||
//! code (or can't be reached) ends the join with the reason.
|
||||
|
||||
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
|
||||
use figcraft_collab::message::{self, ClientMsg, PROTOCOL, Presence, Resolve, ServerMsg};
|
||||
use figcraft_engine::live::{Event, Status};
|
||||
// `std::time::Instant` on native builds; the browser's clock in the web build.
|
||||
use web_time::Instant;
|
||||
|
||||
use crate::inbox::Inbox;
|
||||
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, take};
|
||||
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
|
||||
/// A WebSocket the driver opens, writes and closes; what it receives comes back through the
|
||||
@@ -93,12 +97,15 @@ impl<S: Socket> Driver<S> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The connection is open: say hello.
|
||||
/// The connection is open: say hello (or ask about the join code first).
|
||||
pub(crate) fn opened(&mut self, now: Instant) {
|
||||
let Some(c) = self.conn.as_mut() else { return };
|
||||
c.opened = true;
|
||||
c.last_heard = now;
|
||||
let hello = hello(&self.cfg, &self.resume);
|
||||
let hello = match &self.cfg.code {
|
||||
Some(code) => ClientMsg::Resolve(Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.clone() }),
|
||||
None => hello(&self.cfg, &self.resume),
|
||||
};
|
||||
if !self.write(&hello, now) {
|
||||
self.end(Outcome::Retry("the connection dropped".into()), now);
|
||||
}
|
||||
@@ -109,6 +116,16 @@ impl<S: Socket> Driver<S> {
|
||||
let Some(c) = self.conn.as_mut() else { return };
|
||||
c.last_heard = now;
|
||||
let taken = match message::decode::<ServerMsg>(bytes, self.cfg.limits.max_server_message) {
|
||||
// The join code's answer: join the file it opens, on a new connection.
|
||||
Ok(ServerMsg::Resolved { file, token }) if self.cfg.code.is_some() => {
|
||||
return match resolved(&mut self.cfg, file, token) {
|
||||
Ok(()) => {
|
||||
self.sock.close();
|
||||
self.connect(now);
|
||||
}
|
||||
Err(o) => self.end(o, now),
|
||||
};
|
||||
}
|
||||
// Single-threaded: the connection counts as welcomed below, before the engine polls.
|
||||
Ok(m) => take(m, &self.cfg, &self.inbox, &mut c.seen, &self.resume, &|| {}),
|
||||
Err(e) => Taken::End(Outcome::Refused(format!("the host sent a message this version can't read ({e})"))),
|
||||
@@ -233,6 +250,8 @@ impl<S: Socket> Driver<S> {
|
||||
|
||||
/// The connection ended: close it, then stop or plan the reconnect.
|
||||
fn end(&mut self, outcome: Outcome, now: Instant) {
|
||||
// Still asking about the join code: whatever went wrong is final.
|
||||
let outcome = if self.cfg.code.is_some() { unresolved(&self.cfg, outcome) } else { outcome };
|
||||
if self.conn.take().is_some() {
|
||||
self.sock.close();
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ use tungstenite::{Bytes, Message};
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
pub use crate::join::GuestConfig;
|
||||
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, take};
|
||||
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
|
||||
use crate::manage;
|
||||
use crate::net::{Duplex, NetLimits, config, open, shutdown};
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
@@ -39,6 +39,8 @@ pub struct Publish {
|
||||
/// The file's name on the server.
|
||||
pub name: String,
|
||||
pub doc: Arc<Document>,
|
||||
/// Share the file once it is there: links and join codes for others ([`Event::Shared`]).
|
||||
pub share: bool,
|
||||
}
|
||||
|
||||
/// A joined (or joining) session. Dropping it leaves.
|
||||
@@ -60,10 +62,27 @@ enum Cmd {
|
||||
|
||||
impl Guest {
|
||||
/// Start joining (returns at once; progress arrives in `inbox`).
|
||||
pub fn start(cfg: GuestConfig, inbox: Inbox) -> io::Result<Guest> {
|
||||
pub fn start(mut cfg: GuestConfig, inbox: Inbox) -> io::Result<Guest> {
|
||||
let (tx, rx) = channel();
|
||||
let me = tx.clone();
|
||||
let thread = std::thread::Builder::new().name("live-guest".into()).spawn(move || supervise(&cfg, &inbox, &rx, &me))?;
|
||||
let thread = std::thread::Builder::new().name("live-guest".into()).spawn(move || {
|
||||
// A join code: the server says which file it opens, then the join goes on as with a link.
|
||||
if let Some(code) = cfg.code.clone() {
|
||||
inbox.push(Event::Status(Status::Connecting));
|
||||
let asked =
|
||||
manage::resolve(&cfg.invite.server, &code, &cfg.limits, cfg.connect_timeout).map_err(|e| match e.strip_prefix(manage::REFUSED) {
|
||||
Some(why) => Outcome::Refused(why.to_string()),
|
||||
None => Outcome::Retry(e),
|
||||
});
|
||||
if let Err(o) = asked.and_then(|(file, token)| resolved(&mut cfg, file, token)) {
|
||||
if let Next::Stop(Some(e)) = after(unresolved(&cfg, o), &cfg, false, &mut 0, &inbox) {
|
||||
inbox.push(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
supervise(&cfg, &inbox, &rx, &me);
|
||||
})?;
|
||||
Ok(Guest { cmd: tx, thread: Some(thread) })
|
||||
}
|
||||
|
||||
@@ -79,10 +98,21 @@ impl Guest {
|
||||
Ok(done) => done,
|
||||
Err(e) => return inbox.push(Event::Status(Status::Failed { reason: format!("Couldn't publish to the server: {e}") })),
|
||||
};
|
||||
// Before anyone hears the file is there, so what others join with shows with it. The
|
||||
// file's first links stay its owner's (this app joins with one): others get their own,
|
||||
// which stopping takes away again.
|
||||
let shared = job.share.then(|| {
|
||||
let owner = published.owner.as_deref().unwrap_or_default();
|
||||
let made = manage::share(&job.server, job.key.as_deref(), &published.file, owner, &[], true, &cfg.limits, cfg.connect_timeout);
|
||||
made.and_then(|s| s.ok_or_else(|| "the server didn't make the links".to_string()))
|
||||
});
|
||||
if let Some(h) = hosting {
|
||||
inbox.push(Event::Hosting(h));
|
||||
}
|
||||
inbox.push(Event::Published(published));
|
||||
if let Some(shared) = shared {
|
||||
inbox.push(Event::Shared(shared));
|
||||
}
|
||||
// Left while uploading: the file stays on the server, nobody joins it. Anything else
|
||||
// that came meanwhile (presence) waits for the session.
|
||||
let mut waiting = Vec::new();
|
||||
@@ -191,7 +221,7 @@ fn connect(cfg: &GuestConfig) -> Result<Conn, String> {
|
||||
}
|
||||
|
||||
fn supervise(cfg: &GuestConfig, inbox: &Inbox, rx: &Receiver<Cmd>, me: &Sender<Cmd>) {
|
||||
let resume: Arc<Resuming> = Arc::default();
|
||||
let resume: Arc<Resuming> = Arc::new(std::sync::Mutex::new(cfg.resume));
|
||||
let mut attempt = 0u32;
|
||||
let mut was_live = false;
|
||||
let mut pending: Option<Presence> = None;
|
||||
|
||||
@@ -11,6 +11,7 @@ use figcraft_collab::{Outgoing, Server, same_secret};
|
||||
use figcraft_doc::Document;
|
||||
use figcraft_engine::live::{Event, PEOPLE};
|
||||
|
||||
pub use crate::hub::REMOVED;
|
||||
use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name, to_local};
|
||||
use crate::inbox::Inbox;
|
||||
pub use crate::join::ENDED;
|
||||
@@ -129,6 +130,12 @@ impl Host {
|
||||
self.hosting.send(msg);
|
||||
}
|
||||
|
||||
/// Remove client `id` from the session: they are disconnected and can't join again from
|
||||
/// their address while it runs. The host can't remove themselves.
|
||||
pub fn remove(&self, id: u32) {
|
||||
self.hosting.remove(id);
|
||||
}
|
||||
|
||||
/// Cut every connection as a network failure would (no close frame).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn cut_connections(&self) {
|
||||
|
||||
+142
-15
@@ -21,7 +21,7 @@
|
||||
//! backend may refuse addresses or limit what they do. A plain `GET /healthz` on the same port is
|
||||
//! answered `200 ok` for uptime checks.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::net::{IpAddr, Ipv6Addr, SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
@@ -32,7 +32,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::message::{
|
||||
self, Access, Admin, AdminReply, ClientMsg, Hello, HostInfo, LibraryReply, LibraryReq, Manage, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL,
|
||||
PluginReply, PluginReq, Presence, ServerMsg, reads_hosting,
|
||||
PluginReply, PluginReq, Presence, Resolve, ServerMsg, reads_hosting,
|
||||
};
|
||||
use figcraft_collab::{Outgoing, Server};
|
||||
use figcraft_engine::live::{Event, MAX_NAME, PEOPLE, sanitize};
|
||||
@@ -205,6 +205,12 @@ pub trait Backend: Send + 'static {
|
||||
Err("this session is hosted by an app: tell the person hosting it".into())
|
||||
}
|
||||
|
||||
/// A join code typed by someone at `ip`: the file it opens and that link's token, or why not
|
||||
/// (shown to them; wrong codes are counted against the address).
|
||||
fn resolve(&mut self, _ip: IpAddr, _code: &str) -> Result<(String, String), String> {
|
||||
Err("this session is hosted by an app: join it with its own code or invite link".into())
|
||||
}
|
||||
|
||||
/// What everyone is told when the hub stops.
|
||||
fn closing(&self) -> Closing;
|
||||
}
|
||||
@@ -386,8 +392,8 @@ impl InFlight {
|
||||
}
|
||||
|
||||
enum HubIn {
|
||||
/// A connection's first message (`Hello`, `Manage`, `OpenLibrary`, `OpenPlugins` or
|
||||
/// `OpenHosting`) and its client's address ([`ip_key`]).
|
||||
/// A connection's first message (`Hello`, `Manage`, `OpenLibrary`, `OpenPlugins`,
|
||||
/// `OpenHosting` or `Resolve`) and its client's address ([`ip_key`]).
|
||||
Open {
|
||||
conn: u64,
|
||||
ip: IpAddr,
|
||||
@@ -407,6 +413,8 @@ enum HubIn {
|
||||
},
|
||||
Local(Presence),
|
||||
LocalMsg(ClientMsg),
|
||||
/// The person hosting removes client `id` from their file (app sessions).
|
||||
Remove(u32),
|
||||
/// Cut every connection without a word (tests: a network failure).
|
||||
#[cfg(test)]
|
||||
Cut,
|
||||
@@ -455,7 +463,15 @@ impl Hosting {
|
||||
pub(crate) fn start<B: Backend>(listener: TcpListener, backend: B, limits: NetLimits, local: Option<Local>) -> io::Result<Hosting> {
|
||||
let addr = listener.local_addr()?;
|
||||
let in_flight = InFlight(Arc::default());
|
||||
let mut hub = Hub { backend, rooms: HashMap::new(), conns: HashMap::new(), local: None, outbox: Vec::new(), in_flight: in_flight.clone() };
|
||||
let mut hub = Hub {
|
||||
backend,
|
||||
rooms: HashMap::new(),
|
||||
conns: HashMap::new(),
|
||||
local: None,
|
||||
outbox: Vec::new(),
|
||||
in_flight: in_flight.clone(),
|
||||
barred: HashMap::new(),
|
||||
};
|
||||
if let Some(l) = local {
|
||||
let mut room = Room::new(l.server);
|
||||
room.local = Some((l.id, l.inbox));
|
||||
@@ -545,6 +561,12 @@ impl Hosting {
|
||||
let _ = self.hub.send(HubIn::LocalMsg(msg));
|
||||
}
|
||||
|
||||
/// The hosting person removes client `id` from their file (app sessions; see
|
||||
/// [`HubIn::Remove`]).
|
||||
pub(crate) fn remove(&self, id: u32) {
|
||||
let _ = self.hub.send(HubIn::Remove(id));
|
||||
}
|
||||
|
||||
/// Cut every connection as a network failure would (no close frame).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn cut_connections(&self) {
|
||||
@@ -907,7 +929,8 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
|
||||
| ClientMsg::Manage(_)
|
||||
| ClientMsg::OpenLibrary(_)
|
||||
| ClientMsg::OpenPlugins(_)
|
||||
| ClientMsg::OpenHosting(_)),
|
||||
| ClientMsg::OpenHosting(_)
|
||||
| ClientMsg::Resolve(_)),
|
||||
bytes,
|
||||
))) => {
|
||||
// Sessions and the operator may use the last connections; one-shot requests
|
||||
@@ -1097,6 +1120,20 @@ fn unsupported(protocol: u32) -> String {
|
||||
format!("protocol {protocol} isn't supported (this server speaks {PROTOCOL})")
|
||||
}
|
||||
|
||||
/// What someone in a file is told when the link they joined with is revoked (its owner stopped
|
||||
/// sharing): the session's end for them.
|
||||
pub const STOPPED: &str = "Sharing was stopped: the link you joined with no longer works. Your copy stays open";
|
||||
|
||||
/// What someone removed from a file is told: the session's end for them (they keep their copy;
|
||||
/// nothing retries).
|
||||
pub const REMOVED: &str = "You were removed from this session. Your copy stays open";
|
||||
|
||||
/// Why a removed person's address can't join the file again.
|
||||
const BARRED: &str = "you were removed from this session";
|
||||
|
||||
/// The most addresses one file keeps barred.
|
||||
const MAX_BARRED: usize = 256;
|
||||
|
||||
/// What everyone in a file is told when its changes couldn't be saved (they reconnect).
|
||||
const UNSAVED: &str = "The server couldn't save the latest changes; reconnecting";
|
||||
|
||||
@@ -1110,6 +1147,9 @@ struct Hub<B> {
|
||||
local: Option<String>,
|
||||
/// What the servers said, sent once the backend made what changed durable.
|
||||
outbox: Vec<(String, Outgoing)>,
|
||||
/// Addresses ([`ip_key`]) removed from each open file: they can't join it again until its
|
||||
/// session closes (the file is closed or removed; the entry goes with it).
|
||||
barred: HashMap<String, HashSet<IpAddr>>,
|
||||
}
|
||||
|
||||
impl<B: Backend> Hub<B> {
|
||||
@@ -1171,6 +1211,7 @@ impl<B: Backend> Hub<B> {
|
||||
ClientMsg::OpenLibrary(o) => self.open_library(conn, o, c),
|
||||
ClientMsg::OpenPlugins(o) => self.open_plugins(conn, &o, c),
|
||||
ClientMsg::OpenHosting(o) => self.open_hosting(conn, &o, c),
|
||||
ClientMsg::Resolve(r) => self.resolve(&r, &c),
|
||||
_ => refuse(&c.out, "say hello first"),
|
||||
}
|
||||
}
|
||||
@@ -1196,6 +1237,13 @@ impl<B: Backend> Hub<B> {
|
||||
self.client_message(&file, id, msg, None);
|
||||
}
|
||||
}
|
||||
HubIn::Remove(id) => {
|
||||
if let Some(file) = self.local.clone()
|
||||
&& let Err(why) = self.remove_peer(&file, id)
|
||||
{
|
||||
log::info!("live session: couldn't remove client {id}: {why}");
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
HubIn::Cut => {
|
||||
for c in self.conns.values() {
|
||||
@@ -1220,6 +1268,14 @@ impl<B: Backend> Hub<B> {
|
||||
self.client_message(&file, id, msg, Some(bytes));
|
||||
}
|
||||
Some(Kind::Hosting { owns }) => match msg {
|
||||
ClientMsg::Admin(Admin::RemovePeer { file, client }) => {
|
||||
let reply = if owns.as_deref() == Some(file.as_str()) {
|
||||
self.removed_reply(&file, client)
|
||||
} else {
|
||||
AdminReply::Failed { reason: "an owner token manages only its own file".into() }
|
||||
};
|
||||
self.answer(conn, &ServerMsg::Admin(Box::new(reply)));
|
||||
}
|
||||
ClientMsg::Admin(req) => {
|
||||
let owns = owns.clone();
|
||||
let rooms = &self.rooms;
|
||||
@@ -1231,6 +1287,10 @@ impl<B: Backend> Hub<B> {
|
||||
_ => self.kick(&[conn], "only publishing and managing your own files are spoken on this connection"),
|
||||
},
|
||||
Some(Kind::Managing) => match msg {
|
||||
ClientMsg::Admin(Admin::RemovePeer { file, client }) => {
|
||||
let reply = self.removed_reply(&file, client);
|
||||
self.answer(conn, &ServerMsg::Admin(Box::new(reply)));
|
||||
}
|
||||
ClientMsg::Admin(req) => self.admin(conn, req),
|
||||
ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong),
|
||||
_ => self.kick(&[conn], "only requests to manage files are spoken on this connection"),
|
||||
@@ -1293,6 +1353,10 @@ impl<B: Backend> Hub<B> {
|
||||
Ok(a) => a,
|
||||
Err(why) => return refuse(&c.out, &why),
|
||||
};
|
||||
if self.barred.get(&hello.file).is_some_and(|b| b.contains(&c.ip)) {
|
||||
log::info!("event=refused ip={} reason=removed", c.ip);
|
||||
return refuse(&c.out, BARRED);
|
||||
}
|
||||
let hosting = reads_hosting(&hello.client_version);
|
||||
// The backend checked the secret: the sessions don't keep it.
|
||||
hello.token.clear();
|
||||
@@ -1350,6 +1414,34 @@ impl<B: Backend> Hub<B> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove client `id` from `file`: they are told the session is over for them and
|
||||
/// disconnected, and their address can't join the file again while its session lasts. The
|
||||
/// person hosting can't be removed.
|
||||
fn remove_peer(&mut self, file: &str, id: u32) -> Result<(), String> {
|
||||
let room = self.rooms.get(file).ok_or("nobody is in this file")?;
|
||||
if room.local.as_ref().is_some_and(|(local, _)| *local == id) {
|
||||
return Err("the person hosting can't be removed".into());
|
||||
}
|
||||
let conn = *room.ids.get(&id).ok_or_else(|| format!("nobody with client id {id} is in this file"))?;
|
||||
let c = self.conns.get(&conn).ok_or_else(|| format!("nobody with client id {id} is in this file"))?;
|
||||
let barred = self.barred.entry(file.to_string()).or_default();
|
||||
if barred.len() < MAX_BARRED {
|
||||
barred.insert(c.ip);
|
||||
}
|
||||
log::info!("event=removed file={file} client={id} ip={}", c.ip);
|
||||
let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into()));
|
||||
self.gone(conn);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`remove_peer`](Self::remove_peer) as a managing connection's answer.
|
||||
fn removed_reply(&mut self, file: &str, id: u32) -> AdminReply {
|
||||
match self.remove_peer(file, id) {
|
||||
Ok(()) => AdminReply::Done,
|
||||
Err(reason) => AdminReply::Failed { reason },
|
||||
}
|
||||
}
|
||||
|
||||
/// Take in connection `c` as `kind` and send it `first`.
|
||||
fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) {
|
||||
self.conns.insert(conn, Conn { out: c.out, sock: c.sock, kind, ip: c.ip });
|
||||
@@ -1408,6 +1500,26 @@ impl<B: Backend> Hub<B> {
|
||||
self.take_in(conn, c, Kind::Hosting { owns }, &ServerMsg::Hosting(Box::new(info)));
|
||||
}
|
||||
|
||||
/// A join code: the file it opens and the token to join it with, then the connection closes
|
||||
/// (it was never taken in).
|
||||
fn resolve(&mut self, r: &Resolve, c: &Pending) {
|
||||
if r.protocol != PROTOCOL {
|
||||
return refuse(&c.out, &unsupported(r.protocol));
|
||||
}
|
||||
match self.backend.resolve(c.ip, &r.code) {
|
||||
Ok((file, token)) => {
|
||||
if let Some(b) = encode(&ServerMsg::Resolved { file, token }) {
|
||||
let _ = c.out.tx.try_send(Out::Bytes(b, None));
|
||||
}
|
||||
let _ = c.out.tx.try_send(Out::Close(CloseCode::Normal, String::new()));
|
||||
}
|
||||
Err(why) => {
|
||||
log::info!("event=code_refused ip={} reason={why:?}", c.ip);
|
||||
refuse(&c.out, &why);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn admin(&mut self, conn: u64, req: Admin) {
|
||||
let rooms = &self.rooms;
|
||||
let open = |file: &str| rooms.get(file).map(Room::info);
|
||||
@@ -1419,15 +1531,24 @@ impl<B: Backend> Hub<B> {
|
||||
fn handled(&mut self, conn: u64, handled: Handled) {
|
||||
self.answer(conn, &ServerMsg::Admin(Box::new(handled.reply)));
|
||||
if let Some((file, link)) = handled.revoked {
|
||||
let users: Vec<u64> = self
|
||||
.conns
|
||||
.iter()
|
||||
.filter(|(_, c)| {
|
||||
matches!(&c.kind, Kind::Joined { file: f, link: Some(l), .. } | Kind::Library { file: f, link: Some(l), .. } if *f == file && *l == link)
|
||||
})
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
self.kick(&users, "the link you joined with was revoked");
|
||||
let with = |joined: bool| -> Vec<u64> {
|
||||
let uses = |c: &Conn| match &c.kind {
|
||||
Kind::Joined { file: f, link: Some(l), .. } => joined && *f == file && *l == link,
|
||||
Kind::Library { file: f, link: Some(l), .. } => !joined && *f == file && *l == link,
|
||||
_ => false,
|
||||
};
|
||||
self.conns.iter().filter(|(_, c)| uses(c)).map(|(k, _)| *k).collect()
|
||||
};
|
||||
let (people, libraries) = (with(true), with(false));
|
||||
// People in the file are told the session is over for them, as when a host stops (they
|
||||
// keep their copy; nothing retries); a library request is refused.
|
||||
for conn in &people {
|
||||
if let Some(c) = self.conns.get(conn) {
|
||||
let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, STOPPED.into()));
|
||||
}
|
||||
self.gone(*conn);
|
||||
}
|
||||
self.kick(&libraries, "the link you joined with was revoked");
|
||||
}
|
||||
if let Some(file) = handled.removed {
|
||||
let users: Vec<u64> = self
|
||||
@@ -1438,6 +1559,7 @@ impl<B: Backend> Hub<B> {
|
||||
.collect();
|
||||
// Closed without saving: the file is gone.
|
||||
self.rooms.remove(&file);
|
||||
self.barred.remove(&file);
|
||||
self.kick(&users, "this file was removed from the server");
|
||||
}
|
||||
if let Some(ip) = handled.blocked {
|
||||
@@ -1515,6 +1637,7 @@ impl<B: Backend> Hub<B> {
|
||||
let Some(room) = self.rooms.get_mut(file).filter(|r| r.empty()) else { return };
|
||||
if self.backend.idle(file, &mut room.server) {
|
||||
self.rooms.remove(file);
|
||||
self.barred.remove(file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1620,3 +1743,7 @@ pub(crate) fn to_local(inbox: &Inbox, local: u32, msg: &ServerMsg) {
|
||||
#[cfg(test)]
|
||||
#[path = "queue_tests.rs"]
|
||||
mod queue_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "barred_tests.rs"]
|
||||
mod barred_tests;
|
||||
@@ -10,6 +10,10 @@
|
||||
//! connects to the endpoint and sends the token in its `Hello`. A server address for managing
|
||||
//! files carries the server's key the same way (`ws://host:port/?key=<key>`). Errors never echo a
|
||||
//! link or an address.
|
||||
//!
|
||||
//! A join by code names the server to ask and the code:
|
||||
//! `figcraft://join?server=<ws(s)://…>&code=<code>` ([`Invite::parse_join`]); the server says which
|
||||
//! file and token the code stands for.
|
||||
|
||||
/// Where a session or a server listens: `ws://host:port/path` or `wss://…` (TLS).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -134,6 +138,15 @@ impl Invite {
|
||||
|
||||
/// Read an invite link (any form; surrounding spaces, quotes or angle brackets are fine).
|
||||
pub fn parse(link: &str) -> Result<Invite, String> {
|
||||
match Self::parse_join(link)? {
|
||||
(invite, None) => Ok(invite),
|
||||
(_, Some(_)) => Err("that names a join code, not a file: join with it first".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read what a join starts from: an invite link, or a server with a join code to ask it about
|
||||
/// (then the invite names only the server, until the server says what the code opens).
|
||||
pub fn parse_join(link: &str) -> Result<(Invite, Option<String>), String> {
|
||||
let link = clean(link);
|
||||
if link.len() > MAX_LINK {
|
||||
return Err(NOT.into());
|
||||
@@ -158,9 +171,14 @@ impl Invite {
|
||||
return Err(NOT.into());
|
||||
};
|
||||
let get = |k: &str| query.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone());
|
||||
if let Some(code) = get("code") {
|
||||
let code = figcraft_collab::code::normalize(&code).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN);
|
||||
let code = code.ok_or("the link's join code isn't one")?;
|
||||
return Ok((Invite { server, file: String::new(), token: String::new() }, Some(code)));
|
||||
}
|
||||
let file = get("file").filter(|f| valid_id(f, 1, 64)).ok_or("the invite link has no valid file id")?;
|
||||
let token = get("token").filter(|t| valid_id(t, 16, 128)).ok_or("the invite link has no valid token")?;
|
||||
Ok(Invite { server, file, token })
|
||||
Ok((Invite { server, file, token }, None))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ pub(crate) const CLOSE_RESTART: u16 = 1012;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GuestConfig {
|
||||
pub invite: Invite,
|
||||
/// A join code to ask `invite.server` about first: its answer fills in the invite's file and
|
||||
/// token ([`resolved`]).
|
||||
pub code: Option<String>,
|
||||
/// Your name (empty: the host picks one).
|
||||
pub name: String,
|
||||
pub limits: NetLimits,
|
||||
@@ -38,18 +41,23 @@ pub struct GuestConfig {
|
||||
pub max_backoff: Duration,
|
||||
/// Reconnect attempts before giving up.
|
||||
pub attempts: u32,
|
||||
/// Join as the client you were (an app that starts over after a crash): the first hello
|
||||
/// carries it, and a server that still knows the id gives it back.
|
||||
pub resume: Option<Resume>,
|
||||
}
|
||||
|
||||
impl GuestConfig {
|
||||
pub fn new(invite: Invite, name: String) -> Self {
|
||||
Self {
|
||||
invite,
|
||||
code: None,
|
||||
name,
|
||||
limits: NetLimits::default(),
|
||||
connect_timeout: Duration::from_secs(5),
|
||||
backoff: Duration::from_millis(500),
|
||||
max_backoff: Duration::from_secs(10),
|
||||
attempts: 12,
|
||||
resume: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,10 +75,36 @@ pub(crate) enum Outcome {
|
||||
Refused(String),
|
||||
/// The host ended the session: final.
|
||||
Ended(String),
|
||||
/// It can't go on, for this reason as it is shown: final.
|
||||
Failed(String),
|
||||
/// You left.
|
||||
Left,
|
||||
}
|
||||
|
||||
/// The server couldn't be asked about a join code, or said no: final, in words for the person who
|
||||
/// typed it (the server's own for a code it doesn't know).
|
||||
pub(crate) fn unresolved(cfg: &GuestConfig, outcome: Outcome) -> Outcome {
|
||||
Outcome::Failed(match outcome {
|
||||
// A server from before join codes can't read the question.
|
||||
Outcome::Refused(why) if why.contains("couldn't be read") => {
|
||||
format!("{} doesn't know join codes yet (it runs an older version): ask for the invite link instead", cfg.invite.server.display())
|
||||
}
|
||||
Outcome::Refused(why) | Outcome::Failed(why) | Outcome::Ended(why) => why,
|
||||
Outcome::Retry(why) => format!("Couldn't reach the server ({why})"),
|
||||
Outcome::Left => return Outcome::Left,
|
||||
})
|
||||
}
|
||||
|
||||
/// The server's answer to the join code: the invite is whole now.
|
||||
pub(crate) fn resolved(cfg: &mut GuestConfig, file: String, token: String) -> Result<(), Outcome> {
|
||||
if !crate::invite::valid_id(&file, 1, 64) || !crate::invite::valid_id(&token, 16, 128) {
|
||||
return Err(Outcome::Failed("the server answered the join code with something that isn't a file".into()));
|
||||
}
|
||||
cfg.code = None;
|
||||
(cfg.invite.file, cfg.invite.token) = (file, token);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a close frame from the host means.
|
||||
pub(crate) fn closed(code: u16, reason: &str) -> Outcome {
|
||||
match code {
|
||||
@@ -97,6 +131,7 @@ pub(crate) fn after(outcome: Outcome, cfg: &GuestConfig, was_live: bool, attempt
|
||||
match outcome {
|
||||
Outcome::Left => Next::Stop(None),
|
||||
Outcome::Refused(why) => failed(format!("The host refused: {why}")),
|
||||
Outcome::Failed(why) => failed(why),
|
||||
Outcome::Ended(reason) => Next::Stop(Some(Event::Ended { reason })),
|
||||
Outcome::Retry(why) if !was_live => failed(format!("Couldn't join the live session: {why}")),
|
||||
Outcome::Retry(why) => {
|
||||
@@ -211,8 +246,8 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se
|
||||
ServerMsg::Signal { client_id, signal } => inbox.push(Event::Signal(client_id, signal)),
|
||||
ServerMsg::Hosting(info) => inbox.push(Event::Hosting(*info)),
|
||||
ServerMsg::Reported { error } => inbox.push(Event::Reported(error)),
|
||||
// Answers for managing and library connections: not this one's.
|
||||
ServerMsg::Pong | ServerMsg::Admin(_) | ServerMsg::Library(_) | ServerMsg::Plugins(_) => {}
|
||||
// Answers for managing and library connections, and to a join code: not this one's.
|
||||
ServerMsg::Pong | ServerMsg::Admin(_) | ServerMsg::Library(_) | ServerMsg::Plugins(_) | ServerMsg::Resolved { .. } => {}
|
||||
}
|
||||
Taken::Continue
|
||||
}
|
||||
@@ -19,6 +19,7 @@ pub mod inbox;
|
||||
pub mod invite;
|
||||
mod join;
|
||||
pub mod limits;
|
||||
mod sessions;
|
||||
pub mod throttle;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -51,6 +52,8 @@ pub use link::{DEFAULT_PORT, NetLink, lan_ip};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web::NetLink;
|
||||
|
||||
#[cfg(test)]
|
||||
mod code_tests;
|
||||
#[cfg(test)]
|
||||
mod driver_tests;
|
||||
#[cfg(test)]
|
||||
@@ -62,4 +65,6 @@ mod loopback_tests;
|
||||
#[cfg(test)]
|
||||
mod session_tests;
|
||||
#[cfg(test)]
|
||||
mod sessions_tests;
|
||||
#[cfg(test)]
|
||||
mod tls_tests;
|
||||
@@ -5,9 +5,11 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::code;
|
||||
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq};
|
||||
use figcraft_engine::live::{
|
||||
Bind, Event, HostInfo, HostOptions, LiveLink, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Role, SessionInfo,
|
||||
Bind, Event, HostInfo, HostOptions, LiveLink, NO_SESSION, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Resume, Role,
|
||||
SessionId, SessionInfo, Shared,
|
||||
};
|
||||
|
||||
use crate::guest::{Guest, GuestConfig, Publish, upload_file};
|
||||
@@ -16,6 +18,7 @@ use crate::inbox::{Inbox, Waker};
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
use crate::manage;
|
||||
use crate::net::NetLimits;
|
||||
use crate::sessions::Sessions;
|
||||
use crate::token;
|
||||
|
||||
/// The port a hosted session tries first.
|
||||
@@ -29,12 +32,20 @@ enum Active {
|
||||
Guest(Guest),
|
||||
}
|
||||
|
||||
/// Hosts or joins one session at a time.
|
||||
impl Active {
|
||||
/// Stop hosting, or leave.
|
||||
fn end(self) {
|
||||
match self {
|
||||
Active::Host(mut h) => h.stop(),
|
||||
Active::Guest(mut g) => g.leave(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hosts and joins sessions, any number at once (one per open document).
|
||||
pub struct NetLink {
|
||||
wake: Waker,
|
||||
limits: NetLimits,
|
||||
active: Option<Active>,
|
||||
inbox: Option<Inbox>,
|
||||
sessions: Sessions<Active>,
|
||||
/// Tweaks for joining (tests shorten the reconnect pauses).
|
||||
guest: fn(GuestConfig) -> GuestConfig,
|
||||
/// Answers to requests sent in the background (outlives sessions).
|
||||
@@ -47,7 +58,7 @@ impl NetLink {
|
||||
/// `wake` is called (from network threads) whenever something arrives for the UI.
|
||||
pub fn new(wake: Waker) -> Self {
|
||||
let libraries = Inbox::new(wake.clone());
|
||||
Self { wake, limits: NetLimits::default(), active: None, inbox: None, guest: |c| c, libraries, fetching: Arc::default() }
|
||||
Self { limits: NetLimits::default(), sessions: Sessions::new(wake), guest: |c| c, libraries, fetching: Arc::default() }
|
||||
}
|
||||
|
||||
/// With other transport limits.
|
||||
@@ -62,20 +73,27 @@ impl NetLink {
|
||||
self
|
||||
}
|
||||
|
||||
/// How this link joins `invite`.
|
||||
fn guest_config(&self, invite: Invite, name: &str) -> GuestConfig {
|
||||
let mut cfg = (self.guest)(GuestConfig::new(invite, name.to_string()));
|
||||
/// How this link joins `invite` (after asking its server about `code`, if there is one).
|
||||
fn guest_config(&self, invite: Invite, code: Option<String>, name: &str) -> GuestConfig {
|
||||
let mut cfg = (self.guest)(GuestConfig { code, ..GuestConfig::new(invite, name.to_string()) });
|
||||
cfg.limits = self.limits;
|
||||
cfg
|
||||
}
|
||||
|
||||
/// The running host, if hosting.
|
||||
pub fn host_handle(&self) -> Option<&Host> {
|
||||
match &self.active {
|
||||
/// The running host of session `session`, if it is hosted here.
|
||||
pub fn host_handle(&self, session: SessionId) -> Option<&Host> {
|
||||
match self.sessions.get(session) {
|
||||
Some(Active::Host(h)) => Some(h),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Session `session` started (one that had the id ends).
|
||||
fn started(&mut self, session: SessionId, active: Active, inbox: Inbox) {
|
||||
if let Some(old) = self.sessions.insert(session, active, inbox) {
|
||||
old.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This machine's address on the local network: the interface the default route leaves by (no
|
||||
@@ -96,15 +114,20 @@ pub fn lan_ip() -> Option<IpAddr> {
|
||||
}
|
||||
|
||||
impl LiveLink for NetLink {
|
||||
fn host(&mut self, o: HostOptions) -> Result<SessionInfo, String> {
|
||||
self.leave();
|
||||
fn host(&mut self, session: SessionId, o: HostOptions) -> Result<SessionInfo, String> {
|
||||
let ip = match o.bind {
|
||||
Bind::Local => IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
Bind::Lan => lan_ip().ok_or("this machine has no local network address (connect to a network, or share on this machine only)")?,
|
||||
Bind::Addr(ip) => ip,
|
||||
};
|
||||
let (file, token) = (token::file_id()?, token::secret()?);
|
||||
let inbox = Inbox::new(self.wake.clone());
|
||||
// A session shared with a join code: its secret is short enough to be typed with the
|
||||
// address (see `figcraft_collab::code`); the code and the invite link name the same session.
|
||||
let (file, token) = if o.short {
|
||||
(code::LOCAL_FILE.to_string(), code::Local::new(Ipv4Addr::UNSPECIFIED, 0, token::random()?).token())
|
||||
} else {
|
||||
(token::file_id()?, token::secret()?)
|
||||
};
|
||||
let inbox = self.sessions.inbox();
|
||||
let doc = (*o.doc).clone();
|
||||
let pages = doc.pages.iter().map(|p| PageInfo { id: p.0, name: doc.node(*p).map(|n| n.name.clone()).unwrap_or_default() }).collect();
|
||||
let file_name = doc.name.clone();
|
||||
@@ -133,34 +156,33 @@ impl LiveLink for NetLink {
|
||||
network: !ip.is_loopback(),
|
||||
access: Access::Edit,
|
||||
};
|
||||
self.inbox = Some(inbox);
|
||||
self.active = Some(Active::Host(host));
|
||||
self.started(session, Active::Host(host), inbox);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
fn join(&mut self, link: &str, name: &str) -> Result<(), String> {
|
||||
let invite = Invite::parse(link)?;
|
||||
let cfg = self.guest_config(invite, name);
|
||||
self.leave();
|
||||
let inbox = Inbox::new(self.wake.clone());
|
||||
fn join(&mut self, session: SessionId, link: &str, name: &str) -> Result<(), String> {
|
||||
self.join_resuming(session, link, name, None)
|
||||
}
|
||||
|
||||
fn join_resuming(&mut self, session: SessionId, link: &str, name: &str, resume: Option<Resume>) -> Result<(), String> {
|
||||
let (invite, code) = Invite::parse_join(link)?;
|
||||
let cfg = GuestConfig { resume, ..self.guest_config(invite, code, name) };
|
||||
let inbox = self.sessions.inbox();
|
||||
let guest = Guest::start(cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
|
||||
self.inbox = Some(inbox);
|
||||
self.active = Some(Active::Guest(guest));
|
||||
self.started(session, Active::Guest(guest), inbox);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn publish(&mut self, o: PublishOptions) -> Result<(), String> {
|
||||
fn publish(&mut self, session: SessionId, o: PublishOptions) -> Result<(), String> {
|
||||
let (server, query) = Endpoint::parse(&o.server)?;
|
||||
// Without a key: as anyone may, when the server is open to the public (it says if not).
|
||||
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
|
||||
// Until the upload is done the invite names only the server.
|
||||
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, &o.me);
|
||||
self.leave();
|
||||
let inbox = Inbox::new(self.wake.clone());
|
||||
let job = Publish { server, key, name: o.name, doc: o.doc };
|
||||
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, None, &o.me);
|
||||
let inbox = self.sessions.inbox();
|
||||
let job = Publish { server, key, name: o.name, doc: o.doc, share: o.share };
|
||||
let guest = Guest::publish(job, cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
|
||||
self.inbox = Some(inbox);
|
||||
self.active = Some(Active::Guest(guest));
|
||||
self.started(session, Active::Guest(guest), inbox);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -180,6 +202,10 @@ impl LiveLink for NetLink {
|
||||
hosting(server, file, owner, req, &self.limits)
|
||||
}
|
||||
|
||||
fn share(&mut self, server: &str, file: &str, owner: &str, revoke: &[String], create: bool) -> Result<Option<Shared>, String> {
|
||||
share(server, file, owner, revoke, create, &self.limits)
|
||||
}
|
||||
|
||||
fn background(&mut self, job: u64, req: NetRequest) -> bool {
|
||||
let (inbox, limits) = (self.libraries.clone(), self.limits);
|
||||
let (failed, failure) = (inbox.clone(), req.failure());
|
||||
@@ -189,6 +215,7 @@ impl LiveLink for NetLink {
|
||||
NetRequest::Library { link, req } => NetReply::Library(manage::library(&link, req, &limits, CONNECT)),
|
||||
NetRequest::Upload(o) => NetReply::Upload(upload(o, &limits)),
|
||||
NetRequest::Hosting { server, file, owner, req } => NetReply::Hosting(hosting(&server, &file, &owner, req, &limits)),
|
||||
NetRequest::Share { server, file, owner, revoke, create } => NetReply::Share(share(&server, &file, &owner, &revoke, create, &limits)),
|
||||
};
|
||||
inbox.push(Event::Net { job, reply: Box::new(reply) });
|
||||
});
|
||||
@@ -216,34 +243,43 @@ impl LiveLink for NetLink {
|
||||
}
|
||||
}
|
||||
|
||||
fn leave(&mut self) {
|
||||
match self.active.take() {
|
||||
Some(Active::Host(mut h)) => h.stop(),
|
||||
Some(Active::Guest(mut g)) => g.leave(),
|
||||
None => {}
|
||||
fn remove(&mut self, session: SessionId, client: u32) -> Result<(), String> {
|
||||
match self.sessions.get(session) {
|
||||
Some(Active::Host(h)) if h.me().client_id == client => Err("you can't remove yourself: stop the session instead".into()),
|
||||
Some(Active::Host(h)) => {
|
||||
h.remove(client);
|
||||
Ok(())
|
||||
}
|
||||
Some(Active::Guest(_)) => Err("only the person hosting can remove people from this session".into()),
|
||||
None => Err("this session is over".into()),
|
||||
}
|
||||
self.inbox = None;
|
||||
}
|
||||
|
||||
fn presence(&mut self, p: &Presence) {
|
||||
match &self.active {
|
||||
fn leave(&mut self, session: SessionId) {
|
||||
if let Some(active) = self.sessions.remove(session) {
|
||||
active.end();
|
||||
}
|
||||
}
|
||||
|
||||
fn presence(&mut self, session: SessionId, p: &Presence) {
|
||||
match self.sessions.get(session) {
|
||||
Some(Active::Host(h)) => h.presence(p.clone()),
|
||||
Some(Active::Guest(g)) => g.presence(p.clone()),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn send(&mut self, msg: ClientMsg) {
|
||||
match &self.active {
|
||||
fn send(&mut self, session: SessionId, msg: ClientMsg) {
|
||||
match self.sessions.get(session) {
|
||||
Some(Active::Host(h)) => h.send(msg),
|
||||
Some(Active::Guest(g)) => g.send(msg),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Vec<Event> {
|
||||
let mut out = self.libraries.drain();
|
||||
out.extend(self.inbox.as_ref().map(Inbox::drain).unwrap_or_default());
|
||||
fn poll(&mut self) -> Vec<(SessionId, Event)> {
|
||||
let mut out: Vec<(SessionId, Event)> = self.libraries.drain().into_iter().map(|e| (NO_SESSION, e)).collect();
|
||||
out.extend(self.sessions.poll());
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -271,6 +307,14 @@ fn hosting(server: &str, file: &str, owner: &str, req: Option<Admin>, limits: &N
|
||||
Ok((info, reply))
|
||||
}
|
||||
|
||||
/// See [`LiveLink::share`]: as the server's operator when the address carries its key, else as
|
||||
/// the file's owner.
|
||||
fn share(server: &str, file: &str, owner: &str, revoke: &[String], create: bool, limits: &NetLimits) -> Result<Option<Shared>, String> {
|
||||
let (endpoint, query) = Endpoint::parse(server)?;
|
||||
let key = query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v).filter(|k| !k.trim().is_empty());
|
||||
manage::share(&endpoint, key.as_deref(), file, owner, revoke, create, limits, CONNECT)
|
||||
}
|
||||
|
||||
/// One request to the plugin directory at `server` (its key in the address's query, if any).
|
||||
fn plugins(server: &str, req: PluginReq, limits: &NetLimits) -> Result<PluginReply, String> {
|
||||
let (server, query) = Endpoint::parse(server)?;
|
||||
@@ -280,6 +324,8 @@ fn plugins(server: &str, req: PluginReq, limits: &NetLimits) -> Result<PluginRep
|
||||
|
||||
impl Drop for NetLink {
|
||||
fn drop(&mut self) {
|
||||
self.leave();
|
||||
for id in self.sessions.ids() {
|
||||
self.leave(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ use tungstenite::protocol::WebSocket;
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
use crate::guest::{Guest, GuestConfig};
|
||||
use crate::host::{ENDED, Host, HostConfig};
|
||||
use crate::host::{ENDED, Host, HostConfig, REMOVED};
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::Invite;
|
||||
use crate::net::{Bucket, NetLimits};
|
||||
@@ -611,3 +611,34 @@ fn until_presence_or_pong(r: &mut Raw) -> Option<ServerMsg> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_removes_someone_and_they_cant_come_back() {
|
||||
let (mut host, mut hp, invite) = start();
|
||||
let (_a, mut ap) = join(&invite, "Ada");
|
||||
let (b, mut bp) = join(&invite, "Bo");
|
||||
ap.wait("welcome", welcomed);
|
||||
bp.wait("welcome", welcomed);
|
||||
let (ada, bo) = (ap.me(), bp.me());
|
||||
connections_settle(&host, 2);
|
||||
// The host can't remove themselves, nor someone who isn't there: nothing happens.
|
||||
host.remove(host.me().client_id);
|
||||
host.remove(9999);
|
||||
host.remove(ada);
|
||||
// Ada is told the session is over for her (no reconnecting); the others see her leave.
|
||||
ap.wait("Ada removed", |e| e.iter().any(|e| matches!(e, Event::Ended { reason } if reason == REMOVED)));
|
||||
hp.wait("the host sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada)));
|
||||
bp.wait("Bo sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada)));
|
||||
connections_settle(&host, 1);
|
||||
assert!(!ap.events.iter().any(|e| matches!(e, Event::Status(Status::Reconnecting { .. }))), "a removed guest doesn't reconnect");
|
||||
assert!(!hp.left().contains(&bo), "only Ada was removed");
|
||||
// Her address can't join again with the same invite (every guest here comes from 127.0.0.1).
|
||||
let (_again, mut againp) = join(&invite, "Ada");
|
||||
againp.wait("a refusal", |e| e.iter().any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.contains("removed"))));
|
||||
assert!(!welcomed(&againp.events));
|
||||
// Bo, already in, stays and can still talk to the host.
|
||||
b.presence(presence(3.0, 4.0));
|
||||
hp.wait("Bo's cursor", |e| e.iter().any(|e| matches!(e, Event::Presence(id, _) if *id == bo)));
|
||||
host.stop();
|
||||
assert_eq!(host.connections(), 0);
|
||||
}
|
||||
@@ -7,9 +7,10 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::message::{
|
||||
self, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, PluginReply,
|
||||
PluginReq, ServerMsg,
|
||||
self, Access, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, NewLink, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL,
|
||||
PluginReply, PluginReq, Resolve, ServerMsg,
|
||||
};
|
||||
use figcraft_engine::live::{Shared, SharedLink};
|
||||
use tungstenite::{Bytes, Message, WebSocket};
|
||||
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
@@ -80,6 +81,74 @@ pub fn hosting(
|
||||
Ok((info, replies))
|
||||
}
|
||||
|
||||
/// Ask the server at `server` which file join code `code` opens: the file's id and its link's
|
||||
/// token. Fails with the server's own words when it knows no such code (or the address tried too
|
||||
/// many: the error starts with [`REFUSED`]); a server older than join codes can't read the
|
||||
/// question ("couldn't be read").
|
||||
pub fn resolve(server: &Endpoint, code: &str, limits: &NetLimits, timeout: Duration) -> Result<(String, String), String> {
|
||||
let ask = Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.to_string() };
|
||||
let mut ws = connect(server, limits, timeout)?;
|
||||
send(&mut ws, &ClientMsg::Resolve(ask))?;
|
||||
let answer = reply(&mut ws, limits, |m| match m {
|
||||
ServerMsg::Resolved { file, token } => Some((file, token)),
|
||||
_ => None,
|
||||
})?;
|
||||
close(ws);
|
||||
Ok(answer)
|
||||
}
|
||||
|
||||
/// Change who else gets into `file`, as whoever manages it (with the server's `key`, else with the
|
||||
/// file's `owner` token): revoke the links with the ids in `revoke` (whoever joined with them is
|
||||
/// disconnected, and their join codes stop working; one already gone is fine), then, to `create`,
|
||||
/// make a new edit link and a new view link and ask for their join codes: what others join with.
|
||||
/// A server older than join codes makes the links without codes.
|
||||
pub fn share(
|
||||
server: &Endpoint,
|
||||
key: Option<&str>,
|
||||
file: &str,
|
||||
owner: &str,
|
||||
revoke: &[String],
|
||||
create: bool,
|
||||
limits: &NetLimits,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<Shared>, String> {
|
||||
let ask = |requests: Vec<Admin>| match key {
|
||||
Some(key) => request(server, key, requests, limits, timeout),
|
||||
None => hosting(server, file, owner, requests, limits, timeout).map(|(_, replies)| replies),
|
||||
};
|
||||
let link = |access: Access| Admin::CreateLink { file: file.to_string(), access, label: format!("Shared ({})", access.id()) };
|
||||
let mut requests: Vec<Admin> = revoke.iter().map(|id| Admin::Revoke { file: file.to_string(), link: id.clone() }).collect();
|
||||
requests.extend(create.then(|| [link(Access::Edit), link(Access::View)]).into_iter().flatten());
|
||||
let mut made = Vec::new();
|
||||
for reply in ask(requests)? {
|
||||
match reply {
|
||||
AdminReply::Link(l) => made.push(l),
|
||||
// A link that is gone already is as revoked as it gets.
|
||||
AdminReply::Failed { reason } if made.is_empty() && reason.contains("no such link") => {}
|
||||
AdminReply::Failed { reason } => return Err(reason),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let (Some(view), Some(edit)) = (made.pop(), made.pop()) else {
|
||||
return if create { Err("the server didn't make the links".into()) } else { Ok(None) };
|
||||
};
|
||||
// Their codes, on a connection of its own: a server from before join codes can't read the
|
||||
// request and closes it (the links are shared without codes then).
|
||||
let code = |token: &str| Admin::Code { file: file.to_string(), token: token.to_string() };
|
||||
let mut codes = ask(vec![code(&edit.token), code(&view.token)])
|
||||
.map_err(|e| log::info!("live session: no join codes from {}: {e}", server.display()))
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|r| if let AdminReply::Code { code } = r { Some(code) } else { None });
|
||||
let mut shared = |l: NewLink| SharedLink {
|
||||
link: Invite { server: server.clone(), file: file.to_string(), token: l.token }.link(),
|
||||
id: l.link.id,
|
||||
code: codes.next().flatten(),
|
||||
};
|
||||
let edit = shared(edit);
|
||||
Ok(Some(Shared { edit, view: Some(shared(view)) }))
|
||||
}
|
||||
|
||||
/// Send one library request about the file behind share link `link`; the reply. Fails when the
|
||||
/// link isn't one, the server can't be reached or refuses the link (a request it can't do is a
|
||||
/// [`LibraryReply::Failed`] reply). Never echoes the link.
|
||||
@@ -148,12 +217,15 @@ fn send(ws: &mut Ws, msg: &ClientMsg) -> Result<(), String> {
|
||||
ws.send(Message::Binary(Bytes::from(bytes))).map_err(|e| format!("the connection to the server dropped ({e})"))
|
||||
}
|
||||
|
||||
/// How an error starts when the server said no (then its reason), rather than not answering.
|
||||
pub(crate) const REFUSED: &str = "the server refused: ";
|
||||
|
||||
/// The next answer `pick` takes, or why there is none.
|
||||
fn reply<T>(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option<T>) -> Result<T, String> {
|
||||
loop {
|
||||
return match ws.read() {
|
||||
Ok(Message::Binary(b)) => match message::decode::<ServerMsg>(&b, limits.max_server_message) {
|
||||
Ok(ServerMsg::Refused { reason }) => Err(format!("the server refused: {reason}")),
|
||||
Ok(ServerMsg::Refused { reason }) => Err(format!("{REFUSED}{reason}")),
|
||||
Ok(ServerMsg::Pong) => continue,
|
||||
Ok(m) => pick(m).ok_or_else(|| "the server sent something other than an answer".into()),
|
||||
Err(e) => Err(format!("the server sent a message this version can't read ({e})")),
|
||||
|
||||
@@ -47,10 +47,10 @@ fn host_and_join() -> (Session, Session, u32, u32) {
|
||||
let r = guest.execute("collab.join", &json!({"link": link})).unwrap();
|
||||
assert_eq!(r["status"], "connecting");
|
||||
pump(&mut host, &mut guest, "the join", |h, g| {
|
||||
g.live.status == Status::Live && h.live.peers.len() == 1 && g.live.info.as_ref().is_some_and(|i| !i.color.is_empty())
|
||||
g.live().status == Status::Live && h.live().peers.len() == 1 && g.live().info.as_ref().is_some_and(|i| !i.color.is_empty())
|
||||
});
|
||||
let hana = host.live.info.as_ref().unwrap().me;
|
||||
let ada = guest.live.info.as_ref().unwrap().me;
|
||||
let hana = host.live().info.as_ref().unwrap().me;
|
||||
let ada = guest.live().info.as_ref().unwrap().me;
|
||||
(host, guest, hana, ada)
|
||||
}
|
||||
|
||||
@@ -62,23 +62,23 @@ fn host_join_and_see_each_other() {
|
||||
assert_eq!(g["fileName"], "Untitled", "the host's file name");
|
||||
assert_eq!(g["name"], "Ada");
|
||||
assert_eq!(g["peers"][0]["name"], "Hana");
|
||||
assert_eq!(host.live.peers[&ada].name, "Ada");
|
||||
assert_eq!(host.live().peers[&ada].name, "Ada");
|
||||
// The pointer: the guest's cursor shows up in the host's state.
|
||||
guest.execute("collab.cursor", &json!({"x": 120, "y": 80})).unwrap();
|
||||
pump(&mut host, &mut guest, "the guest's cursor", |h, _| {
|
||||
h.live.presence.get(&ada).and_then(|p| p.cursor).is_some_and(|c| c.1 == 120.0 && c.2 == 80.0)
|
||||
h.live().presence.get(&ada).and_then(|p| p.cursor).is_some_and(|c| c.1 == 120.0 && c.2 == 80.0)
|
||||
});
|
||||
// A selection travels too.
|
||||
let r = host.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
host.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
pump(&mut host, &mut guest, "the host's selection", |_, g| {
|
||||
g.live.presence.get(&hana).and_then(|p| p.selection.clone()).is_some_and(|s| s == vec![NodeId(id)])
|
||||
g.live().presence.get(&hana).and_then(|p| p.selection.clone()).is_some_and(|s| s == vec![NodeId(id)])
|
||||
});
|
||||
// Leaving: the host sees it, the guest is back to no session.
|
||||
assert_eq!(guest.execute("collab.leave", &json!({})).unwrap()["ended"], true);
|
||||
assert_eq!(guest.live.status, Status::Off);
|
||||
pump(&mut host, &mut guest, "the leave", |h, _| h.live.peers.is_empty() && h.live.presence.is_empty());
|
||||
assert_eq!(guest.live().status, Status::Off);
|
||||
pump(&mut host, &mut guest, "the leave", |h, _| h.live().peers.is_empty() && h.live().presence.is_empty());
|
||||
host.execute("collab.stop", &json!({})).unwrap();
|
||||
assert_eq!(host.live_summary()["status"], "off");
|
||||
}
|
||||
@@ -87,7 +87,7 @@ fn host_join_and_see_each_other() {
|
||||
fn following_a_viewport_until_you_move() {
|
||||
let (mut host, mut guest, hana, ada) = host_and_join();
|
||||
host.execute("collab.follow", &json!({"client": ada})).unwrap();
|
||||
assert_eq!(host.live.follow, Some(ada));
|
||||
assert_eq!(host.live().follow, Some(ada));
|
||||
// The guest pans and zooms: the host's view goes along.
|
||||
{
|
||||
let ds = guest.ds_mut().unwrap();
|
||||
@@ -103,29 +103,29 @@ fn following_a_viewport_until_you_move() {
|
||||
// The host pans: following stops, and later moves of the guest don't move the host.
|
||||
host.ds_mut().unwrap().view.cx += 100.0;
|
||||
host.live_sync();
|
||||
assert_eq!(host.live.follow, None);
|
||||
assert_eq!(host.live().follow, None);
|
||||
guest.ds_mut().unwrap().view.cx = -9000.0;
|
||||
pump(&mut host, &mut guest, "the guest's new viewport at the host", |h, _| {
|
||||
h.live.presence.get(&ada).and_then(|p| p.viewport).is_some_and(|v| (v.1 + v.3 / 2.0 + 9000.0).abs() < 1e-6)
|
||||
h.live().presence.get(&ada).and_then(|p| p.viewport).is_some_and(|v| (v.1 + v.3 / 2.0 + 9000.0).abs() < 1e-6)
|
||||
});
|
||||
assert!((host.ds().unwrap().view.cx - 5100.0).abs() < 1e-6);
|
||||
// Escape stops following too; following someone who isn't there fails.
|
||||
guest.execute("collab.follow", &json!({"client": hana})).unwrap();
|
||||
guest.escape().unwrap();
|
||||
assert_eq!(guest.live.follow, None);
|
||||
assert_eq!(guest.live().follow, None);
|
||||
assert!(guest.execute("collab.follow", &json!({"client": 999})).is_err());
|
||||
// The followed peer leaving ends the follow.
|
||||
host.execute("collab.follow", &json!({"client": ada})).unwrap();
|
||||
guest.execute("collab.leave", &json!({})).unwrap();
|
||||
pump(&mut host, &mut guest, "the leave", |h, _| h.live.follow.is_none() && h.live.peers.is_empty());
|
||||
pump(&mut host, &mut guest, "the leave", |h, _| h.live().follow.is_none() && h.live().peers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_stopping_ends_the_session_for_guests() {
|
||||
let (mut host, mut guest, _, _) = host_and_join();
|
||||
host.execute("collab.stop", &json!({})).unwrap();
|
||||
pump(&mut host, &mut guest, "the end", |_, g| g.live.status == Status::Off);
|
||||
assert!(guest.live.peers.is_empty());
|
||||
pump(&mut host, &mut guest, "the end", |_, g| g.live().status == Status::Off);
|
||||
assert!(guest.live().peers.is_empty());
|
||||
assert_eq!(guest.message.as_deref(), Some(crate::host::ENDED));
|
||||
}
|
||||
|
||||
@@ -138,8 +138,8 @@ fn a_bad_invite_is_reported() {
|
||||
let r: Value = host.execute("collab.host", &json!({"port": 0})).unwrap();
|
||||
let link = r["link"].as_str().unwrap().replace("token=", "token=x");
|
||||
guest.execute("collab.join", &json!({"link": link})).unwrap();
|
||||
pump(&mut host, &mut guest, "the refusal", |_, g| matches!(&g.live.status, Status::Failed { reason } if reason.contains("token")));
|
||||
assert!(host.live.peers.is_empty());
|
||||
pump(&mut host, &mut guest, "the refusal", |_, g| matches!(&g.live().status, Status::Failed { reason } if reason.contains("token")));
|
||||
assert!(host.live().peers.is_empty());
|
||||
}
|
||||
|
||||
/// The synced properties of two documents agree (nothing to send from one to the other).
|
||||
@@ -175,7 +175,7 @@ fn edits_sync_between_apps() {
|
||||
}
|
||||
};
|
||||
all(&mut host, &mut a, &mut b, "everyone syncing", &|h, a, b| {
|
||||
h.live.syncing() && a.live.syncing() && b.live.syncing() && h.live.peers.len() == 2
|
||||
h.live().syncing() && a.live().syncing() && b.live().syncing() && h.live().peers.len() == 2
|
||||
});
|
||||
// The guests got the host's file as a new tab.
|
||||
assert_eq!(a.docs.len(), 2);
|
||||
@@ -208,7 +208,7 @@ fn edits_sync_between_apps() {
|
||||
// And the server's: someone joining now gets its document in the welcome.
|
||||
let mut c = session("Cy");
|
||||
c.execute("collab.join", &json!({"link": link})).unwrap();
|
||||
all(&mut host, &mut a, &mut c, "the late joiner", &|_, _, c| c.live.syncing());
|
||||
all(&mut host, &mut a, &mut c, "the late joiner", &|_, _, c| c.live().syncing());
|
||||
assert!(same(&hd, c.doc().unwrap()), "the server's document is everyone's");
|
||||
host.execute("collab.stop", &json!({})).unwrap();
|
||||
}
|
||||
@@ -216,13 +216,13 @@ fn edits_sync_between_apps() {
|
||||
#[test]
|
||||
fn cursor_chat_and_spotlight_between_apps() {
|
||||
let (mut host, mut guest, hana, ada) = host_and_join();
|
||||
pump(&mut host, &mut guest, "both told signals work", |h, g| h.live.signals && g.live.signals);
|
||||
pump(&mut host, &mut guest, "both told signals work", |h, g| h.live().signals && g.live().signals);
|
||||
// The guest chats next to its cursor; the host sees it, cleaned, and then sees it close.
|
||||
guest.execute("collab.cursor", &json!({"x": 40, "y": 30})).unwrap();
|
||||
guest.execute("collab.cursorChat", &json!({"text": "over\there"})).unwrap();
|
||||
pump(&mut host, &mut guest, "the chat", |h, _| h.live.chats.get(&ada).is_some_and(|t| t == "over here"));
|
||||
pump(&mut host, &mut guest, "the chat", |h, _| h.live().chats.get(&ada).is_some_and(|t| t == "over here"));
|
||||
guest.execute("collab.cursorChat", &json!({"text": ""})).unwrap();
|
||||
pump(&mut host, &mut guest, "the chat closing", |h, _| h.live.chats.is_empty());
|
||||
pump(&mut host, &mut guest, "the chat closing", |h, _| h.live().chats.is_empty());
|
||||
// The host spotlights: the guest follows its view, page included.
|
||||
let second = host.execute("page.add", &json!({"name": "Two"})).unwrap()["id"].as_u64().map(NodeId).unwrap();
|
||||
pump(&mut host, &mut guest, "the new page at the guest", |_, g| g.doc().unwrap().pages.contains(&second));
|
||||
@@ -235,17 +235,17 @@ fn cursor_chat_and_spotlight_between_apps() {
|
||||
}
|
||||
pump(&mut host, &mut guest, "the guest following the spotlight", |_, g| {
|
||||
let ds = g.ds().unwrap();
|
||||
g.live.spotlight == Some(hana) && g.live.follow == Some(hana) && ds.page == second && (ds.view.cx - 777.0).abs() < 1e-6
|
||||
g.live().spotlight == Some(hana) && g.live().follow == Some(hana) && ds.page == second && (ds.view.cx - 777.0).abs() < 1e-6
|
||||
});
|
||||
// A late joiner is told and follows too.
|
||||
let mut late = session("Lin");
|
||||
late.execute("collab.join", &json!({"link": host.live.info.as_ref().unwrap().link.clone()})).unwrap();
|
||||
pump(&mut host, &mut late, "the late joiner following", |_, l| l.live.follow == Some(hana) && l.live.spotlight == Some(hana));
|
||||
late.execute("collab.join", &json!({"link": host.live().info.as_ref().unwrap().link.clone()})).unwrap();
|
||||
pump(&mut host, &mut late, "the late joiner following", |_, l| l.live().follow == Some(hana) && l.live().spotlight == Some(hana));
|
||||
// The guest moves: it stops following, the spotlight stays; the host stops it for everyone.
|
||||
guest.ds_mut().unwrap().view.cx += 300.0;
|
||||
guest.live_sync();
|
||||
assert_eq!((guest.live.follow, guest.live.spotlight), (None, Some(hana)));
|
||||
assert_eq!((guest.live().follow, guest.live().spotlight), (None, Some(hana)));
|
||||
host.execute("collab.spotlight", &json!({"on": false})).unwrap();
|
||||
pump(&mut host, &mut guest, "the spotlight ending", |_, g| g.live.spotlight.is_none());
|
||||
pump(&mut host, &mut late, "the spotlight ending for the late joiner", |_, l| l.live.spotlight.is_none() && l.live.follow.is_none());
|
||||
pump(&mut host, &mut guest, "the spotlight ending", |_, g| g.live().spotlight.is_none());
|
||||
pump(&mut host, &mut late, "the spotlight ending for the late joiner", |_, l| l.live().spotlight.is_none() && l.live().follow.is_none());
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! The sessions a link carries at once (the engine has one per document): each under the id the
|
||||
//! engine gave it, with its own [`Inbox`], so what happens in one never reaches another and a
|
||||
//! session that ended can't reach the next. The desktop link and the browser link both keep
|
||||
//! theirs here.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use figcraft_engine::live::{Event, SessionId};
|
||||
|
||||
use crate::inbox::{Inbox, Waker};
|
||||
|
||||
/// Running sessions of kind `T` (a host or a guest connection) by id.
|
||||
pub(crate) struct Sessions<T> {
|
||||
wake: Waker,
|
||||
running: BTreeMap<SessionId, (T, Inbox)>,
|
||||
}
|
||||
|
||||
impl<T> Sessions<T> {
|
||||
pub(crate) fn new(wake: Waker) -> Self {
|
||||
Self { wake, running: BTreeMap::new() }
|
||||
}
|
||||
|
||||
/// A mailbox for a session about to start.
|
||||
pub(crate) fn inbox(&self) -> Inbox {
|
||||
Inbox::new(self.wake.clone())
|
||||
}
|
||||
|
||||
/// Session `id` started, with the mailbox its threads fill. Returns the session that had
|
||||
/// the id, to be ended.
|
||||
pub(crate) fn insert(&mut self, id: SessionId, session: T, inbox: Inbox) -> Option<T> {
|
||||
self.running.insert(id, (session, inbox)).map(|(old, _)| old)
|
||||
}
|
||||
|
||||
/// Session `id` is over: it comes back to be ended, and its mailbox is dropped.
|
||||
pub(crate) fn remove(&mut self, id: SessionId) -> Option<T> {
|
||||
self.running.remove(&id).map(|(session, _)| session)
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, id: SessionId) -> Option<&T> {
|
||||
self.running.get(&id).map(|(session, _)| session)
|
||||
}
|
||||
|
||||
/// Every session's id, oldest first.
|
||||
pub(crate) fn ids(&self) -> Vec<SessionId> {
|
||||
self.running.keys().copied().collect()
|
||||
}
|
||||
|
||||
/// What happened in each session since the last call, with its id.
|
||||
pub(crate) fn poll(&self) -> Vec<(SessionId, Event)> {
|
||||
self.running.iter().flat_map(|(id, (_, inbox))| inbox.drain().into_iter().map(|e| (*id, e))).collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Sessions per document over the network: the link's session table, and two engine sessions in
|
||||
//! one process that host and join several files at once through `collab.*` commands. Each file's
|
||||
//! session has its own people, links, edits and presence; closing a file ends its session only.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::live::{Event, Live, Presence, Role, Status};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::NetLink;
|
||||
use crate::loopback_tests::quick;
|
||||
use crate::sessions::Sessions;
|
||||
|
||||
#[test]
|
||||
fn each_session_has_its_own_mailbox() {
|
||||
let woken = Arc::new(AtomicUsize::new(0));
|
||||
let wake = woken.clone();
|
||||
let mut table: Sessions<&str> = Sessions::new(Arc::new(move || {
|
||||
wake.fetch_add(1, Ordering::SeqCst);
|
||||
}));
|
||||
let (a, b) = (table.inbox(), table.inbox());
|
||||
assert!(table.insert(1, "host", a.clone()).is_none());
|
||||
assert!(table.insert(2, "guest", b.clone()).is_none());
|
||||
a.push(Event::PeerLeft(7));
|
||||
b.push(Event::PeerLeft(9));
|
||||
b.presence(3, Presence::default());
|
||||
assert_eq!(woken.load(Ordering::SeqCst), 3, "every arrival wakes the UI");
|
||||
assert_eq!(table.poll(), vec![(1, Event::PeerLeft(7)), (2, Event::PeerLeft(9)), (2, Event::Presence(3, Presence::default()))]);
|
||||
assert!(table.poll().is_empty(), "taken once");
|
||||
// A session that ended: what its threads still send goes nowhere, the other goes on.
|
||||
assert_eq!(table.remove(1), Some("host"));
|
||||
a.push(Event::PeerLeft(8));
|
||||
b.push(Event::PeerLeft(10));
|
||||
assert_eq!(table.poll(), vec![(2, Event::PeerLeft(10))]);
|
||||
assert_eq!((table.ids(), table.get(1), table.get(2)), (vec![2], None, Some(&"guest")));
|
||||
// An id started again replaces what had it, mailbox included.
|
||||
assert_eq!(table.insert(2, "again", table.inbox()), Some("guest"));
|
||||
b.push(Event::PeerLeft(11));
|
||||
assert!(table.poll().is_empty());
|
||||
assert_eq!(table.remove(3), None);
|
||||
}
|
||||
|
||||
fn session(name: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.live.install(Box::new(NetLink::new(Arc::new(|| {})).with_guest_config(quick)));
|
||||
s.execute("pref.userName", &json!({"name": name})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Pump the sessions (as their UIs would every frame) until `f` holds.
|
||||
fn pump(all: &mut [&mut Session], what: &str, f: impl Fn(&[&mut Session]) -> bool) {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
for s in all.iter_mut() {
|
||||
s.live_poll();
|
||||
s.live_sync();
|
||||
}
|
||||
if f(all) {
|
||||
return;
|
||||
}
|
||||
let states: Vec<String> = all.iter().map(|s| s.live_summary()["sessions"].to_string()).collect();
|
||||
assert!(start.elapsed() < Duration::from_secs(10), "timed out waiting for {what}: {states:?}");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
fn link_of(v: &Value) -> String {
|
||||
v["link"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
fn uid_at(s: &Session, index: usize) -> u64 {
|
||||
s.docs[index].uid
|
||||
}
|
||||
|
||||
/// The session syncing through the host at `address` (a guest's).
|
||||
fn joined<'a>(s: &'a Session, address: &str) -> Option<&'a Live> {
|
||||
s.live.iter().find(|l| l.syncing() && l.info.as_ref().is_some_and(|i| i.role == Role::Guest && i.address == address))
|
||||
}
|
||||
|
||||
fn has(s: &Session, uid: u64, node: u64) -> bool {
|
||||
s.docs.iter().find(|d| d.uid == uid).is_some_and(|d| d.doc.contains(NodeId(node)))
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session) -> u64 {
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_files_are_hosted_and_joined_independently() {
|
||||
let mut host = session("Hana");
|
||||
let mut guest = session("Ada");
|
||||
// File A is hosted; a new file B isn't in a session until it is hosted too.
|
||||
let a = host.execute("collab.host", &json!({"port": 0})).unwrap();
|
||||
host.execute("file.new", &json!({"name": "Second"})).unwrap();
|
||||
let off = host.execute("collab.peers", &json!({})).unwrap();
|
||||
assert_eq!((&off["status"], &off["session"], &off["link"], &off["document"]), (&json!("off"), &Value::Null, &Value::Null, &Value::Null));
|
||||
assert_eq!(off["sessions"].as_array().unwrap().len(), 1, "the first file's session goes on: {off}");
|
||||
assert_eq!(off["sessions"][0]["document"], 0);
|
||||
let b = host.execute("collab.host", &json!({"port": 0})).unwrap();
|
||||
assert_ne!(a["address"], b["address"], "each file listens on its own port");
|
||||
assert_ne!(a["session"], b["session"]);
|
||||
assert_eq!(b["sessions"].as_array().unwrap().len(), 2);
|
||||
let (addr_a, addr_b) = (a["address"].as_str().unwrap(), b["address"].as_str().unwrap());
|
||||
let (ha, hb) = (uid_at(&host, 0), uid_at(&host, 1));
|
||||
// The guest joins both, one right after the other: two more tabs, each with its session.
|
||||
for l in [&a, &b] {
|
||||
assert_eq!(guest.execute("collab.join", &json!({"link": link_of(l)})).unwrap()["status"], "connecting");
|
||||
}
|
||||
pump(&mut [&mut host, &mut guest], "both joins", |s| {
|
||||
joined(&*s[1], addr_a).is_some() && joined(&*s[1], addr_b).is_some() && s[0].live_of(ha).peers.len() == 1 && s[0].live_of(hb).peers.len() == 1
|
||||
});
|
||||
assert_eq!(guest.docs.len(), 3, "the guest's own file and the two it joined");
|
||||
let (ga, gb) = (joined(&guest, addr_a).unwrap().doc_uid.unwrap(), joined(&guest, addr_b).unwrap().doc_uid.unwrap());
|
||||
assert_ne!(ga, gb);
|
||||
assert_eq!(guest.live_of(uid_at(&guest, 0)).status, Status::Off, "the file the joins were started from is in no session");
|
||||
assert_eq!(guest.docs.iter().find(|d| d.uid == gb).unwrap().doc.name, "Second");
|
||||
// `collab.peers` is the file in front's: switching files switches it.
|
||||
assert_eq!(host.live_summary()["link"], b["link"]);
|
||||
host.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(host.live_summary()["link"], a["link"]);
|
||||
assert_eq!(host.live_summary()["document"], 0);
|
||||
// An edit in A reaches the guest's copy of A only, while B is in front at the guest.
|
||||
assert_eq!(guest.ds().unwrap().uid, gb, "the file joined last is in front");
|
||||
let in_a = rect(&mut host);
|
||||
host.execute("doc.switch", &json!({"index": 1})).unwrap();
|
||||
let in_b = rect(&mut host);
|
||||
host.execute("node.set", &json!({"ids": [in_b], "props": {"name": "Only in B"}})).unwrap();
|
||||
let name = |s: &Session, uid: u64, node: u64| s.docs.iter().find(|d| d.uid == uid).and_then(|d| d.doc.node(NodeId(node))).map(|n| n.name.clone());
|
||||
// The rename is its own batch, behind the one that creates the layer: wait for it too.
|
||||
pump(&mut [&mut host, &mut guest], "the edits at the guest", |s| has(&*s[1], ga, in_a) && name(&*s[1], gb, in_b).as_deref() == Some("Only in B"));
|
||||
assert_ne!(name(&guest, ga, in_b).as_deref(), Some("Only in B"), "B's edit never reaches A");
|
||||
assert_ne!(name(&host, ha, in_b).as_deref(), Some("Only in B"));
|
||||
// And back: the guest edits its copy of A in the background tab's session.
|
||||
guest.execute("doc.switch", &json!({"index": guest.docs.iter().position(|d| d.uid == ga).unwrap()})).unwrap();
|
||||
let from_guest = rect(&mut guest);
|
||||
guest.execute("node.set", &json!({"ids": [from_guest], "props": {"name": "From Ada"}})).unwrap();
|
||||
pump(&mut [&mut host, &mut guest], "the guest's edit at the host", |s| name(&*s[0], ha, from_guest).as_deref() == Some("From Ada"));
|
||||
assert_ne!(name(&host, hb, from_guest).as_deref(), Some("From Ada"));
|
||||
// Presence stays in its session: the guest points in A (in front there), not in B.
|
||||
let ada = |s: &Session, uid: u64| s.live_of(uid).peers.keys().next().copied().unwrap();
|
||||
guest.execute("collab.cursor", &json!({"x": 64, "y": 48})).unwrap();
|
||||
pump(&mut [&mut host, &mut guest], "the guest's cursor in A", |s| {
|
||||
s[0].live_of(ha).presence.get(&ada(&*s[0], ha)).and_then(|p| p.cursor).is_some_and(|c| c.1 == 64.0 && c.2 == 48.0)
|
||||
});
|
||||
assert!(host.live_of(hb).presence.get(&ada(&host, hb)).is_none_or(|p| p.cursor.is_none()), "no cursor in the file that isn't in front");
|
||||
// The host closes B: its session ends for everyone, A's goes on.
|
||||
host.execute("file.close", &json!({"index": 1, "force": true})).unwrap();
|
||||
pump(&mut [&mut host, &mut guest], "B's session ending", |s| s[0].live.iter().count() == 1 && s[1].live.iter().count() == 1);
|
||||
assert_eq!(guest.live_of(gb).status, Status::Off);
|
||||
assert_eq!(guest.docs.len(), 3, "the guest keeps its copy of B");
|
||||
assert_eq!((host.live_of(ha).status.clone(), host.live_of(ha).peers.len()), (Status::Live, 1));
|
||||
assert_eq!(guest.live_of(ga).status, Status::Live);
|
||||
let later = rect(&mut host);
|
||||
pump(&mut [&mut host, &mut guest], "an edit after B closed", |s| has(&*s[1], ga, later));
|
||||
assert!(!has(&guest, gb, later));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosting_one_file_while_joined_to_another() {
|
||||
let mut x = session("Hana");
|
||||
let mut y = session("Ada");
|
||||
let from_x = link_of(&x.execute("collab.host", &json!({"port": 0})).unwrap());
|
||||
let from_y = link_of(&y.execute("collab.host", &json!({"port": 0})).unwrap());
|
||||
let (xu, yu) = (uid_at(&x, 0), uid_at(&y, 0));
|
||||
// Each joins the other's file from the tab it hosts: the hosting goes on.
|
||||
y.execute("collab.join", &json!({"link": from_x})).unwrap();
|
||||
x.execute("collab.join", &json!({"link": from_y})).unwrap();
|
||||
pump(&mut [&mut x, &mut y], "both joined", |s| {
|
||||
s.iter().all(|s| s.docs.len() == 2 && s.live().syncing() && s.live().role() == Some(Role::Guest))
|
||||
&& s[0].live_of(xu).peers.len() == 1
|
||||
&& s[1].live_of(yu).peers.len() == 1
|
||||
});
|
||||
for (s, own) in [(&x, xu), (&y, yu)] {
|
||||
let p = s.live_summary();
|
||||
assert_eq!((&p["role"], &p["document"]), (&json!("guest"), &json!(1)), "the joined file is in front: {p}");
|
||||
assert_eq!(p["sessions"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(s.live_of(own).role(), Some(Role::Host));
|
||||
assert_eq!(s.live_of(own).status, Status::Live);
|
||||
}
|
||||
// Y edits X's file (its tab in front) and its own; each lands in its own file at X.
|
||||
let in_x = rect(&mut y);
|
||||
y.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
let in_y = rect(&mut y);
|
||||
y.execute("node.set", &json!({"ids": [in_y], "props": {"name": "Hosted by Ada"}})).unwrap();
|
||||
let named = |s: &Session, uid: u64| {
|
||||
s.docs.iter().find(|d| d.uid == uid).is_some_and(|d| d.doc.node(NodeId(in_y)).is_some_and(|n| n.name == "Hosted by Ada"))
|
||||
};
|
||||
let x_copy_of_y = x.live().doc_uid.unwrap();
|
||||
pump(&mut [&mut x, &mut y], "Y's edits at X", |s| has(&*s[0], xu, in_x) && named(&*s[0], x_copy_of_y));
|
||||
assert!(!named(&x, xu), "the edit of Y's file isn't in X's");
|
||||
// Y leaves X's file: Y still hosts its own, with X in it.
|
||||
y.execute("doc.switch", &json!({"index": 1})).unwrap();
|
||||
assert_eq!(y.execute("collab.leave", &json!({})).unwrap()["ended"], true);
|
||||
pump(&mut [&mut x, &mut y], "the leave", |s| s[0].live_of(xu).peers.is_empty());
|
||||
assert_eq!((y.live().status.clone(), y.live.iter().count()), (Status::Off, 1));
|
||||
assert_eq!((y.live_of(yu).status.clone(), y.live_of(yu).peers.len()), (Status::Live, 1));
|
||||
assert_eq!(x.live_of(x_copy_of_y).status, Status::Live);
|
||||
}
|
||||
@@ -15,3 +15,10 @@ pub fn file_id() -> Result<String, String> {
|
||||
getrandom::fill(&mut b).map_err(|e| format!("no secure random numbers: {e}"))?;
|
||||
Ok(b.iter().map(|x| format!("{x:02x}")).collect())
|
||||
}
|
||||
|
||||
/// 64 random bits (join codes are made from them).
|
||||
pub fn random() -> Result<u64, String> {
|
||||
let mut b = [0u8; 8];
|
||||
getrandom::fill(&mut b).map_err(|e| format!("no secure random numbers: {e}"))?;
|
||||
Ok(u64::from_le_bytes(b))
|
||||
}
|
||||
@@ -6,8 +6,9 @@
|
||||
//! socket).
|
||||
//!
|
||||
//! The page runs everything on one thread: each session's socket, callbacks and timer live in a
|
||||
//! thread-local table, and [`NetLink`] (the engine needs it to be `Send`) holds only the session's
|
||||
//! number and its [`Inbox`]. Whatever arrives wakes egui through the [`Waker`]; nothing polls.
|
||||
//! thread-local table, and [`NetLink`] (the engine needs it to be `Send`) holds only each session's
|
||||
//! number there and its [`Inbox`](crate::inbox::Inbox). It joins any number of sessions at once
|
||||
//! (one per open document). Whatever arrives wakes egui through the [`Waker`]; nothing polls.
|
||||
//!
|
||||
//! An `https://` page can't open an unencrypted `ws://` connection (browsers block mixed content)
|
||||
//! except to this machine; [`NetLink::join`] says so up front.
|
||||
@@ -17,16 +18,17 @@ use std::collections::BTreeMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use figcraft_collab::message::{ClientMsg, Presence};
|
||||
use figcraft_engine::live::{Event, HostOptions, LiveLink, SessionInfo};
|
||||
use figcraft_engine::live::{Event, HostOptions, LiveLink, SessionId, SessionInfo};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use web_sys::{BinaryType, CloseEvent, MessageEvent, WebSocket};
|
||||
use web_time::Instant;
|
||||
|
||||
use crate::driver::{Driver, Socket};
|
||||
use crate::inbox::{Inbox, Waker};
|
||||
use crate::inbox::Waker;
|
||||
use crate::invite::Invite;
|
||||
use crate::join::GuestConfig;
|
||||
use crate::sessions::Sessions;
|
||||
|
||||
/// Close code for leaving normally.
|
||||
const CLOSE_NORMAL: u16 = 1000;
|
||||
@@ -137,22 +139,21 @@ fn secure_page() -> bool {
|
||||
web_sys::window().and_then(|w| w.location().protocol().ok()).is_some_and(|p| p == "https:")
|
||||
}
|
||||
|
||||
/// The browser's [`LiveLink`]: joins one session at a time; hosting needs the desktop app.
|
||||
/// The browser's [`LiveLink`]: joins sessions; hosting needs the desktop app.
|
||||
pub struct NetLink {
|
||||
wake: Waker,
|
||||
active: Option<u64>,
|
||||
inbox: Option<Inbox>,
|
||||
/// Each session's number in the thread-local table.
|
||||
sessions: Sessions<u64>,
|
||||
}
|
||||
|
||||
impl NetLink {
|
||||
/// `wake` is called whenever something arrives for the UI (egui's `request_repaint`).
|
||||
pub fn new(wake: Waker) -> Self {
|
||||
Self { wake, active: None, inbox: None }
|
||||
Self { sessions: Sessions::new(wake) }
|
||||
}
|
||||
|
||||
fn with(&self, f: impl FnOnce(&mut Driver<BrowserSocket>, Instant)) {
|
||||
if let Some(id) = self.active {
|
||||
with_driver(id, f);
|
||||
fn with(&self, session: SessionId, f: impl FnOnce(&mut Driver<BrowserSocket>, Instant)) {
|
||||
if let Some(id) = self.sessions.get(session) {
|
||||
with_driver(*id, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,51 +163,54 @@ impl LiveLink for NetLink {
|
||||
false
|
||||
}
|
||||
|
||||
fn host(&mut self, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
fn host(&mut self, _: SessionId, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
Err("hosting a live session needs the desktop app; in the browser you can join one".into())
|
||||
}
|
||||
|
||||
fn join(&mut self, link: &str, name: &str) -> Result<(), String> {
|
||||
let invite = Invite::parse(link)?;
|
||||
fn join(&mut self, session: SessionId, link: &str, name: &str) -> Result<(), String> {
|
||||
let (invite, code) = Invite::parse_join(link)?;
|
||||
if secure_page() && !invite.server.secure && !invite.loopback() {
|
||||
return Err("this page was loaded over https, and browsers block unencrypted ws:// connections; use a wss:// link (a server behind TLS), open FigCraft from an http:// address (like http://localhost:8767), or join from the desktop app".into());
|
||||
}
|
||||
self.leave();
|
||||
let id = NEXT_ID.with(|n| n.replace(n.get() + 1));
|
||||
let inbox = Inbox::new(self.wake.clone());
|
||||
let driver = Driver::start(BrowserSocket { id, ws: None }, GuestConfig::new(invite, name.to_string()), inbox.clone(), Instant::now());
|
||||
let inbox = self.sessions.inbox();
|
||||
let cfg = GuestConfig { code, ..GuestConfig::new(invite, name.to_string()) };
|
||||
let driver = Driver::start(BrowserSocket { id, ws: None }, cfg, inbox.clone(), Instant::now());
|
||||
let timer = Closure::<dyn FnMut()>::new(move || with_driver(id, |d, now| d.tick(now)));
|
||||
let mut g = Guest { driver, timer, timeout: None };
|
||||
g.arm();
|
||||
if !g.driver.done() {
|
||||
GUESTS.with(|t| t.borrow_mut().insert(id, Rc::new(RefCell::new(g))));
|
||||
}
|
||||
self.inbox = Some(inbox);
|
||||
self.active = Some(id);
|
||||
if let Some(old) = self.sessions.insert(session, id, inbox) {
|
||||
with_driver(old, |d, now| d.leave(now));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn leave(&mut self) {
|
||||
self.with(|d, now| d.leave(now));
|
||||
self.active = None;
|
||||
self.inbox = None;
|
||||
fn leave(&mut self, session: SessionId) {
|
||||
if let Some(id) = self.sessions.remove(session) {
|
||||
with_driver(id, |d, now| d.leave(now));
|
||||
}
|
||||
}
|
||||
|
||||
fn presence(&mut self, p: &Presence) {
|
||||
self.with(|d, now| d.presence(p.clone(), now));
|
||||
fn presence(&mut self, session: SessionId, p: &Presence) {
|
||||
self.with(session, |d, now| d.presence(p.clone(), now));
|
||||
}
|
||||
|
||||
fn send(&mut self, msg: ClientMsg) {
|
||||
self.with(|d, now| d.send(msg, now));
|
||||
fn send(&mut self, session: SessionId, msg: ClientMsg) {
|
||||
self.with(session, |d, now| d.send(msg, now));
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Vec<Event> {
|
||||
self.inbox.as_ref().map(Inbox::drain).unwrap_or_default()
|
||||
fn poll(&mut self) -> Vec<(SessionId, Event)> {
|
||||
self.sessions.poll()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NetLink {
|
||||
fn drop(&mut self) {
|
||||
self.leave();
|
||||
for id in self.sessions.ids() {
|
||||
self.leave(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ impl Client {
|
||||
|
||||
/// Start from the server's `Welcome` (which must carry a snapshot).
|
||||
pub fn join(welcome: ServerMsg) -> Result<Client, Error> {
|
||||
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), peers, access, .. } = welcome else {
|
||||
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), processed, peers, access } = welcome else {
|
||||
return Err(Error::Protocol("expected a welcome with the document".into()));
|
||||
};
|
||||
let mut confirmed = Replica::from_snapshot(&snapshot)?;
|
||||
@@ -97,7 +97,9 @@ impl Client {
|
||||
confirmed,
|
||||
seq,
|
||||
pending: VecDeque::new(),
|
||||
next_batch: 1,
|
||||
// After the batches the server already processed for this id (a client that starts
|
||||
// over under the id it had, e.g. after its app crashed: lower ids would be ignored).
|
||||
next_batch: processed.last_batch.saturating_add(1),
|
||||
next_id,
|
||||
peers: peers.into_iter().map(|p| (p.client_id, p)).collect(),
|
||||
presence: BTreeMap::new(),
|
||||
@@ -305,7 +307,8 @@ impl Client {
|
||||
/// Queue `changes` as a new batch (already shown in the view).
|
||||
fn queue(&mut self, changes: Vec<Change>, kind: BatchKind, step: Option<u64>) {
|
||||
let batch_id = self.next_batch;
|
||||
self.next_batch += 1;
|
||||
// `next_batch` starts from the welcome's `last_batch`, the host's word: saturate.
|
||||
self.next_batch = self.next_batch.saturating_add(1);
|
||||
self.pending.push_back(Pending { batch_id, base_seq: self.seq, changes, kind, step, rebased: false, sent: false });
|
||||
}
|
||||
|
||||
@@ -451,7 +454,9 @@ impl Client {
|
||||
}
|
||||
// Cursor chat, spotlights and what a server says about its hosting are the app's (the
|
||||
// transport hands them over).
|
||||
ServerMsg::Pong | ServerMsg::Signal { .. } | ServerMsg::Hosting(_) | ServerMsg::Reported { .. } => Ok(Vec::new()),
|
||||
ServerMsg::Pong | ServerMsg::Signal { .. } | ServerMsg::Hosting(_) | ServerMsg::Reported { .. } | ServerMsg::Resolved { .. } => {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
ServerMsg::Welcome { .. } if self.online => Err(Error::Protocol("already joined".into())),
|
||||
ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access } => {
|
||||
if session != self.session || client_id != self.id {
|
||||
@@ -480,7 +485,11 @@ impl Client {
|
||||
|
||||
/// A batch the server accepted (in order). Follow-ups it causes are queued.
|
||||
fn accepted(&mut self, acc: Accepted, recompute: &dyn Recompute) -> Result<(), Error> {
|
||||
if acc.seq != self.seq + 1 {
|
||||
// The host's sequence number is untrusted: at `u64::MAX` it can't step past it.
|
||||
let Some(next) = self.seq.checked_add(1) else {
|
||||
return Err(Error::Protocol(format!("sequence {} after {}", acc.seq, self.seq)));
|
||||
};
|
||||
if acc.seq != next {
|
||||
return Err(Error::Protocol(format!("sequence {} after {}", acc.seq, self.seq)));
|
||||
}
|
||||
if acc.author != self.id {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! A client's handling of the host's messages: sequence numbers and batch ids come from the host,
|
||||
//! so they are treated as hostile input (they must never overflow).
|
||||
|
||||
use figcraft_doc::NodeKind;
|
||||
|
||||
use crate::message::{Accepted, ClientMsg, Hello, ServerMsg};
|
||||
use crate::sim_tests::base;
|
||||
use crate::{Client, Limits, NoRecompute, Outgoing, Server};
|
||||
|
||||
fn hello() -> Hello {
|
||||
let ClientMsg::Hello(h) = Client::hello("f", "tok", "Ada", "#FF8800") else { panic!("not a hello") };
|
||||
h
|
||||
}
|
||||
|
||||
/// A welcome whose sequence numbers are what a lying host would send.
|
||||
fn hostile_welcome(seq: u64, last_batch: u64) -> ServerMsg {
|
||||
let mut s = Server::new(base(), "tok", Limits::default());
|
||||
let (_, out) = s.join(hello()).unwrap();
|
||||
let mut welcome = out
|
||||
.into_iter()
|
||||
.find_map(|o| match o {
|
||||
Outgoing::To(_, m) => Some(m),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let ServerMsg::Welcome { seq: seq0, processed, .. } = &mut welcome else { panic!("no welcome") };
|
||||
*seq0 = seq;
|
||||
processed.last_batch = last_batch;
|
||||
welcome
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sequence_numbers_do_not_overflow_the_client() {
|
||||
let mut c = Client::join(hostile_welcome(u64::MAX, u64::MAX)).unwrap();
|
||||
// This client's own first batch must not overflow its batch counter.
|
||||
let mut d = c.document().clone();
|
||||
let page = d.first_page().unwrap();
|
||||
d.create(page, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
assert!(c.edit(&d).unwrap().is_some(), "the edit is batched");
|
||||
// A batch the host claims follows `u64::MAX` can't exist: refuse it, don't overflow.
|
||||
let acc = Accepted { seq: 0, author: c.id().wrapping_add(1), batch_id: 0, changes: Vec::new() };
|
||||
assert!(c.receive(ServerMsg::Changes(acc), &NoRecompute).is_err());
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Join codes: the short text someone types to join a shared file, in place of an invite link.
|
||||
//!
|
||||
//! Both kinds use Crockford's base32 alphabet (digits and capitals without `I`, `L`, `O`, `U`), so
|
||||
//! a code survives being read aloud or copied by hand: reading one ignores case, spaces and dashes
|
||||
//! and takes `O` as `0` and `I` or `L` as `1`.
|
||||
//!
|
||||
//! - An **online** code ([`ONLINE_LEN`] symbols, 45 random bits, shown `K7M-2QX-9TD`) is a name a
|
||||
//! server gave one share link: the server is asked which file it opens.
|
||||
//! - A **local network** code ([`LOCAL_LEN`] symbols, shown in fives) has no server to ask, so it
|
||||
//! carries what a joiner needs ([`Local`]): the host's IPv4 address, its port, the session's
|
||||
//! 44-bit secret and 8 check bits that catch typing mistakes before anything connects.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
/// Crockford's base32 symbols, by value.
|
||||
const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
|
||||
/// Symbols in an online code.
|
||||
pub const ONLINE_LEN: usize = 9;
|
||||
/// Symbols in a local network code.
|
||||
pub const LOCAL_LEN: usize = 20;
|
||||
/// Bits of a local session's secret.
|
||||
const SECRET_BITS: u32 = 44;
|
||||
/// Longest text read as a code (anything longer isn't one).
|
||||
const MAX_TYPED: usize = 128;
|
||||
|
||||
/// The file id every local session uses (its port tells sessions apart).
|
||||
pub const LOCAL_FILE: &str = "local";
|
||||
const TOKEN_PREFIX: &str = "local-";
|
||||
|
||||
/// The value of a typed symbol: its own, or the digit it is mistaken for.
|
||||
fn value(c: char) -> Option<u8> {
|
||||
let c = match c.to_ascii_uppercase() {
|
||||
'O' => '0',
|
||||
'I' | 'L' => '1',
|
||||
c => c,
|
||||
};
|
||||
ALPHABET.iter().position(|a| char::from(*a) == c).and_then(|i| u8::try_from(i).ok())
|
||||
}
|
||||
|
||||
/// The symbol for the low five bits of `v`.
|
||||
fn symbol(v: u8) -> char {
|
||||
char::from(ALPHABET.get(usize::from(v & 31)).copied().unwrap_or(b'0'))
|
||||
}
|
||||
|
||||
/// The lowest `n` symbols of `v`, most significant first.
|
||||
fn symbols(v: u128, n: usize) -> String {
|
||||
(0..n).rev().map(|i| symbol((v >> (5 * i)).to_le_bytes()[0])).collect()
|
||||
}
|
||||
|
||||
/// What someone typed, as a code's symbols: capitals, without spaces and dashes, look-alikes
|
||||
/// read as the digits they stand for. Errors say what to fix (they never echo the text).
|
||||
pub fn normalize(text: &str) -> Result<String, String> {
|
||||
if text.len() > MAX_TYPED {
|
||||
return Err("that is too long to be a join code".into());
|
||||
}
|
||||
let typed = text.chars().filter(|c| !c.is_whitespace() && !matches!(c, '-' | '–' | '—' | '_' | '.'));
|
||||
let code = typed
|
||||
.map(|c| value(c).map(symbol))
|
||||
.collect::<Option<String>>()
|
||||
.ok_or("a join code has only letters and digits (and no U): check it and try again")?;
|
||||
if code.is_empty() {
|
||||
return Err("type the join code you were given".into());
|
||||
}
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// A code as it is shown: in groups, with dashes (`K7M-2QX-9TD`; local codes in fives).
|
||||
pub fn grouped(code: &str) -> String {
|
||||
let n = if code.len() == ONLINE_LEN { 3 } else { 5 };
|
||||
let chars: Vec<char> = code.chars().collect();
|
||||
chars.chunks(n).map(|c| c.iter().collect::<String>()).collect::<Vec<_>>().join("-")
|
||||
}
|
||||
|
||||
/// An online code from 45 random bits (the rest of `random` is ignored).
|
||||
pub fn online(random: u64) -> String {
|
||||
symbols(u128::from(random), ONLINE_LEN)
|
||||
}
|
||||
|
||||
/// What a local network code carries.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Local {
|
||||
pub ip: Ipv4Addr,
|
||||
pub port: u16,
|
||||
/// The session's secret ([`SECRET_BITS`] bits).
|
||||
pub secret: u64,
|
||||
}
|
||||
|
||||
impl Local {
|
||||
/// For a session at `ip:port` with a secret made from `random` (its low 44 bits).
|
||||
pub fn new(ip: Ipv4Addr, port: u16, random: u64) -> Self {
|
||||
Self { ip, port, secret: random & ((1 << SECRET_BITS) - 1) }
|
||||
}
|
||||
|
||||
/// Address, port and secret as one number (92 bits).
|
||||
fn bits(&self) -> u128 {
|
||||
(u128::from(u32::from(self.ip)) << 60) | (u128::from(self.port) << SECRET_BITS) | u128::from(self.secret)
|
||||
}
|
||||
|
||||
/// The code's symbols (show them [`grouped`]).
|
||||
pub fn code(&self) -> String {
|
||||
let bits = self.bits();
|
||||
symbols((bits << 8) | u128::from(check(bits)), LOCAL_LEN)
|
||||
}
|
||||
|
||||
/// Read a [`normalize`]d local code.
|
||||
fn read(code: &str) -> Result<Local, String> {
|
||||
const TYPO: &str = "that local network code has a typing mistake: check it and try again";
|
||||
let v = code.chars().try_fold(0u128, |v, c| value(c).map(|s| (v << 5) | u128::from(s))).ok_or(TYPO)?;
|
||||
let bits = v >> 8;
|
||||
let local = Local {
|
||||
ip: Ipv4Addr::from(u32::try_from(bits >> 60).map_err(|_| TYPO)?),
|
||||
port: u16::try_from((bits >> SECRET_BITS) & 0xFFFF).map_err(|_| TYPO)?,
|
||||
secret: u64::try_from(bits & ((1 << SECRET_BITS) - 1)).map_err(|_| TYPO)?,
|
||||
};
|
||||
if u128::from(check(bits)) != v & 0xFF || local.port == 0 || local.ip.is_unspecified() {
|
||||
return Err(TYPO.into());
|
||||
}
|
||||
Ok(local)
|
||||
}
|
||||
|
||||
/// The session's token: what its host checks a hello against.
|
||||
pub fn token(&self) -> String {
|
||||
format!("{TOKEN_PREFIX}{:011x}", self.secret)
|
||||
}
|
||||
|
||||
/// The session's invite link (what a joiner's app connects with).
|
||||
pub fn link(&self) -> String {
|
||||
format!("figcraft://join?host={}:{}&file={LOCAL_FILE}&token={}", self.ip, self.port, self.token())
|
||||
}
|
||||
|
||||
/// The session behind a local session's invite link (`None` for any other link: only IPv4
|
||||
/// hosts with a code-sized secret have a code).
|
||||
pub fn from_link(link: &str) -> Option<Local> {
|
||||
let query = link.strip_prefix("figcraft://join?")?;
|
||||
let get = |key: &str| query.split('&').find_map(|kv| kv.split_once('=').filter(|(k, _)| *k == key).map(|(_, v)| v));
|
||||
let addr: std::net::SocketAddrV4 = get("host")?.parse().ok()?;
|
||||
let hex = get("token")?.strip_prefix(TOKEN_PREFIX).filter(|h| h.len() == 11)?;
|
||||
let local = Local { ip: *addr.ip(), port: addr.port(), secret: u64::from_str_radix(hex, 16).ok()? };
|
||||
(get("file") == Some(LOCAL_FILE) && local.port != 0).then_some(local)
|
||||
}
|
||||
}
|
||||
|
||||
/// Eight check bits over a local code's 92 bits (FNV-1a, folded): not a secret, only a typo catcher.
|
||||
fn check(bits: u128) -> u8 {
|
||||
let h = bits.to_be_bytes().iter().fold(0x811C_9DC5_u32, |h, b| (h ^ u32::from(*b)).wrapping_mul(0x0100_0193));
|
||||
h.to_be_bytes().iter().fold(0, |a, b| a ^ b)
|
||||
}
|
||||
|
||||
/// A code someone typed, by kind.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Code {
|
||||
/// A server's code (its symbols): the server says which file it opens.
|
||||
Online(String),
|
||||
Local(Local),
|
||||
}
|
||||
|
||||
/// Read what someone typed as a join code of either kind.
|
||||
pub fn parse(text: &str) -> Result<Code, String> {
|
||||
let code = normalize(text)?;
|
||||
match code.len() {
|
||||
ONLINE_LEN => Ok(Code::Online(code)),
|
||||
LOCAL_LEN => Local::read(&code).map(Code::Local),
|
||||
n => Err(format!("a join code has {ONLINE_LEN} characters ({LOCAL_LEN} on the local network); that one has {n}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "code_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Join codes: what typing tolerates, both kinds round trip, and tampered local codes are caught.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn typed_codes_are_normalised() {
|
||||
let table = [
|
||||
("K7M-2QX-9TD", Ok("K7M2QX9TD")),
|
||||
(" k7m 2qx 9td\n", Ok("K7M2QX9TD")),
|
||||
("k7m2qx9td", Ok("K7M2QX9TD")),
|
||||
("K7M–2QX—9TD", Ok("K7M2QX9TD")),
|
||||
("K7M_2QX.9TD", Ok("K7M2QX9TD")),
|
||||
// Look-alikes are the digits they are mistaken for.
|
||||
("O0o-IiL-l1X", Ok("00011111X")),
|
||||
("", Err("type the join code")),
|
||||
(" - ", Err("type the join code")),
|
||||
("K7M-2QU-9TD", Err("no U")),
|
||||
("K7M/2QX", Err("only letters and digits")),
|
||||
("K7M2QX9T€", Err("only letters and digits")),
|
||||
("figcraft://join?host=1.2.3.4:5&file=f&token=t", Err("only letters and digits")),
|
||||
];
|
||||
for (typed, want) in table {
|
||||
match (normalize(typed), want) {
|
||||
(Ok(got), Ok(want)) => assert_eq!(got, want, "{typed:?}"),
|
||||
(Err(e), Err(part)) => assert!(e.contains(part), "{typed:?}: {e}"),
|
||||
(got, want) => panic!("{typed:?}: got {got:?}, wanted {want:?}"),
|
||||
}
|
||||
}
|
||||
assert!(normalize(&"A".repeat(500)).is_err(), "too long to be a code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_never_echo_what_was_typed() {
|
||||
for typed in ["s3cr3t!!", "SECRETSECRETSECRETSECRE", "figcraft://join?token=hunter2hunter2hunter2"] {
|
||||
let e = parse(typed).unwrap_err();
|
||||
assert!(!e.to_lowercase().contains(&typed.to_lowercase()[..6]), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_codes_are_nine_unambiguous_symbols() {
|
||||
for random in [0, 1, u64::MAX, 0x1234_5678_9ABC_DEF0] {
|
||||
let code = online(random);
|
||||
assert_eq!(code.len(), ONLINE_LEN);
|
||||
assert!(code.chars().all(|c| c.is_ascii_digit() || (c.is_ascii_uppercase() && !"ILOU".contains(c))), "{code}");
|
||||
assert_eq!(parse(&grouped(&code).to_lowercase()), Ok(Code::Online(code)));
|
||||
}
|
||||
assert_eq!(grouped("K7M2QX9TD"), "K7M-2QX-9TD");
|
||||
assert_ne!(online(1), online(2));
|
||||
// Only 45 bits count.
|
||||
assert_eq!(online(1 << 45), online(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_codes_round_trip() {
|
||||
for (ip, port, random) in [([192, 168, 1, 20], 47_900, 0x0ABC_DEF0_1234_u64), ([10, 0, 0, 1], 1, 0), ([127, 0, 0, 1], 65_535, u64::MAX)] {
|
||||
let local = Local::new(Ipv4Addr::from(ip), port, random);
|
||||
let code = local.code();
|
||||
assert_eq!(code.len(), LOCAL_LEN);
|
||||
let shown = grouped(&code);
|
||||
assert_eq!(shown.len(), LOCAL_LEN + 3, "four groups of five: {shown}");
|
||||
assert_eq!(parse(&shown), Ok(Code::Local(local)));
|
||||
assert_eq!(parse(&shown.to_lowercase().replace('-', " ")), Ok(Code::Local(local)));
|
||||
// The link a joiner connects with names the same session, and gives the code back.
|
||||
let link = local.link();
|
||||
assert_eq!(link, format!("figcraft://join?host={}:{port}&file=local&token={}", Ipv4Addr::from(ip), local.token()));
|
||||
assert_eq!(local.token().len(), 17);
|
||||
assert_eq!(Local::from_link(&link), Some(local));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_local_codes_are_caught() {
|
||||
let code = Local::new(Ipv4Addr::new(192, 168, 1, 20), 47_900, 0x123_4567_89AB).code();
|
||||
let symbols = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
let (mut tried, mut missed) = (0, 0);
|
||||
for at in 0..code.len() {
|
||||
for s in symbols.chars().filter(|s| code.chars().nth(at) != Some(*s)) {
|
||||
let mut changed: Vec<char> = code.chars().collect();
|
||||
changed[at] = s;
|
||||
tried += 1;
|
||||
missed += usize::from(parse(&changed.iter().collect::<String>()).is_ok());
|
||||
}
|
||||
}
|
||||
// Eight check bits: about one changed symbol in 256 slips through (and then fails to join).
|
||||
assert!(missed * 50 < tried, "{missed} of {tried} single-symbol changes went unnoticed");
|
||||
// Two symbols swapped.
|
||||
let mut swapped: Vec<char> = code.chars().collect();
|
||||
let at = (0..19).find(|i| swapped[*i] != swapped[*i + 1]).unwrap();
|
||||
swapped.swap(at, at + 1);
|
||||
assert!(parse(&swapped.iter().collect::<String>()).unwrap_err().contains("typing mistake"));
|
||||
// Wrong lengths say what a code looks like.
|
||||
assert!(parse(&code[..19]).unwrap_err().contains("9 characters"));
|
||||
assert!(parse("K7M2QX9T").unwrap_err().contains("that one has 8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_local_session_links_have_a_code() {
|
||||
for link in [
|
||||
"figcraft://join?host=192.168.1.20:47900&file=abc&token=local-0123456789a",
|
||||
"figcraft://join?host=192.168.1.20:47900&file=local&token=AbCdEfGhIjKlMnOpQrStUv",
|
||||
"figcraft://join?host=[::1]:47900&file=local&token=local-0123456789a",
|
||||
"figcraft://join?host=192.168.1.20:0&file=local&token=local-0123456789a",
|
||||
"figcraft://join?host=192.168.1.20:47900&file=local&token=local-0123456789az",
|
||||
"figcraft://join?server=wss://example.org/&file=local&token=local-0123456789a",
|
||||
"ws://192.168.1.20:47900/?file=local&token=local-0123456789a",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(Local::from_link(link), None, "{link}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_messages_go_last_on_the_wire() {
|
||||
use crate::message::{Admin, AdminReply, ClientMsg, Resolve, ServerMsg, encode};
|
||||
// postcard writes a variant's index: an older peer reads everything it knew unchanged, and
|
||||
// fails only on the variants added for join codes (which is how the app tells).
|
||||
let tag = |bytes: Vec<u8>| bytes[1];
|
||||
let ask = Resolve { protocol: 2, client_version: String::new(), code: "K7M2QX9TD".into() };
|
||||
assert_eq!(tag(encode(&ClientMsg::Ping).unwrap()), 4);
|
||||
assert_eq!(tag(encode(&ClientMsg::Report { reason: String::new() }).unwrap()), 13);
|
||||
assert_eq!(tag(encode(&ClientMsg::Resolve(ask)).unwrap()), 14);
|
||||
assert_eq!(tag(encode(&ServerMsg::Pong).unwrap()), 7);
|
||||
assert_eq!(tag(encode(&ServerMsg::Reported { error: None }).unwrap()), 13);
|
||||
assert_eq!(tag(encode(&ServerMsg::Resolved { file: String::new(), token: String::new() }).unwrap()), 14);
|
||||
assert_eq!(tag(encode(&Admin::Blocked).unwrap()), 11);
|
||||
assert_eq!(tag(encode(&Admin::Code { file: String::new(), token: String::new() }).unwrap()), 12);
|
||||
assert_eq!(tag(encode(&AdminReply::Blocked(Vec::new())).unwrap()), 10);
|
||||
assert_eq!(tag(encode(&AdminReply::Code { code: String::new() }).unwrap()), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_never_panics() {
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15_u64;
|
||||
for _ in 0..20_000 {
|
||||
let len = (seed % 40) as usize;
|
||||
let text: String = (0..len)
|
||||
.map(|_| {
|
||||
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
||||
char::from_u32((seed >> 40) as u32 % 0x250).unwrap_or('-')
|
||||
})
|
||||
.collect();
|
||||
let _ = parse(&text);
|
||||
let _ = Local::from_link(&text);
|
||||
let _ = grouped(&text);
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
||||
ClientMsg::Admin(Admin::Block { ip: "203.0.113.9".into() }),
|
||||
ClientMsg::Admin(Admin::Unblock { ip: "2001:db8::".into() }),
|
||||
ClientMsg::Admin(Admin::Blocked),
|
||||
ClientMsg::Admin(Admin::RemovePeer { file: "f".into(), client: 7 }),
|
||||
ClientMsg::Report { reason: "Spam ☕".into() },
|
||||
];
|
||||
let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap();
|
||||
|
||||
@@ -109,7 +109,13 @@ fn kind_changes_and_transforms_round_trip() {
|
||||
}
|
||||
|
||||
fn style(id: &str) -> Style {
|
||||
Style { id: id.into(), name: format!("S {id}"), description: String::new(), kind: StyleKind::Paint { paints: vec![Paint::solid(Color::BLACK)] } }
|
||||
Style {
|
||||
id: id.into(),
|
||||
name: format!("S {id}"),
|
||||
description: String::new(),
|
||||
kind: StyleKind::Paint { paints: vec![Paint::solid(Color::BLACK)] },
|
||||
bound: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::sim::Sim;
|
||||
use crate::{Client, Limits, NoRecompute, Outgoing, Server};
|
||||
|
||||
fn style(id: String, name: &str) -> Style {
|
||||
Style { id, name: name.into(), description: String::new(), kind: StyleKind::Paint { paints: Vec::new() } }
|
||||
Style { id, name: name.into(), description: String::new(), kind: StyleKind::Paint { paints: Vec::new() }, bound: Default::default() }
|
||||
}
|
||||
|
||||
fn comment(id: u64, doc: &Document) -> Comment {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
pub mod change;
|
||||
pub mod client;
|
||||
pub mod code;
|
||||
mod diff;
|
||||
mod docprops;
|
||||
pub mod frac;
|
||||
@@ -122,6 +123,8 @@ impl Recompute for NoRecompute {
|
||||
fn recompute(&self, _: &mut Document, _: &Document, _: &BTreeSet<NodeId>) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod client_tests;
|
||||
#[cfg(test)]
|
||||
mod codec_tests;
|
||||
#[cfg(test)]
|
||||
@@ -137,6 +140,8 @@ mod merge_list_tests;
|
||||
#[cfg(test)]
|
||||
mod merge_tests;
|
||||
#[cfg(test)]
|
||||
mod owned_tests;
|
||||
#[cfg(test)]
|
||||
mod reconnect_tests;
|
||||
#[cfg(test)]
|
||||
mod server_tests;
|
||||
|
||||
@@ -27,7 +27,13 @@ fn files() -> Files {
|
||||
}
|
||||
|
||||
fn style(id: &str, c: Color) -> Style {
|
||||
Style { id: id.into(), name: format!("Style {id}"), description: String::new(), kind: StyleKind::Paint { paints: vec![Paint::solid(c)] } }
|
||||
Style {
|
||||
id: id.into(),
|
||||
name: format!("Style {id}"),
|
||||
description: String::new(),
|
||||
kind: StyleKind::Paint { paints: vec![Paint::solid(c)] },
|
||||
bound: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_of(f: &Files) -> Plan {
|
||||
|
||||
@@ -266,6 +266,32 @@ pub enum Admin {
|
||||
Unblock { ip: String },
|
||||
/// The blocked addresses.
|
||||
Blocked,
|
||||
/// A join code for the link of `file` whose token this is (the token proves the link and lets
|
||||
/// the server give it to whoever types the code): [`AdminReply::Code`]. A link has one code:
|
||||
/// asking again replaces it. Servers older than join codes can't read this request.
|
||||
Code { file: String, token: String },
|
||||
/// On an open hosting connection (no file named in its opening): what became of the files
|
||||
/// these `(file id, owner token)` pairs own, at most [`MAX_OWNED`] of them:
|
||||
/// [`AdminReply::Owned`]. Servers older than this request can't read it.
|
||||
Owned { files: Vec<(String, String)> },
|
||||
/// Remove client `client` from `file`'s session: they are disconnected and their address
|
||||
/// can't join the file again while its session lasts. With the server key, or on an open
|
||||
/// hosting connection with `file`'s owner token. Servers older than this request can't read it.
|
||||
RemovePeer { file: String, client: u32 },
|
||||
}
|
||||
|
||||
/// The most files one [`Admin::Owned`] asks about.
|
||||
pub const MAX_OWNED: usize = 200;
|
||||
|
||||
/// A file its owner asked about ([`Admin::Owned`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OwnedFile {
|
||||
/// Its name on the server.
|
||||
pub name: String,
|
||||
/// The ids of its share links (the owner's own and the ones made for others).
|
||||
pub links: Vec<String>,
|
||||
/// When it is deleted if nobody opens it (seconds since 1970).
|
||||
pub expires: Option<u64>,
|
||||
}
|
||||
|
||||
/// A file a server keeps.
|
||||
@@ -336,6 +362,13 @@ pub enum AdminReply {
|
||||
Listing(Vec<FileRow>),
|
||||
Reports(Vec<ReportInfo>),
|
||||
Blocked(Vec<String>),
|
||||
/// A link's new join code (shown this once; the server keeps only a hash).
|
||||
Code {
|
||||
code: String,
|
||||
},
|
||||
/// The answer to [`Admin::Owned`], one per pair asked about, in order. `None`: no file of that
|
||||
/// id is managed by that token here (removed, expired, or never there).
|
||||
Owned(Vec<Option<OwnedFile>>),
|
||||
}
|
||||
|
||||
/// A file with what its operator moderates by.
|
||||
@@ -380,6 +413,17 @@ pub struct OpenHosting {
|
||||
pub owner: String,
|
||||
}
|
||||
|
||||
/// Asks a server which file a join code opens (the first message on a connection, instead of
|
||||
/// [`Hello`]). Answered with [`ServerMsg::Resolved`], then the connection closes; a wrong code, too
|
||||
/// many wrong ones from an address, or a server that hosts no files is `Refused`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Resolve {
|
||||
pub protocol: u32,
|
||||
pub client_version: String,
|
||||
/// The code's symbols ([`crate::code::normalize`]d).
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
/// What a server says about its hosting: whether anyone may publish, who runs it and its terms,
|
||||
/// how long files published without the key are kept unopened, and the largest file.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -580,6 +624,8 @@ pub enum ClientMsg {
|
||||
Report {
|
||||
reason: String,
|
||||
},
|
||||
/// Ask which file a join code opens (the first message on a connection, instead of `Hello`).
|
||||
Resolve(Resolve),
|
||||
}
|
||||
|
||||
/// Server → client.
|
||||
@@ -642,6 +688,12 @@ pub enum ServerMsg {
|
||||
Reported {
|
||||
error: Option<String>,
|
||||
},
|
||||
/// The answer to [`ClientMsg::Resolve`]: the file the code opens and its link's token (what a
|
||||
/// `Hello` joins with).
|
||||
Resolved {
|
||||
file: String,
|
||||
token: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! `Admin::Owned` and its answer on the wire: both encodings round-trip, the new variants sit at
|
||||
//! the end of their enums (so every message an older peer reads keeps its bytes), and an older
|
||||
//! peer, which doesn't know them, fails to read them instead of misreading them.
|
||||
|
||||
use crate::message::{Admin, AdminReply, ClientMsg, OwnedFile, ServerMsg, decode, decode_json, encode, encode_json};
|
||||
|
||||
fn asked() -> ClientMsg {
|
||||
ClientMsg::Admin(Admin::Owned { files: vec![("f1".into(), "OwnerToken".into()), (String::new(), String::new())] })
|
||||
}
|
||||
|
||||
fn answered() -> ServerMsg {
|
||||
let file = OwnedFile { name: "Homepage".into(), links: vec!["a".into(), "b".into()], expires: Some(1_900_000_000) };
|
||||
ServerMsg::Admin(Box::new(AdminReply::Owned(vec![Some(file), None])))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_encodings_round_trip() {
|
||||
let (c, s) = (asked(), answered());
|
||||
assert_eq!(decode::<ClientMsg>(&encode(&c).unwrap(), 1 << 20).unwrap(), c);
|
||||
assert_eq!(decode::<ServerMsg>(&encode(&s).unwrap(), 1 << 20).unwrap(), s);
|
||||
let json = encode_json(&c).unwrap();
|
||||
assert!(json.contains(r#""owned":{"files":[["f1","OwnerToken"],["",""]]}"#), "{json}");
|
||||
assert_eq!(decode_json::<ClientMsg>(&json, 1 << 20).unwrap(), c);
|
||||
let json = encode_json(&s).unwrap();
|
||||
assert!(json.contains(r#""owned":[{"name":"Homepage","links":["a","b"],"expires":1900000000},null]"#), "{json}");
|
||||
assert_eq!(decode_json::<ServerMsg>(&json, 1 << 20).unwrap(), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_variants_are_the_last_of_their_enums() {
|
||||
// Postcard writes a variant as its index: the last byte before the payload of an `Admin`
|
||||
// request is the variant's. `Code` was the last one before; `Owned` follows it.
|
||||
let index = |a: Admin| postcard::to_allocvec(&a).unwrap()[0];
|
||||
let code = index(Admin::Code { file: String::new(), token: String::new() });
|
||||
assert_eq!(index(Admin::Owned { files: vec![] }), code + 1);
|
||||
let index = |r: AdminReply| postcard::to_allocvec(&r).unwrap()[0];
|
||||
assert_eq!(index(AdminReply::Owned(vec![])), index(AdminReply::Code { code: String::new() }) + 1);
|
||||
// A peer from before them reads an unknown variant as an error, never as another message:
|
||||
// one index further is as unknown to this version as `Owned` is to an older one.
|
||||
let mut bytes = encode(&asked()).unwrap();
|
||||
let at = bytes.iter().position(|b| *b == code + 1).unwrap();
|
||||
bytes[at] = code + 2;
|
||||
assert!(decode::<ClientMsg>(&bytes, 1 << 20).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_answers_are_refused_by_size_not_trusted() {
|
||||
// A count that promises more than the message holds doesn't allocate or read past the end.
|
||||
let mut bytes = encode(&answered()).unwrap();
|
||||
bytes.truncate(bytes.len() - 3);
|
||||
assert!(decode::<ServerMsg>(&bytes, 1 << 20).is_err());
|
||||
assert!(decode::<ServerMsg>(&encode(&answered()).unwrap(), 4).is_err(), "over the size limit");
|
||||
}
|
||||
@@ -392,6 +392,7 @@ impl Server {
|
||||
| ClientMsg::OpenPlugins(_)
|
||||
| ClientMsg::Plugins(_)
|
||||
| ClientMsg::OpenHosting(_)
|
||||
| ClientMsg::Resolve(_)
|
||||
| ClientMsg::Report { .. } => {
|
||||
vec![Outgoing::To(from, ServerMsg::Refused { reason: "managing files and libraries needs a connection of its own".into() })]
|
||||
}
|
||||
|
||||
@@ -109,7 +109,13 @@ pub(crate) fn random_edit(doc: &mut Document, rng: &mut Rng, derive: &dyn Recomp
|
||||
doc.styles.remove(i);
|
||||
}
|
||||
Some(i) => doc.styles[i].name = format!("Renamed {}", rng.below(100)),
|
||||
None => doc.styles.push(Style { id, name: "S".into(), description: String::new(), kind: StyleKind::Paint { paints: vec![] } }),
|
||||
None => doc.styles.push(Style {
|
||||
id,
|
||||
name: "S".into(),
|
||||
description: String::new(),
|
||||
kind: StyleKind::Paint { paints: vec![] },
|
||||
bound: Default::default(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-16
@@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
|
||||
mod blend;
|
||||
mod hsx;
|
||||
mod icc;
|
||||
mod named;
|
||||
mod profile;
|
||||
pub use blend::BlendMode;
|
||||
pub use hsx::*;
|
||||
@@ -77,6 +78,8 @@ impl Color {
|
||||
pub const DEFAULT_SHAPE: Color = Color::from_rgb8_const(0xD9, 0xD9, 0xD9);
|
||||
/// Default page background (#F5F5F5).
|
||||
pub const DEFAULT_PAGE: Color = Color::from_rgb8_const(0xF5, 0xF5, 0xF5);
|
||||
/// The background of a page made while the interface shows its dark theme (#1E1E1E).
|
||||
pub const DARK_PAGE: Color = Color::from_rgb8_const(0x1E, 0x1E, 0x1E);
|
||||
|
||||
pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self {
|
||||
Self { r: clamp01(r), g: clamp01(g), b: clamp01(b), a: clamp01(a) }
|
||||
@@ -137,7 +140,13 @@ impl Color {
|
||||
Some(Self::from_rgba8(r, g, b, a))
|
||||
}
|
||||
|
||||
/// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()` or a few common names.
|
||||
/// What a colour field accepts: hex as [`Color::from_hex`] reads it, or a CSS colour name
|
||||
/// (`red` → `FF0000`, `Cornflower Blue` → `6495ED`; opaque).
|
||||
pub fn from_hex_or_name(s: &str) -> Option<Self> {
|
||||
Self::from_hex(s).or_else(|| named::lookup(s).map(|[r, g, b]| Self::from_rgba8(r, g, b, 255)))
|
||||
}
|
||||
|
||||
/// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()`, a CSS colour name or `transparent`.
|
||||
pub fn parse_css(s: &str) -> Option<Self> {
|
||||
let t = s.trim().to_ascii_lowercase();
|
||||
if t.starts_with('#') {
|
||||
@@ -164,21 +173,10 @@ impl Color {
|
||||
let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0);
|
||||
return Some(Hsl { h, s, l }.to_color().with_alpha(alpha));
|
||||
}
|
||||
let named = match t.as_str() {
|
||||
"black" => Color::BLACK,
|
||||
"white" => Color::WHITE,
|
||||
"transparent" => Color::TRANSPARENT,
|
||||
"red" => Color::from_rgb8_const(255, 0, 0),
|
||||
"green" => Color::from_rgb8_const(0, 128, 0),
|
||||
"lime" => Color::from_rgb8_const(0, 255, 0),
|
||||
"blue" => Color::from_rgb8_const(0, 0, 255),
|
||||
"yellow" => Color::from_rgb8_const(255, 255, 0),
|
||||
"orange" => Color::from_rgb8_const(255, 165, 0),
|
||||
"purple" => Color::from_rgb8_const(128, 0, 128),
|
||||
"gray" | "grey" => Color::from_rgb8_const(128, 128, 128),
|
||||
_ => return Self::from_hex(&t),
|
||||
};
|
||||
Some(named)
|
||||
if t == "transparent" {
|
||||
return Some(Color::TRANSPARENT);
|
||||
}
|
||||
Self::from_hex_or_name(&t)
|
||||
}
|
||||
|
||||
/// CSS `rgba(...)` / hex text for copy-as-CSS.
|
||||
@@ -199,6 +197,11 @@ impl Color {
|
||||
0.2126 * lin(self.r) + 0.7152 * lin(self.g) + 0.0722 * lin(self.b)
|
||||
}
|
||||
|
||||
/// Whether black on this colour reads worse than white (a dark page: new text is white).
|
||||
pub fn is_dark(self) -> bool {
|
||||
self.contrast(Color::WHITE) > self.contrast(Color::BLACK)
|
||||
}
|
||||
|
||||
/// WCAG contrast ratio between two opaque colours.
|
||||
pub fn contrast(self, other: Color) -> f32 {
|
||||
let (a, b) = (self.luminance() + 0.05, other.luminance() + 0.05);
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! The CSS named colours (CSS Color Module Level 4 §6.1), so a colour field takes `red` as well as
|
||||
//! `FF0000`.
|
||||
|
||||
/// Every CSS named colour except `transparent`, sorted by name for binary search.
|
||||
const NAMED: [(&str, [u8; 3]); 148] = [
|
||||
("aliceblue", [240, 248, 255]),
|
||||
("antiquewhite", [250, 235, 215]),
|
||||
("aqua", [0, 255, 255]),
|
||||
("aquamarine", [127, 255, 212]),
|
||||
("azure", [240, 255, 255]),
|
||||
("beige", [245, 245, 220]),
|
||||
("bisque", [255, 228, 196]),
|
||||
("black", [0, 0, 0]),
|
||||
("blanchedalmond", [255, 235, 205]),
|
||||
("blue", [0, 0, 255]),
|
||||
("blueviolet", [138, 43, 226]),
|
||||
("brown", [165, 42, 42]),
|
||||
("burlywood", [222, 184, 135]),
|
||||
("cadetblue", [95, 158, 160]),
|
||||
("chartreuse", [127, 255, 0]),
|
||||
("chocolate", [210, 105, 30]),
|
||||
("coral", [255, 127, 80]),
|
||||
("cornflowerblue", [100, 149, 237]),
|
||||
("cornsilk", [255, 248, 220]),
|
||||
("crimson", [220, 20, 60]),
|
||||
("cyan", [0, 255, 255]),
|
||||
("darkblue", [0, 0, 139]),
|
||||
("darkcyan", [0, 139, 139]),
|
||||
("darkgoldenrod", [184, 134, 11]),
|
||||
("darkgray", [169, 169, 169]),
|
||||
("darkgreen", [0, 100, 0]),
|
||||
("darkgrey", [169, 169, 169]),
|
||||
("darkkhaki", [189, 183, 107]),
|
||||
("darkmagenta", [139, 0, 139]),
|
||||
("darkolivegreen", [85, 107, 47]),
|
||||
("darkorange", [255, 140, 0]),
|
||||
("darkorchid", [153, 50, 204]),
|
||||
("darkred", [139, 0, 0]),
|
||||
("darksalmon", [233, 150, 122]),
|
||||
("darkseagreen", [143, 188, 143]),
|
||||
("darkslateblue", [72, 61, 139]),
|
||||
("darkslategray", [47, 79, 79]),
|
||||
("darkslategrey", [47, 79, 79]),
|
||||
("darkturquoise", [0, 206, 209]),
|
||||
("darkviolet", [148, 0, 211]),
|
||||
("deeppink", [255, 20, 147]),
|
||||
("deepskyblue", [0, 191, 255]),
|
||||
("dimgray", [105, 105, 105]),
|
||||
("dimgrey", [105, 105, 105]),
|
||||
("dodgerblue", [30, 144, 255]),
|
||||
("firebrick", [178, 34, 34]),
|
||||
("floralwhite", [255, 250, 240]),
|
||||
("forestgreen", [34, 139, 34]),
|
||||
("fuchsia", [255, 0, 255]),
|
||||
("gainsboro", [220, 220, 220]),
|
||||
("ghostwhite", [248, 248, 255]),
|
||||
("gold", [255, 215, 0]),
|
||||
("goldenrod", [218, 165, 32]),
|
||||
("gray", [128, 128, 128]),
|
||||
("green", [0, 128, 0]),
|
||||
("greenyellow", [173, 255, 47]),
|
||||
("grey", [128, 128, 128]),
|
||||
("honeydew", [240, 255, 240]),
|
||||
("hotpink", [255, 105, 180]),
|
||||
("indianred", [205, 92, 92]),
|
||||
("indigo", [75, 0, 130]),
|
||||
("ivory", [255, 255, 240]),
|
||||
("khaki", [240, 230, 140]),
|
||||
("lavender", [230, 230, 250]),
|
||||
("lavenderblush", [255, 240, 245]),
|
||||
("lawngreen", [124, 252, 0]),
|
||||
("lemonchiffon", [255, 250, 205]),
|
||||
("lightblue", [173, 216, 230]),
|
||||
("lightcoral", [240, 128, 128]),
|
||||
("lightcyan", [224, 255, 255]),
|
||||
("lightgoldenrodyellow", [250, 250, 210]),
|
||||
("lightgray", [211, 211, 211]),
|
||||
("lightgreen", [144, 238, 144]),
|
||||
("lightgrey", [211, 211, 211]),
|
||||
("lightpink", [255, 182, 193]),
|
||||
("lightsalmon", [255, 160, 122]),
|
||||
("lightseagreen", [32, 178, 170]),
|
||||
("lightskyblue", [135, 206, 250]),
|
||||
("lightslategray", [119, 136, 153]),
|
||||
("lightslategrey", [119, 136, 153]),
|
||||
("lightsteelblue", [176, 196, 222]),
|
||||
("lightyellow", [255, 255, 224]),
|
||||
("lime", [0, 255, 0]),
|
||||
("limegreen", [50, 205, 50]),
|
||||
("linen", [250, 240, 230]),
|
||||
("magenta", [255, 0, 255]),
|
||||
("maroon", [128, 0, 0]),
|
||||
("mediumaquamarine", [102, 205, 170]),
|
||||
("mediumblue", [0, 0, 205]),
|
||||
("mediumorchid", [186, 85, 211]),
|
||||
("mediumpurple", [147, 112, 219]),
|
||||
("mediumseagreen", [60, 179, 113]),
|
||||
("mediumslateblue", [123, 104, 238]),
|
||||
("mediumspringgreen", [0, 250, 154]),
|
||||
("mediumturquoise", [72, 209, 204]),
|
||||
("mediumvioletred", [199, 21, 133]),
|
||||
("midnightblue", [25, 25, 112]),
|
||||
("mintcream", [245, 255, 250]),
|
||||
("mistyrose", [255, 228, 225]),
|
||||
("moccasin", [255, 228, 181]),
|
||||
("navajowhite", [255, 222, 173]),
|
||||
("navy", [0, 0, 128]),
|
||||
("oldlace", [253, 245, 230]),
|
||||
("olive", [128, 128, 0]),
|
||||
("olivedrab", [107, 142, 35]),
|
||||
("orange", [255, 165, 0]),
|
||||
("orangered", [255, 69, 0]),
|
||||
("orchid", [218, 112, 214]),
|
||||
("palegoldenrod", [238, 232, 170]),
|
||||
("palegreen", [152, 251, 152]),
|
||||
("paleturquoise", [175, 238, 238]),
|
||||
("palevioletred", [219, 112, 147]),
|
||||
("papayawhip", [255, 239, 213]),
|
||||
("peachpuff", [255, 218, 185]),
|
||||
("peru", [205, 133, 63]),
|
||||
("pink", [255, 192, 203]),
|
||||
("plum", [221, 160, 221]),
|
||||
("powderblue", [176, 224, 230]),
|
||||
("purple", [128, 0, 128]),
|
||||
("rebeccapurple", [102, 51, 153]),
|
||||
("red", [255, 0, 0]),
|
||||
("rosybrown", [188, 143, 143]),
|
||||
("royalblue", [65, 105, 225]),
|
||||
("saddlebrown", [139, 69, 19]),
|
||||
("salmon", [250, 128, 114]),
|
||||
("sandybrown", [244, 164, 96]),
|
||||
("seagreen", [46, 139, 87]),
|
||||
("seashell", [255, 245, 238]),
|
||||
("sienna", [160, 82, 45]),
|
||||
("silver", [192, 192, 192]),
|
||||
("skyblue", [135, 206, 235]),
|
||||
("slateblue", [106, 90, 205]),
|
||||
("slategray", [112, 128, 144]),
|
||||
("slategrey", [112, 128, 144]),
|
||||
("snow", [255, 250, 250]),
|
||||
("springgreen", [0, 255, 127]),
|
||||
("steelblue", [70, 130, 180]),
|
||||
("tan", [210, 180, 140]),
|
||||
("teal", [0, 128, 128]),
|
||||
("thistle", [216, 191, 216]),
|
||||
("tomato", [255, 99, 71]),
|
||||
("turquoise", [64, 224, 208]),
|
||||
("violet", [238, 130, 238]),
|
||||
("wheat", [245, 222, 179]),
|
||||
("white", [255, 255, 255]),
|
||||
("whitesmoke", [245, 245, 245]),
|
||||
("yellow", [255, 255, 0]),
|
||||
("yellowgreen", [154, 205, 50]),
|
||||
];
|
||||
|
||||
/// Longest name in [`NAMED`]; anything longer can't match.
|
||||
const LONGEST: usize = 20;
|
||||
|
||||
/// The opaque sRGB bytes of a CSS colour name. Case, spaces, `-` and `_` are ignored, so
|
||||
/// `Light Blue`, `light-blue` and `lightblue` are the same colour.
|
||||
pub fn lookup(s: &str) -> Option<[u8; 3]> {
|
||||
let key: String = s.chars().filter(|c| !c.is_whitespace() && *c != '-' && *c != '_').take(LONGEST + 1).collect::<String>().to_ascii_lowercase();
|
||||
if key.is_empty() || key.len() > LONGEST {
|
||||
return None;
|
||||
}
|
||||
NAMED.binary_search_by(|(name, _)| name.cmp(&key.as_str())).ok().and_then(|i| NAMED.get(i)).map(|(_, rgb)| *rgb)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_is_sorted_and_unique() {
|
||||
assert!(NAMED.windows(2).all(|w| w[0].0 < w[1].0));
|
||||
assert_eq!(NAMED.iter().map(|(n, _)| n.len()).max(), Some(LONGEST));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_name_is_found() {
|
||||
for (name, rgb) in NAMED {
|
||||
assert_eq!(lookup(name), Some(rgb), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_normalises() {
|
||||
assert_eq!(lookup("Red"), Some([255, 0, 0]));
|
||||
assert_eq!(lookup(" CORNFLOWER blue "), Some([100, 149, 237]));
|
||||
assert_eq!(lookup("light-goldenrod_yellow"), Some([250, 250, 210]));
|
||||
assert_eq!(lookup(""), None);
|
||||
assert_eq!(lookup("transparent"), None);
|
||||
assert_eq!(lookup("reddish"), None);
|
||||
assert_eq!(lookup("rédé"), None);
|
||||
assert_eq!(lookup(&"a".repeat(10_000)), None);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,23 @@ fn css_parsing() {
|
||||
assert_eq!(Color::parse_css("rgba(0 0 255 / 50%)").unwrap().to_rgba8(), [0, 0, 255, 128]);
|
||||
assert_eq!(Color::parse_css("hsl(120, 100%, 50%)").unwrap().to_hex(), "00FF00");
|
||||
assert_eq!(Color::parse_css("white").unwrap(), Color::WHITE);
|
||||
assert_eq!(Color::parse_css("RebeccaPurple").unwrap().to_hex(), "663399");
|
||||
assert_eq!(Color::parse_css("transparent").unwrap(), Color::TRANSPARENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_fields_take_names() {
|
||||
assert_eq!(Color::from_hex_or_name("red").unwrap().to_css_hex(), "#FF0000");
|
||||
assert_eq!(Color::from_hex_or_name(" Cornflower Blue ").unwrap().to_hex(), "6495ED");
|
||||
assert_eq!(Color::from_hex_or_name("darkblue").unwrap().to_rgba8(), [0, 0, 139, 255]);
|
||||
// Hex wins: every digit string still reads as hex.
|
||||
assert_eq!(Color::from_hex_or_name("abc").unwrap().to_hex(), "AABBCC");
|
||||
assert_eq!(Color::from_hex_or_name("#0D99FF").unwrap().to_hex(), "0D99FF");
|
||||
// Names are opaque; `transparent` is not a field colour.
|
||||
assert!(Color::from_hex_or_name("transparent").is_none());
|
||||
for junk in ["", "#", "redd", "re d d", "12345", "\u{0}", "🟥", &"x".repeat(100_000)] {
|
||||
assert!(Color::from_hex_or_name(junk).is_none(), "{junk:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -22,10 +22,17 @@ fn colourful() -> (Document, NodeId) {
|
||||
n.grids = vec![LayoutGrid { color: RED, ..LayoutGrid::default() }];
|
||||
n.interactions = vec![proto::Interaction {
|
||||
trigger: Default::default(),
|
||||
actions: vec![proto::Action::OpenOverlay { dest: p, position: Default::default(), close_on_outside: false, background: Some(RED) }],
|
||||
actions: vec![proto::Action::OpenOverlay {
|
||||
dest: p,
|
||||
position: Default::default(),
|
||||
close_on_outside: false,
|
||||
background: Some(RED),
|
||||
offset: None,
|
||||
}],
|
||||
transition: Default::default(),
|
||||
duration_ms: 300,
|
||||
easing: Default::default(),
|
||||
delay_ms: 0,
|
||||
}];
|
||||
}
|
||||
if let Some(NodeKind::Text(td)) = d.node_mut(t).map(|n| &mut n.kind) {
|
||||
@@ -40,6 +47,7 @@ fn colourful() -> (Document, NodeId) {
|
||||
name: "Red".into(),
|
||||
description: String::new(),
|
||||
kind: StyleKind::Paint { paints: vec![Paint::solid(RED)] },
|
||||
bound: Default::default(),
|
||||
});
|
||||
d.variables.collections.push(Collection {
|
||||
id: "c1".into(),
|
||||
|
||||
+29
-10
@@ -106,7 +106,7 @@ fn near_path(path: &BezPath, p: Point, dist: f64) -> bool {
|
||||
/// boolean groups are hit where their `result` is.
|
||||
pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
|
||||
let p = doc.node(page)?;
|
||||
p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, false).filter(|_| !masked_out(doc, p, *c, pt)))
|
||||
p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, None).filter(|_| !masked_out(doc, p, *c, pt)))
|
||||
}
|
||||
|
||||
/// Whether child `c` of `parent` is masked at `pt`: a visible mask below it (the nearest mask
|
||||
@@ -129,24 +129,42 @@ fn covers(doc: &Document, id: NodeId, n: &Node, pt: Point, depth: usize) -> bool
|
||||
|
||||
/// Like [`hit_leaf`], but only within layer `root` (it included).
|
||||
pub fn hit_in(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
|
||||
hit_rec(doc, root, pt, tol, 0, result, false)
|
||||
hit_rec(doc, root, pt, tol, 0, result, None)
|
||||
}
|
||||
|
||||
/// [`hit_leaf`] (or [`hit_in`] for a `root` that isn't a page) in outline mode: shapes are hit
|
||||
/// where their outlines are, so a click inside one reaches the layers drawn behind it.
|
||||
pub fn hit_outlines(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
|
||||
///
|
||||
/// `also` is what outline mode draws besides the shapes: hidden layers are hit like the others
|
||||
/// when it shows them, and every layer on the edge of its box when it shows object bounds.
|
||||
pub fn hit_outlines(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult, also: OutlineHits) -> Option<NodeId> {
|
||||
match doc.node(root) {
|
||||
Some(p) if p.is_page() => p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, true)),
|
||||
_ => hit_rec(doc, root, pt, tol, 0, result, true),
|
||||
Some(p) if p.is_page() => p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, Some(also))),
|
||||
_ => hit_rec(doc, root, pt, tol, 0, result, Some(also)),
|
||||
}
|
||||
}
|
||||
|
||||
fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, outline: bool) -> Option<NodeId> {
|
||||
/// What outline mode shows besides the shapes' outlines (see [`hit_outlines`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct OutlineHits {
|
||||
pub hidden: bool,
|
||||
pub bounds: bool,
|
||||
}
|
||||
|
||||
/// Whether `pt` is within `tol` of the edge of `n`'s box.
|
||||
fn on_box_edge(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64) -> bool {
|
||||
let lp = doc.world_transform(id).inverse() * pt;
|
||||
let r = n.local_rect();
|
||||
r.inflate(tol, tol).contains(lp) && !(r.width() > 2.0 * tol && r.height() > 2.0 * tol && r.inflate(-tol, -tol).contains(lp))
|
||||
}
|
||||
|
||||
fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, also: Option<OutlineHits>) -> Option<NodeId> {
|
||||
if depth > 512 {
|
||||
return None;
|
||||
}
|
||||
let n = doc.node(id)?;
|
||||
if !n.visible || n.locked {
|
||||
let outline = also.is_some();
|
||||
if (!n.visible && !also.is_some_and(|a| a.hidden)) || n.locked {
|
||||
return None;
|
||||
}
|
||||
if let NodeKind::Section { contents_hidden: true } = n.kind {
|
||||
@@ -164,11 +182,12 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result
|
||||
return None;
|
||||
}
|
||||
for c in n.paint_order().rev() {
|
||||
if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result, outline).filter(|_| !masked_out(doc, n, c, pt)) {
|
||||
if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result, also).filter(|_| !masked_out(doc, n, c, pt)) {
|
||||
return Some(h);
|
||||
}
|
||||
}
|
||||
if in_result == Some(true) || hits_node_itself(doc, id, n, pt, tol, outline) {
|
||||
if in_result == Some(true) || hits_node_itself(doc, id, n, pt, tol, outline) || also.is_some_and(|a| a.bounds) && on_box_edge(doc, id, n, pt, tol)
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
None
|
||||
@@ -191,7 +210,7 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: Bool
|
||||
let at = out.len();
|
||||
let whole = matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Section { contents_hidden: true });
|
||||
if whole {
|
||||
if hit_rec(doc, id, pt, tol, depth, result, false).is_some() {
|
||||
if hit_rec(doc, id, pt, tol, depth, result, None).is_some() {
|
||||
out.push((id, depth));
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -7,7 +7,13 @@ use crate::{Collection, Comment, Document, Message, Mode, NodeId, NodeKind, Styl
|
||||
const C3: u64 = 3 << 32;
|
||||
|
||||
fn style(id: &str) -> Style {
|
||||
Style { id: id.into(), name: format!("Style {id}"), description: String::new(), kind: StyleKind::Paint { paints: Vec::new() } }
|
||||
Style {
|
||||
id: id.into(),
|
||||
name: format!("Style {id}"),
|
||||
description: String::new(),
|
||||
kind: StyleKind::Paint { paints: Vec::new() },
|
||||
bound: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn comment(id: u64, page: NodeId) -> Comment {
|
||||
|
||||
+67
-18
@@ -21,6 +21,7 @@ pub mod styles;
|
||||
pub mod text;
|
||||
pub mod vector;
|
||||
pub mod vector_faces;
|
||||
pub mod vector_regions;
|
||||
|
||||
pub use colors::*;
|
||||
pub use comments::*;
|
||||
@@ -780,6 +781,23 @@ impl Document {
|
||||
self.paste_nodes(&src_nodes, &map, id, parent, index)
|
||||
}
|
||||
|
||||
/// Deep-copy each of `ids`' subtrees to the end of `parent`, in order, returning the new root
|
||||
/// ids. One id map covers every copy, so references between the copied subtrees (a navigation
|
||||
/// from one copied frame to another) point at the copies.
|
||||
pub fn duplicate_all_into(&mut self, ids: &[NodeId], parent: NodeId) -> DocResult<Vec<NodeId>> {
|
||||
let mut map = BTreeMap::new();
|
||||
let mut trees = Vec::with_capacity(ids.len());
|
||||
for &id in ids {
|
||||
let src_nodes: Vec<Node> = self.subtree(id).into_iter().filter_map(|i| self.node(i).cloned()).collect();
|
||||
for n in &src_nodes {
|
||||
let nid = self.alloc_id();
|
||||
map.insert(n.id, nid);
|
||||
}
|
||||
trees.push((id, src_nodes));
|
||||
}
|
||||
trees.into_iter().map(|(id, src_nodes)| self.paste_nodes(&src_nodes, &map, id, parent, None)).collect()
|
||||
}
|
||||
|
||||
/// Insert copies of `nodes` (a subtree rooted at `root`, from this or another document) with ids
|
||||
/// remapped by `map`. Interactions, instance references and pattern sources pointing inside the
|
||||
/// subtree are remapped too.
|
||||
@@ -902,15 +920,22 @@ impl Document {
|
||||
/// A unique name like "Frame 3" for a new node of a kind.
|
||||
pub fn next_name(&self, base: &str) -> String {
|
||||
let mut max = 0u64;
|
||||
let mut taken = std::collections::BTreeSet::new();
|
||||
for n in self.nodes() {
|
||||
if let Some(rest) = n.name.strip_prefix(base)
|
||||
&& let Some(num) = rest.strip_prefix(' ')
|
||||
&& let Ok(k) = num.parse::<u64>()
|
||||
{
|
||||
max = max.max(k);
|
||||
taken.insert(k);
|
||||
}
|
||||
}
|
||||
format!("{base} {}", max + 1)
|
||||
let next = match max.checked_add(1) {
|
||||
Some(next) => next,
|
||||
// The counter is spent: the lowest free number instead of wrapping onto a name.
|
||||
None => (1u64..).find(|k| !taken.contains(k)).unwrap_or(1),
|
||||
};
|
||||
format!("{base} {next}")
|
||||
}
|
||||
|
||||
pub fn style(&self, id: &str) -> Option<&Style> {
|
||||
@@ -939,7 +964,11 @@ impl Document {
|
||||
if n.id != *id {
|
||||
issues.push(format!("node key {id} has id {}", n.id));
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for c in &n.children {
|
||||
if !seen.insert(*c) {
|
||||
issues.push(format!("child {c} listed twice in {id}"));
|
||||
}
|
||||
match self.node(*c) {
|
||||
Some(cn) if cn.parent == Some(*id) => {}
|
||||
Some(_) => issues.push(format!("child {c} of {id} has another parent")),
|
||||
@@ -956,10 +985,32 @@ impl Document {
|
||||
if n.is_page() != listed {
|
||||
issues.push(format!("page list mismatch for {id}"));
|
||||
}
|
||||
if n.is_page() && n.parent.is_some() {
|
||||
issues.push(format!("page {id} has a parent"));
|
||||
}
|
||||
}
|
||||
let reachable = self.reachable();
|
||||
for id in self.nodes.keys().filter(|k| !reachable.contains(*k)) {
|
||||
issues.push(format!("{id} is not under a page (cycle or detached)"));
|
||||
}
|
||||
issues
|
||||
}
|
||||
|
||||
/// Every node reachable from a page through reciprocal parent/child links, each once (cycles
|
||||
/// and repeated children are walked once, so this is linear in the node count).
|
||||
fn reachable(&self) -> std::collections::BTreeSet<NodeId> {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut stack: Vec<NodeId> = self.nodes.iter().filter(|(_, n)| n.is_page() && n.parent.is_none()).map(|(k, _)| *k).collect();
|
||||
while let Some(id) = stack.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let Some(n) = self.nodes.get(&id) else { continue };
|
||||
stack.extend(n.children.iter().filter(|c| !seen.contains(*c) && self.nodes.get(*c).is_some_and(|cn| cn.parent == Some(id))));
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
/// Repair what [`validate`](Self::validate) reports (lenient loading of untrusted files).
|
||||
pub fn repair(&mut self) {
|
||||
let ids: Vec<NodeId> = self.nodes.keys().copied().collect();
|
||||
@@ -967,34 +1018,30 @@ impl Document {
|
||||
if let Some(n) = self.nodes.get_mut(id) {
|
||||
let n = Arc::make_mut(n);
|
||||
n.id = *id;
|
||||
// Pages are roots.
|
||||
if n.is_page() {
|
||||
n.parent = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Drop dangling children and children claimed by another parent.
|
||||
// Drop dangling children, children claimed by another parent and repeats.
|
||||
let parents: BTreeMap<NodeId, Option<NodeId>> = self.nodes.iter().map(|(k, n)| (*k, n.parent)).collect();
|
||||
for id in &ids {
|
||||
let Some(n) = self.nodes.get(id) else { continue };
|
||||
let keep: Vec<NodeId> = n.children.iter().copied().filter(|c| parents.get(c).copied().flatten() == Some(*id)).collect();
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let keep: Vec<NodeId> =
|
||||
n.children.iter().copied().filter(|c| parents.get(c).copied().flatten() == Some(*id) && seen.insert(*c)).collect();
|
||||
if keep != n.children
|
||||
&& let Some(n) = self.nodes.get_mut(id)
|
||||
{
|
||||
Arc::make_mut(n).children = keep;
|
||||
}
|
||||
}
|
||||
// Orphans (parent missing or not listing them) are removed.
|
||||
loop {
|
||||
let orphan = self.nodes.iter().find_map(|(k, n)| {
|
||||
if n.is_page() {
|
||||
return None;
|
||||
}
|
||||
match n.parent {
|
||||
Some(p) if self.nodes.get(&p).is_some_and(|pn| pn.children.contains(k)) => None,
|
||||
_ => Some(*k),
|
||||
}
|
||||
});
|
||||
let Some(o) = orphan else { break };
|
||||
for d in self.subtree(o) {
|
||||
self.nodes.remove(&d);
|
||||
}
|
||||
// Nodes not under a page (orphans, detached subtrees, cycles) are removed.
|
||||
let reachable = self.reachable();
|
||||
let gone: Vec<NodeId> = self.nodes.keys().filter(|k| !reachable.contains(*k)).copied().collect();
|
||||
for k in gone {
|
||||
self.nodes.remove(&k);
|
||||
}
|
||||
// Snapshots of layers that are gone or no longer changed since marked ready for dev, or that
|
||||
// don't start with their layer, are dropped.
|
||||
@@ -1130,6 +1177,8 @@ mod proto_expr_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod vector_regions_tests;
|
||||
#[cfg(test)]
|
||||
mod width_sides_tests;
|
||||
#[cfg(test)]
|
||||
mod width_tests;
|
||||
@@ -1015,6 +1015,19 @@ pub enum CrossAlign {
|
||||
Baseline,
|
||||
}
|
||||
|
||||
/// How the lines of a wrapping layout (rows of a horizontal one, columns of a vertical one) share
|
||||
/// the free space across the flow.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ContentAlign {
|
||||
/// Packed, `cross_gap` apart, at the start, centre or end the cross alignment names (lines of
|
||||
/// nothing but fill children stretch instead).
|
||||
#[default]
|
||||
Auto,
|
||||
/// The free space goes between the lines (one line, or no free space: like `Auto`).
|
||||
SpaceBetween,
|
||||
}
|
||||
|
||||
/// Auto layout container properties (a frame with auto layout).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -1029,6 +1042,8 @@ pub struct AutoLayout {
|
||||
pub padding: [f64; 4],
|
||||
pub main_align: MainAlign,
|
||||
pub cross_align: CrossAlign,
|
||||
/// Wrapping layouts: how the lines are spread across the flow.
|
||||
pub content_align: ContentAlign,
|
||||
/// Grid mode: column and row counts (rows grow to fit the children).
|
||||
pub grid_columns: u32,
|
||||
pub grid_rows: u32,
|
||||
@@ -1052,6 +1067,7 @@ impl Default for AutoLayout {
|
||||
padding: [10.0; 4],
|
||||
main_align: MainAlign::Min,
|
||||
cross_align: CrossAlign::Min,
|
||||
content_align: ContentAlign::Auto,
|
||||
grid_columns: 2,
|
||||
grid_rows: 2,
|
||||
column_tracks: Vec::new(),
|
||||
|
||||
@@ -534,6 +534,10 @@ pub enum Action {
|
||||
/// Colour drawn behind the overlay (over the frame underneath); None = no background.
|
||||
#[serde(default)]
|
||||
background: Option<figcraft_color::Color>,
|
||||
/// Manual position: where the overlay's top-left sits from the top-left of the layer that
|
||||
/// opened it (x, y). None = right below that layer.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
offset: Option<[f64; 2]>,
|
||||
},
|
||||
SwapOverlay {
|
||||
dest: NodeId,
|
||||
@@ -709,12 +713,33 @@ pub struct Interaction {
|
||||
pub duration_ms: u32,
|
||||
#[serde(default)]
|
||||
pub easing: Easing,
|
||||
/// Mouse enter, leave, down and up: how long after the gesture the actions run (ms).
|
||||
#[serde(default, alias = "delayMs", skip_serializing_if = "is_zero")]
|
||||
pub delay_ms: u32,
|
||||
}
|
||||
|
||||
impl Interaction {
|
||||
/// Seconds between the gesture and the actions: the delay of a mouse enter, leave, down or up
|
||||
/// trigger (other triggers have none; "after delay" times itself).
|
||||
pub fn delay_secs(&self) -> f64 {
|
||||
match self.trigger {
|
||||
Trigger::MouseEnter | Trigger::MouseLeave | Trigger::MouseDown | Trigger::MouseUp => f64::from(self.delay_ms.min(MAX_DELAY_MS)) / 1000.0,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest delay a trigger waits (ms).
|
||||
pub const MAX_DELAY_MS: u32 = 600_000;
|
||||
|
||||
fn default_duration() -> u32 {
|
||||
300
|
||||
}
|
||||
|
||||
fn is_zero(v: &u32) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ impl Expr {
|
||||
return "…".into();
|
||||
}
|
||||
match self {
|
||||
Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| v.name.clone()),
|
||||
Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)),
|
||||
Expr::Value(VariableValue::String(s)) => quote(s),
|
||||
// Exact numbers and `#` colours, so the text reads back as the same expression.
|
||||
Expr::Value(VariableValue::Number(n)) => format!("{n}"),
|
||||
@@ -178,6 +178,18 @@ impl Expr {
|
||||
}
|
||||
}
|
||||
|
||||
/// A variable as written in an expression: its bare name when that reads back as this variable,
|
||||
/// otherwise `{name}` (or `{id}` when even the braced name wouldn't), so names like `1`, `2x` or
|
||||
/// `"q"` don't read back as literals or errors.
|
||||
fn var_ref(vars: &Variables, v: &crate::Variable) -> String {
|
||||
let this = Expr::var(v.id.clone());
|
||||
if Expr::parse(&v.name, vars).is_ok_and(|e| e == this) {
|
||||
return v.name.clone();
|
||||
}
|
||||
let braced = !v.name.contains('}') && v.name.trim() == v.name && vars.get(&v.name).is_some_and(|x| x.id == v.id);
|
||||
if braced { format!("{{{}}}", v.name) } else { format!("{{{}}}", v.id) }
|
||||
}
|
||||
|
||||
/// A collection or mode name as written inside `mode(…)` or `[…]`: as it is, or quoted when it has
|
||||
/// spaces at its ends, quotes or brackets.
|
||||
fn name_arg(name: &str) -> String {
|
||||
|
||||
@@ -176,3 +176,22 @@ fn hostile_text_is_bounded() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_that_look_like_literals_read_back_as_variables() {
|
||||
for name in ["count", "count total", "2x", "1", "-3", ".5", "(x)", "#ff", "\"q\"", "not", "true"] {
|
||||
let mut v = vars();
|
||||
if let Some(var) = v.variables.iter_mut().find(|x| x.id == "n") {
|
||||
var.name = name.into();
|
||||
}
|
||||
for e in [Expr::var("n"), Expr::op(ExprOp::Add, vec![Expr::var("n"), n(1.0)]), Expr::InMode { variable: "n".into(), mode: "dark".into() }] {
|
||||
let shown = e.display(&v);
|
||||
assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "{name:?} shown as {shown:?}");
|
||||
}
|
||||
}
|
||||
// Plain names stay bare, and literals still read as literals.
|
||||
let v = vars();
|
||||
assert_eq!(Expr::var("n").display(&v), "n-name");
|
||||
assert_eq!(parse("1"), n(1.0));
|
||||
assert_eq!(parse("\"q\""), Expr::Value(VariableValue::String("q".into())));
|
||||
}
|
||||
@@ -40,6 +40,12 @@ pub struct Style {
|
||||
pub description: String,
|
||||
#[serde(flatten)]
|
||||
pub kind: StyleKind,
|
||||
/// Variables bound to fields of the value, by the keys a layer's bindings use
|
||||
/// (`fills/0/color`, `effects/0/radius`, `layoutGrids/0/size`, a text style's `fontSize`…).
|
||||
/// A layer using the style sees them resolved in its own modes; `kind` holds them resolved in
|
||||
/// the default modes.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub bound: BTreeMap<String, VariableId>,
|
||||
}
|
||||
|
||||
/// A node's links to shared styles.
|
||||
|
||||
@@ -97,6 +97,7 @@ fn duplicate_remaps_ids_and_interactions() {
|
||||
transition: proto::Transition::Instant,
|
||||
duration_ms: 300,
|
||||
easing: proto::Easing::EaseOut,
|
||||
delay_ms: 0,
|
||||
});
|
||||
let f2 = d.duplicate_into(f, p, None).unwrap();
|
||||
let kids = d.node(f2).unwrap().children.clone();
|
||||
@@ -167,6 +168,27 @@ fn text_runs_restyle_and_replace() {
|
||||
assert_eq!(t.characters, "");
|
||||
}
|
||||
|
||||
/// A run that reaches past the text (a file can carry one: nothing clamps `runs` on load)
|
||||
/// must not overflow the arithmetic that shifts the runs along, and must not leave the
|
||||
/// text unwritable.
|
||||
#[test]
|
||||
fn text_runs_past_the_text_never_overflow() {
|
||||
let style = || TextStyle { font_style: "Bold".into(), ..Default::default() };
|
||||
for end in [usize::MAX, usize::MAX - 1, 1_000_000] {
|
||||
let mut t = TextData::new("hi");
|
||||
t.runs = vec![StyleRun { start: 0, end, style: style() }];
|
||||
// Typing at the caret, and replacing a selection: both shift `end`.
|
||||
t.replace_range(0, 0, "x");
|
||||
assert_eq!(t.characters, "xhi", "end {end}");
|
||||
t.replace_range(0, 3, "yo");
|
||||
assert_eq!(t.characters, "yo", "end {end}");
|
||||
// The text is still writable and the runs are back inside it.
|
||||
t.replace_range(0, 0, "!");
|
||||
assert_eq!(t.characters, "!yo", "end {end}");
|
||||
assert!(t.runs.iter().all(|r| r.start < r.end && r.end <= t.char_count()), "end {end}: {:?}", t.runs);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_case() {
|
||||
assert_eq!(TextCase::Title.apply("hello big world"), "Hello Big World");
|
||||
@@ -280,6 +302,30 @@ fn serde_roundtrip_and_repair() {
|
||||
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_drops_cycles_and_repeated_children() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let g = d.create(f, NodeKind::Frame, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let r = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let mut v = serde_json::to_value(&d).unwrap();
|
||||
// F and G each other's parent and child (G listing F twice), and R listed twice by the page.
|
||||
v["nodes"][f.to_string()]["parent"] = serde_json::json!(g);
|
||||
v["nodes"][g.to_string()]["children"] = serde_json::json!([f, f]);
|
||||
v["nodes"][p.to_string()]["children"].as_array_mut().unwrap().push(serde_json::json!(r));
|
||||
let mut bad: Document = serde_json::from_value(v).unwrap();
|
||||
let issues = bad.validate();
|
||||
for want in ["listed twice", "not under a page"] {
|
||||
assert!(issues.iter().any(|i| i.contains(want)), "{want}: {issues:?}");
|
||||
}
|
||||
bad.repair();
|
||||
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
|
||||
assert!(bad.node(f).is_none() && bad.node(g).is_none());
|
||||
assert_eq!(bad.node(p).unwrap().children, vec![r]);
|
||||
assert_eq!(bad.subtree(p), vec![p, r]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_layout_loads_files_without_grid_tracks() {
|
||||
// Saved before grid tracks, cells and spans existed.
|
||||
@@ -310,6 +356,16 @@ fn next_name_increments() {
|
||||
assert_eq!(d.next_name("Ellipse"), "Ellipse 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_name_never_overflows() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
d.create(p, NodeKind::Frame, "Frame 18446744073709551615", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
assert_eq!(d.next_name("Frame"), "Frame 1", "the counter can't step past u64::MAX");
|
||||
d.create(p, NodeKind::Frame, "Frame 1", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
assert_eq!(d.next_name("Frame"), "Frame 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_easings() {
|
||||
use crate::proto::Easing;
|
||||
@@ -346,3 +402,65 @@ fn custom_easings() {
|
||||
assert_eq!(back, Easing::CustomSpring { mass: 2.0, stiffness: 50.0, damping: 5.0 });
|
||||
assert_eq!(serde_json::from_value::<Easing>(serde_json::json!("easeInOut")).unwrap(), Easing::EaseInOut);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_style_updates_merge_equal_runs() {
|
||||
let def = TextStyle { font_size: 14.0, ..Default::default() };
|
||||
let mut t = TextData::new("abcd");
|
||||
t.link_text_style(0, 2, &def, "S");
|
||||
t.link_text_style(2, 4, &def, "S");
|
||||
t.restyle(2, 4, |s| s.font_size = 24.0);
|
||||
assert_eq!(t.runs.len(), 2);
|
||||
t.update_text_style("S", &TextStyle { font_size: 24.0, ..def.clone() });
|
||||
let spans = t.spans();
|
||||
assert_eq!(spans.len(), 1);
|
||||
assert_eq!((spans[0].0, spans[0].1), (0, 4));
|
||||
assert_eq!(spans[0].2.font_size, 24.0);
|
||||
assert_eq!(spans[0].2.style_id.as_deref(), Some("S"));
|
||||
|
||||
// Remapping two links to one id merges the runs.
|
||||
let mut t = TextData::new("ab");
|
||||
t.link_text_style(0, 1, &def, "A");
|
||||
t.link_text_style(1, 2, &def, "B");
|
||||
t.map_text_styles(|_| Some("C".into()));
|
||||
assert_eq!(t.spans().len(), 1);
|
||||
assert_eq!(t.style_at(1).style_id.as_deref(), Some("C"));
|
||||
|
||||
// Different values stay separate.
|
||||
let mut t = TextData::new("ab");
|
||||
t.link_text_style(0, 1, &def, "A");
|
||||
t.link_text_style(1, 2, &TextStyle { font_size: 30.0, ..Default::default() }, "B");
|
||||
t.map_text_styles(|_| Some("C".into()));
|
||||
let spans = t.spans();
|
||||
assert_eq!(spans.len(), 2);
|
||||
assert_eq!((spans[0].2.font_size, spans[1].2.font_size), (14.0, 30.0));
|
||||
|
||||
// Unlinking a run equal to the base drops it.
|
||||
let mut t = TextData::new("ab");
|
||||
let base = t.style.clone();
|
||||
t.link_text_style(0, 1, &base, "S");
|
||||
t.map_text_styles(|_| None);
|
||||
assert!(t.runs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_style_empty_range_links_and_unlinks_all() {
|
||||
let def = TextStyle { font_style: "Bold".into(), ..Default::default() };
|
||||
let mut t = TextData::new("abcd");
|
||||
t.link_text_style(2, 2, &def, "S");
|
||||
assert!((0..4).all(|i| t.style_at(i).style_id.as_deref() == Some("S") && t.style_at(i).font_style == "Bold"));
|
||||
t.unlink_text_style(2, 2);
|
||||
assert!((0..4).all(|i| t.style_at(i).style_id.is_none() && t.style_at(i).font_style == "Bold"));
|
||||
// Partial ranges stay partial.
|
||||
t.link_text_style(0, 4, &def, "S");
|
||||
t.unlink_text_style(1, 3);
|
||||
assert_eq!(t.style_at(0).style_id.as_deref(), Some("S"));
|
||||
assert!(t.style_at(1).style_id.is_none() && t.style_at(2).style_id.is_none());
|
||||
assert_eq!(t.style_at(3).style_id.as_deref(), Some("S"));
|
||||
// Empty text changes the base style.
|
||||
let mut e = TextData::new("");
|
||||
e.link_text_style(0, 0, &def, "S");
|
||||
assert_eq!(e.style.style_id.as_deref(), Some("S"));
|
||||
e.unlink_text_style(0, 0);
|
||||
assert!(e.style.style_id.is_none());
|
||||
}
|
||||
+58
-10
@@ -215,6 +215,10 @@ pub struct ParaList {
|
||||
pub list: ListStyle,
|
||||
/// Nesting level (0 = outermost; plain paragraphs are always 0).
|
||||
pub indent: u8,
|
||||
/// A numbered list that starts counting at this paragraph counts from `start_offset + 1`
|
||||
/// (items that go on counting ignore it; other paragraphs are always 0).
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub start_offset: u32,
|
||||
}
|
||||
|
||||
/// Character style. In runs, every field is an override; `None` means "inherit the base".
|
||||
@@ -562,7 +566,7 @@ impl TextData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop empty/out-of-range runs and runs equal to the base style.
|
||||
/// Drop empty/out-of-range runs and runs equal to the base style, and merge touching equal runs.
|
||||
pub fn normalize(&mut self) {
|
||||
let n = self.char_count();
|
||||
let base = self.style.clone();
|
||||
@@ -571,6 +575,14 @@ impl TextData {
|
||||
r.end = r.end.min(n);
|
||||
}
|
||||
self.runs.sort_by_key(|r| r.start);
|
||||
let mut merged: Vec<StyleRun> = Vec::with_capacity(self.runs.len());
|
||||
for r in std::mem::take(&mut self.runs) {
|
||||
match merged.last_mut() {
|
||||
Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end,
|
||||
_ => merged.push(r),
|
||||
}
|
||||
}
|
||||
self.runs = merged;
|
||||
}
|
||||
|
||||
/// Replace chars `start..end` with `text`, shifting runs (inserted text takes the style at `start`).
|
||||
@@ -602,7 +614,9 @@ impl TextData {
|
||||
if p <= start {
|
||||
p
|
||||
} else if p >= end {
|
||||
p - removed + inserted
|
||||
// Runs of a file that reach past the text (nothing clamps them on load)
|
||||
// would otherwise overflow here; `normalize` below drops what is left.
|
||||
p.saturating_sub(removed).saturating_add(inserted)
|
||||
} else {
|
||||
start + inserted
|
||||
}
|
||||
@@ -627,13 +641,13 @@ impl TextData {
|
||||
|
||||
/// Paragraph `p`'s list settings.
|
||||
pub fn para_list(&self, p: usize) -> ParaList {
|
||||
self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, indent: 0 })
|
||||
self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() })
|
||||
}
|
||||
|
||||
/// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries).
|
||||
fn fill_paragraphs(&mut self) {
|
||||
let n = self.paragraph_count();
|
||||
let fill = ParaList { list: self.list, indent: 0 };
|
||||
let fill = ParaList { list: self.list, ..ParaList::default() };
|
||||
self.paragraphs.resize(n, fill);
|
||||
}
|
||||
|
||||
@@ -647,17 +661,23 @@ impl TextData {
|
||||
}
|
||||
|
||||
/// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain
|
||||
/// paragraphs stay at level 0, levels stop at [`MAX_LIST_INDENT`]); one style for all with
|
||||
/// no nesting folds back into `list`.
|
||||
/// paragraphs stay at level 0, only numbered ones keep a start, levels stop at
|
||||
/// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`.
|
||||
pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) {
|
||||
let (a, b) = self.para_span(start, end);
|
||||
self.fill_paragraphs();
|
||||
for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) {
|
||||
f(pl);
|
||||
pl.indent = if pl.list == ListStyle::None { 0 } else { pl.indent.min(MAX_LIST_INDENT) };
|
||||
if pl.list == ListStyle::None {
|
||||
pl.indent = 0;
|
||||
}
|
||||
if pl.list != ListStyle::Numbered {
|
||||
pl.start_offset = 0;
|
||||
}
|
||||
pl.indent = pl.indent.min(MAX_LIST_INDENT);
|
||||
}
|
||||
if let Some(first) = self.paragraphs.first().copied()
|
||||
&& first.indent == 0
|
||||
&& first == (ParaList { list: first.list, ..ParaList::default() })
|
||||
&& self.paragraphs.iter().all(|p| *p == first)
|
||||
{
|
||||
self.list = first.list;
|
||||
@@ -665,21 +685,24 @@ impl TextData {
|
||||
}
|
||||
}
|
||||
|
||||
/// The list settings shared by the paragraphs chars `start..end` touch, or None when they differ.
|
||||
/// The list style and level shared by the paragraphs chars `start..end` touch (the first
|
||||
/// one's settings), or None when they differ. Start numbers don't count.
|
||||
pub fn common_para_list(&self, start: usize, end: usize) -> Option<ParaList> {
|
||||
let (a, b) = self.para_span(start, end);
|
||||
let first = self.para_list(a);
|
||||
(a..=b).all(|p| self.para_list(p) == first).then_some(first)
|
||||
(a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first)
|
||||
}
|
||||
|
||||
/// Link chars `start..end` (all of them when the range is empty or the whole text) to text
|
||||
/// style `id`, defined as `def`.
|
||||
pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) {
|
||||
let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) };
|
||||
self.restyle(start, end, |s| s.take_text_style(def, id));
|
||||
}
|
||||
|
||||
/// Unlink chars `start..end` (all when empty or whole) from their text styles; values stay.
|
||||
pub fn unlink_text_style(&mut self, start: usize, end: usize) {
|
||||
let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) };
|
||||
self.restyle(start, end, |s| s.style_id = None);
|
||||
}
|
||||
|
||||
@@ -770,4 +793,29 @@ mod case_tests {
|
||||
assert_eq!(st.variations.keys().collect::<Vec<_>>(), ["wdth"]);
|
||||
assert!(valid_axis_tag("wght") && !valid_axis_tag("weight"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_starts_load_from_old_files_and_follow_edits() {
|
||||
use ListStyle::{Bulleted as B, None as P, Numbered as N};
|
||||
// Files written before start numbers load with none, and write the same bytes.
|
||||
let old = r#"{"list":"numbered","indent":1}"#;
|
||||
let pl: ParaList = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 });
|
||||
assert_eq!(serde_json::to_string(&pl).unwrap(), old);
|
||||
// One numbered paragraph with a start keeps it (it doesn't fold into `list`).
|
||||
let mut t = TextData { characters: "Next".into(), ..Default::default() };
|
||||
t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 });
|
||||
assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 });
|
||||
// Enter copies it to the new item; different starts don't make the list mixed.
|
||||
t.replace_range(4, 4, "\nMore");
|
||||
assert_eq!(t.para_list(1).start_offset, 1);
|
||||
t.set_para_lists(5, 5, |p| p.start_offset = 0);
|
||||
assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N));
|
||||
// Bullets have no number, so no start.
|
||||
t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 });
|
||||
assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 });
|
||||
// A plain paragraph has none.
|
||||
t.set_para_lists(0, 0, |p| p.list = P);
|
||||
assert_eq!(t.para_list(0), ParaList::default());
|
||||
}
|
||||
}
|
||||
@@ -468,9 +468,9 @@ impl VectorNetwork {
|
||||
start_dir(&self.outward(seg, at_a)?)
|
||||
}
|
||||
|
||||
/// Whether any point has a stroke cap or join of its own.
|
||||
pub fn has_point_styles(&self) -> bool {
|
||||
self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some())
|
||||
/// Whether any point has a stroke cap of its own or a join other than the layer's `layer_join`.
|
||||
pub fn has_point_styles(&self, layer_join: StrokeJoin) -> bool {
|
||||
self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some_and(|j| j != layer_join))
|
||||
}
|
||||
|
||||
/// The fill path: every region's loops.
|
||||
|
||||
@@ -136,8 +136,8 @@ impl VectorNetwork {
|
||||
}
|
||||
let k = key(&face.loops);
|
||||
match (self.regions.iter().position(|r| key(&r.loops) == k), want) {
|
||||
(Some(i), None | Some(false)) => {
|
||||
self.regions.remove(i);
|
||||
(Some(_), None | Some(false)) => {
|
||||
self.regions.retain(|r| key(&r.loops) != k);
|
||||
Some(false)
|
||||
}
|
||||
(Some(_), Some(true)) => Some(true),
|
||||
@@ -525,4 +525,21 @@ mod tests {
|
||||
let _ = j.paint(Point::new(f64::NAN, 5.0));
|
||||
assert!(interior_point(&BezPath::new()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bucket_unfills_a_face_with_duplicate_regions_in_one_click() {
|
||||
let c = Point::new(50.0, 50.0);
|
||||
let mut n = square(0.0, 0.0, 100.0);
|
||||
n.regions.push(n.regions[0].clone());
|
||||
assert_eq!(n.paint(c), Some(false));
|
||||
assert!(!n.fill_contains(c));
|
||||
assert_eq!(n.region_at(c), None);
|
||||
assert_eq!(n.paint(c), Some(true));
|
||||
assert!(n.fill_contains(c));
|
||||
|
||||
let mut e = square(0.0, 0.0, 100.0);
|
||||
e.regions.push(e.regions[0].clone());
|
||||
assert_eq!(e.paint_to(c, Some(false)), Some(false));
|
||||
assert!(!e.fill_contains(c));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! A vector network's regions as areas: walking a region's loops into contours, whether a point is
|
||||
//! inside a region by that region's own fill rule, which region is under a point (the last one
|
||||
//! listed wins) and a region's points. Vector edit mode hovers, hatches and selects regions with
|
||||
//! these.
|
||||
//!
|
||||
//! A loop lists its segments in ring order, but each segment may be stored either way round: the
|
||||
//! walk follows the point reached so far and runs a segment backwards when it starts at its `b` end.
|
||||
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use figcraft_geom::{BezPath, Point, Shape};
|
||||
|
||||
use crate::vector::{VectorNetwork, WindingRule};
|
||||
|
||||
impl VectorNetwork {
|
||||
/// Loop `lp` walked as a ring: each existing segment with whether it is walked backwards (from
|
||||
/// its `b` end to its `a` end). The first segment runs toward the point it shares with the
|
||||
/// second; a segment that doesn't touch the point reached so far (a malformed loop) is taken
|
||||
/// as stored. Missing segments are skipped.
|
||||
pub fn loop_walk(&self, lp: &[usize]) -> Vec<(usize, bool)> {
|
||||
let mut existing = lp.iter().filter_map(|i| self.segments.get(*i));
|
||||
let Some(first) = existing.next() else { return Vec::new() };
|
||||
let forward_first = match existing.next() {
|
||||
Some(second) => first.b == second.a || first.b == second.b,
|
||||
None => true,
|
||||
};
|
||||
let mut cur = if forward_first { first.a } else { first.b };
|
||||
let mut out = Vec::with_capacity(lp.len());
|
||||
for &i in lp {
|
||||
let Some(s) = self.segments.get(i) else { continue };
|
||||
if s.a == cur {
|
||||
out.push((i, false));
|
||||
cur = s.b;
|
||||
} else if s.b == cur {
|
||||
out.push((i, true));
|
||||
cur = s.a;
|
||||
} else {
|
||||
out.push((i, false));
|
||||
cur = s.b;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Region `r`'s outline: each loop one closed contour, curves kept as curves (segments or
|
||||
/// points that don't exist are left out). `None` when there is no such region or nothing of it
|
||||
/// remains.
|
||||
pub fn region_path(&self, r: usize) -> Option<BezPath> {
|
||||
let region = self.regions.get(r)?;
|
||||
let mut path = BezPath::new();
|
||||
for lp in ®ion.loops {
|
||||
let mut open = false;
|
||||
let mut at = Point::ZERO;
|
||||
for (i, back) in self.loop_walk(lp) {
|
||||
let Some(s) = self.segments.get(i) else { continue };
|
||||
let (Some(a), Some(b)) = (self.vertices.get(s.a), self.vertices.get(s.b)) else { continue };
|
||||
let (a, b) = (a.point(), b.point());
|
||||
let (c1, c2) = (Point::new(a.x + s.ta.0, a.y + s.ta.1), Point::new(b.x + s.tb.0, b.y + s.tb.1));
|
||||
let (from, c1, c2, to) = if back { (b, c2, c1, a) } else { (a, c1, c2, b) };
|
||||
if !open {
|
||||
path.move_to(from);
|
||||
open = true;
|
||||
} else if from != at {
|
||||
// A gap in a malformed loop: bridged with a straight line.
|
||||
path.line_to(from);
|
||||
}
|
||||
if s.is_straight() {
|
||||
path.line_to(to);
|
||||
} else {
|
||||
path.curve_to(c1, c2, to);
|
||||
}
|
||||
at = to;
|
||||
}
|
||||
if open {
|
||||
path.close_path();
|
||||
}
|
||||
}
|
||||
(!path.elements().is_empty()).then_some(path)
|
||||
}
|
||||
|
||||
/// The winding count of `p` about all of region `r`'s loops together (curves counted where
|
||||
/// they run). `None` when there is no such region.
|
||||
pub fn region_winding(&self, r: usize, p: Point) -> Option<i32> {
|
||||
self.regions.get(r)?;
|
||||
Some(self.region_path(r).map_or(0, |path| path.winding(p)))
|
||||
}
|
||||
|
||||
/// Whether `p` is inside region `r` by the region's own fill rule (non-zero: any winding;
|
||||
/// even-odd: an odd winding).
|
||||
pub fn region_contains(&self, r: usize, p: Point) -> bool {
|
||||
let (Some(region), Some(w)) = (self.regions.get(r), self.region_winding(r, p)) else { return false };
|
||||
match region.winding {
|
||||
WindingRule::NonZero => w != 0,
|
||||
WindingRule::EvenOdd => w % 2 != 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The region under `p`: the last one listed that contains it.
|
||||
pub fn region_at(&self, p: Point) -> Option<usize> {
|
||||
if !(p.x.is_finite() && p.y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
(0..self.regions.len()).rev().find(|r| self.region_contains(*r, p))
|
||||
}
|
||||
|
||||
/// Every point on region `r`'s loops (holes' too), each once, sorted (segments or points that
|
||||
/// don't exist are left out).
|
||||
pub fn region_points(&self, r: usize) -> Vec<usize> {
|
||||
let Some(region) = self.regions.get(r) else { return Vec::new() };
|
||||
let mut out: Vec<usize> = region
|
||||
.loops
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter_map(|i| self.segments.get(*i))
|
||||
.flat_map(|s| [s.a, s.b])
|
||||
.filter(|v| *v < self.vertices.len())
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// A fingerprint of the network's topology: which points each segment joins and which
|
||||
/// segments each region's loops list. Moving points or handles keeps it; adding, removing or
|
||||
/// reordering segments, regions or loops changes it (region indexes are only good while it
|
||||
/// stays the same).
|
||||
pub fn topology_key(&self) -> u64 {
|
||||
let mut h = std::hash::DefaultHasher::new();
|
||||
self.segments.len().hash(&mut h);
|
||||
for s in &self.segments {
|
||||
(s.a, s.b).hash(&mut h);
|
||||
}
|
||||
self.regions.len().hash(&mut h);
|
||||
for r in &self.regions {
|
||||
r.loops.hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Regions as areas (`vector_regions.rs`): the inside test by each region's own fill rule, the last
|
||||
//! region winning where regions overlap, curved edges, loops with segments stored backwards, a
|
||||
//! region's points and empty or damaged networks.
|
||||
|
||||
use figcraft_geom::Point;
|
||||
|
||||
use crate::vector::{Region, Segment, VectorNetwork, Vertex, WindingRule};
|
||||
|
||||
/// Four points and four straight segments of a square at (`x0`, `y0`), clockwise on screen (y
|
||||
/// down), appended to `n`. Returns the segment indexes in ring order.
|
||||
fn square(n: &mut VectorNetwork, x0: f64, y0: f64, size: f64) -> Vec<usize> {
|
||||
let v0 = n.vertices.len();
|
||||
for (x, y) in [(x0, y0), (x0 + size, y0), (x0 + size, y0 + size), (x0, y0 + size)] {
|
||||
n.vertices.push(Vertex::new(x, y));
|
||||
}
|
||||
let s0 = n.segments.len();
|
||||
for k in 0..4 {
|
||||
n.segments.push(Segment::line(v0 + k, v0 + (k + 1) % 4));
|
||||
}
|
||||
(s0..s0 + 4).collect()
|
||||
}
|
||||
|
||||
/// The 7.1 network: a 200 square (points 0..3, segments 0..3) with a 80 square inside it (points
|
||||
/// 4..7, segments 4..7), both wound the same way, and no regions yet.
|
||||
fn nested() -> VectorNetwork {
|
||||
let mut n = VectorNetwork::default();
|
||||
square(&mut n, 0.0, 0.0, 200.0);
|
||||
square(&mut n, 60.0, 60.0, 80.0);
|
||||
n
|
||||
}
|
||||
|
||||
fn region(loops: Vec<Vec<usize>>, winding: WindingRule) -> Region {
|
||||
Region { loops, winding }
|
||||
}
|
||||
|
||||
fn p(x: f64, y: f64) -> Point {
|
||||
Point::new(x, y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inside_test_follows_each_regions_own_fill_rule() {
|
||||
let mut n = nested();
|
||||
n.regions = vec![region(vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], WindingRule::NonZero)];
|
||||
// 1. Non-zero: the centre counts (both rings wound the same way).
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0));
|
||||
// 4. Outside.
|
||||
assert_eq!(n.region_at(p(300.0, 100.0)), None);
|
||||
n.regions[0].winding = WindingRule::EvenOdd;
|
||||
// 2. Even-odd: the centre is a hole; 3. the ring is inside.
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), None);
|
||||
assert_eq!(n.region_at(p(20.0, 100.0)), Some(0));
|
||||
assert_eq!(n.region_at(p(300.0, 100.0)), None);
|
||||
// 5. The inner ring wound the other way, non-zero: a hole.
|
||||
let mut r = nested();
|
||||
for s in &mut r.segments[4..8] {
|
||||
std::mem::swap(&mut s.a, &mut s.b);
|
||||
}
|
||||
r.regions = vec![region(vec![vec![0, 1, 2, 3], vec![7, 6, 5, 4]], WindingRule::NonZero)];
|
||||
assert_eq!(r.region_at(p(100.0, 100.0)), None);
|
||||
assert_eq!(r.region_at(p(20.0, 100.0)), Some(0));
|
||||
// Regions of one path may use different rules.
|
||||
let mut m = nested();
|
||||
m.regions = vec![
|
||||
region(vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], WindingRule::NonZero),
|
||||
region(vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], WindingRule::EvenOdd),
|
||||
];
|
||||
assert_eq!(m.region_at(p(100.0, 100.0)), Some(0), "the even-odd region has a hole there, the non-zero one doesn't");
|
||||
assert_eq!(m.region_at(p(20.0, 100.0)), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_overlapping_region_wins() {
|
||||
let mut n = nested();
|
||||
n.regions = vec![region(vec![vec![0, 1, 2, 3]], WindingRule::NonZero), region(vec![vec![4, 5, 6, 7]], WindingRule::NonZero)];
|
||||
// 6, 7.
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), Some(1));
|
||||
assert_eq!(n.region_at(p(20.0, 100.0)), Some(0));
|
||||
// 8. Swapped: the outer square (region 0 before, listed last now) wins in the middle.
|
||||
n.regions.swap(0, 1);
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), Some(1));
|
||||
assert_eq!(n.region_points(1), vec![0, 1, 2, 3], "index 1 is the outer square now");
|
||||
assert_eq!(n.region_at(p(20.0, 100.0)), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curved_edges_count_where_the_curve_runs() {
|
||||
let mut n = VectorNetwork {
|
||||
vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(120.0, 0.0)],
|
||||
segments: vec![Segment { a: 0, b: 1, ta: (0.0, -80.0), tb: (0.0, -80.0) }, Segment::line(1, 0)],
|
||||
regions: vec![region(vec![vec![0, 1]], WindingRule::NonZero)],
|
||||
};
|
||||
// 9. Inside the bulge, above the chord; 10. below the chord; 11. above the curve's top (-60).
|
||||
assert_eq!(n.region_at(p(60.0, -40.0)), Some(0));
|
||||
assert_eq!(n.region_at(p(60.0, 20.0)), None);
|
||||
assert_eq!(n.region_at(p(60.0, -70.0)), None);
|
||||
assert_eq!(n.region_at(p(60.0, -58.0)), Some(0), "just under the top of the curve");
|
||||
// The lens walks toward the point it shares with the second segment, whichever way that is.
|
||||
n.segments.swap(0, 1);
|
||||
n.regions = vec![region(vec![vec![0, 1]], WindingRule::NonZero)];
|
||||
assert_eq!(n.region_at(p(60.0, -40.0)), Some(0));
|
||||
assert_eq!(n.region_at(p(60.0, -70.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_curved_segment_loop_encloses_its_area() {
|
||||
let n = VectorNetwork {
|
||||
vertices: vec![Vertex::new(0.0, 0.0)],
|
||||
segments: vec![Segment { a: 0, b: 0, ta: (100.0, -100.0), tb: (-100.0, -100.0) }],
|
||||
regions: vec![region(vec![vec![0]], WindingRule::NonZero)],
|
||||
};
|
||||
assert_eq!(n.region_at(p(0.0, -40.0)), Some(0));
|
||||
assert_eq!(n.region_at(p(0.0, 40.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segments_stored_backwards_are_walked_the_right_way() {
|
||||
let mut n = VectorNetwork::default();
|
||||
square(&mut n, 0.0, 0.0, 200.0);
|
||||
// Segment 1 stored as 2 → 1 and segment 3 as 0 → 3.
|
||||
n.segments[1] = Segment::line(2, 1);
|
||||
n.segments[3] = Segment::line(0, 3);
|
||||
n.regions = vec![region(vec![vec![0, 1, 2, 3]], WindingRule::NonZero)];
|
||||
// 12, 13.
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0));
|
||||
assert_eq!(n.region_at(p(300.0, 100.0)), None);
|
||||
let walked = n.loop_walk(&[0, 1, 2, 3]);
|
||||
assert_eq!(walked, vec![(0, false), (1, true), (2, false), (3, true)]);
|
||||
// 14. The first segment reversed too: unchanged.
|
||||
n.segments[0] = Segment::line(1, 0);
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0));
|
||||
assert_eq!(n.region_at(p(300.0, 100.0)), None);
|
||||
// The walk equals the same loop stored consistently: one closed contour of the same area.
|
||||
use figcraft_geom::Shape;
|
||||
let mut consistent = VectorNetwork::default();
|
||||
square(&mut consistent, 0.0, 0.0, 200.0);
|
||||
consistent.regions = n.regions.clone();
|
||||
let (a, b) = (n.region_path(0).unwrap().area().abs(), consistent.region_path(0).unwrap().area().abs());
|
||||
assert!((a - 40_000.0).abs() < 1e-6 && (b - 40_000.0).abs() < 1e-6, "{a} {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loop_that_does_not_close_is_closed_implicitly() {
|
||||
let mut n = VectorNetwork::default();
|
||||
square(&mut n, 0.0, 0.0, 200.0);
|
||||
n.regions = vec![region(vec![vec![0, 1, 2]], WindingRule::NonZero)];
|
||||
// The walk ends at point 3 and closes straight back to point 0.
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0));
|
||||
assert_eq!(n.region_at(p(300.0, 100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_segments_are_skipped_wherever_they_sit_in_a_loop() {
|
||||
use figcraft_geom::Shape;
|
||||
let mut n = VectorNetwork::default();
|
||||
square(&mut n, 0.0, 0.0, 200.0);
|
||||
n.regions = vec![region(vec![vec![0, 1, 2]], WindingRule::NonZero)];
|
||||
let control = n.region_path(0).unwrap();
|
||||
let control_walk = n.loop_walk(&[0, 1, 2]);
|
||||
for lp in [vec![99, 0, 1, 2], vec![0, 99, 1, 2], vec![99, 98, 0, 1, 2]] {
|
||||
n.regions = vec![region(vec![lp.clone()], WindingRule::NonZero)];
|
||||
assert_eq!(n.loop_walk(&lp), control_walk, "{lp:?}");
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0), "{lp:?}");
|
||||
assert!(n.region_contains(0, p(100.0, 100.0)), "{lp:?}");
|
||||
let path = n.region_path(0).unwrap();
|
||||
assert_eq!(path.elements().len(), control.elements().len(), "{lp:?}");
|
||||
assert!((path.area().abs() - control.area().abs()).abs() < 1e-6, "{lp:?}");
|
||||
}
|
||||
n.regions = vec![region(vec![vec![99]], WindingRule::NonZero)];
|
||||
assert!(n.region_path(0).is_none());
|
||||
assert!(!n.region_contains(0, p(100.0, 100.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_regions_points_are_listed_once() {
|
||||
let mut n = nested();
|
||||
n.regions = vec![region(vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], WindingRule::NonZero)];
|
||||
// 15.
|
||||
assert_eq!(n.region_points(0), vec![0, 1, 2, 3, 4, 5, 6, 7]);
|
||||
// 16.
|
||||
n.regions = vec![region(vec![vec![0, 1, 2, 3]], WindingRule::NonZero), region(vec![vec![4, 5, 6, 7]], WindingRule::NonZero)];
|
||||
assert_eq!(n.region_points(1), vec![4, 5, 6, 7]);
|
||||
assert!(n.region_points(9).is_empty());
|
||||
// 17. A missing segment is ignored.
|
||||
n.regions = vec![region(vec![vec![0, 1, 99, 2, 3]], WindingRule::NonZero)];
|
||||
assert_eq!(n.region_points(0), vec![0, 1, 2, 3]);
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_damaged_networks_have_no_regions_and_never_panic() {
|
||||
// 18.
|
||||
let mut n = nested();
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), None);
|
||||
let empty = VectorNetwork::default();
|
||||
assert_eq!(empty.region_at(p(0.0, 0.0)), None);
|
||||
assert!(empty.region_path(0).is_none());
|
||||
assert!(empty.region_points(0).is_empty());
|
||||
assert_eq!(empty.region_winding(0, p(0.0, 0.0)), None);
|
||||
// Loops naming missing segments, segments naming missing points, empty loops, junk points.
|
||||
n.regions = vec![region(vec![vec![], vec![42, 43]], WindingRule::EvenOdd), region(vec![vec![0, 1, 2, 3]], WindingRule::NonZero)];
|
||||
n.segments.push(Segment::line(1, 500));
|
||||
n.regions.push(region(vec![vec![8, 8, 8]], WindingRule::NonZero));
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), Some(1));
|
||||
assert!(n.region_path(0).is_none());
|
||||
assert!(n.region_path(2).is_none());
|
||||
for q in [p(f64::NAN, 0.0), p(f64::INFINITY, 1.0), p(1e300, -1e300)] {
|
||||
let _ = n.region_at(q);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_points_leave_out_segment_ends_that_do_not_exist() {
|
||||
let mut n = VectorNetwork::default();
|
||||
let ring = square(&mut n, 0.0, 0.0, 100.0);
|
||||
n.regions = vec![region(vec![ring], WindingRule::NonZero)];
|
||||
assert_eq!(n.region_points(0), vec![0, 1, 2, 3]);
|
||||
n.segments[3] = Segment::line(3, 9);
|
||||
assert_eq!(n.region_points(0), vec![0, 1, 2, 3]);
|
||||
assert!(n.region_path(0).is_some());
|
||||
n.regions = vec![region(vec![vec![99]], WindingRule::NonZero)];
|
||||
assert!(n.region_points(0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_topology_key_ignores_moved_points_but_not_new_segments() {
|
||||
let mut n = nested();
|
||||
n.regions = vec![region(vec![vec![0, 1, 2, 3]], WindingRule::NonZero)];
|
||||
let k = n.topology_key();
|
||||
n.translate(15.0, 25.0);
|
||||
n.segments[0].ta = (5.0, 5.0);
|
||||
assert_eq!(n.topology_key(), k, "moving points and handles keeps it");
|
||||
n.regions.push(region(vec![vec![4, 5, 6, 7]], WindingRule::NonZero));
|
||||
assert_ne!(n.topology_key(), k);
|
||||
let k = n.topology_key();
|
||||
n.regions.swap(0, 1);
|
||||
assert_ne!(n.topology_key(), k, "reordered regions");
|
||||
let k = n.topology_key();
|
||||
n.segments.push(Segment::line(0, 2));
|
||||
assert_ne!(n.topology_key(), k);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Wrapped-line alignment through commands: the `contentAlign` prop and its alias, undo, the CSS
|
||||
//! and inspect output, instance overrides, the REST and `.fig` mappings, and junk values.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::params::id_of;
|
||||
use crate::{EngineError, Session};
|
||||
use figcraft_doc::{ContentAlign, NodeId};
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
/// A 100 × 100 wrapping frame of three 40 × 20 rectangles (gap 10, row gap 5): two rows.
|
||||
fn wrapping(s: &mut Session) -> (NodeId, Vec<NodeId>) {
|
||||
let props = json!({"layout": "horizontal", "wrap": true, "padding": 0, "gap": 10, "crossGap": 5});
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": props})).unwrap());
|
||||
let kids = (0..3).map(|_| id(s.execute("shape.rectangle", &json!({"parent": f, "x": 0, "y": 0, "width": 40, "height": 20})).unwrap())).collect();
|
||||
(f, kids)
|
||||
}
|
||||
|
||||
fn ys(s: &Session, kids: &[NodeId]) -> Vec<f64> {
|
||||
kids.iter().map(|k| s.doc().unwrap().node(*k).unwrap().transform.as_coeffs()[5]).collect()
|
||||
}
|
||||
|
||||
fn content(s: &Session, f: NodeId) -> ContentAlign {
|
||||
s.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().content_align
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_align_spreads_the_rows_and_undoes() {
|
||||
let mut s = Session::new();
|
||||
let (f, kids) = wrapping(&mut s);
|
||||
assert_eq!(ys(&s, &kids), [0.0, 0.0, 25.0]);
|
||||
s.execute("node.set", &json!({"id": f, "props": {"contentAlign": "spaceBetween"}})).unwrap();
|
||||
assert_eq!(ys(&s, &kids), [0.0, 0.0, 80.0]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((content(&s, f), ys(&s, &kids)), (ContentAlign::Auto, vec![0.0, 0.0, 25.0]));
|
||||
// The file format's name and spelling work too; packed rows follow the cross alignment.
|
||||
s.execute("node.set", &json!({"id": f, "props": {"counterAxisAlignContent": "SPACE_BETWEEN"}})).unwrap();
|
||||
assert_eq!(content(&s, f), ContentAlign::SpaceBetween);
|
||||
s.execute("node.set", &json!({"id": f, "props": {"contentAlign": "auto", "crossAlign": "max"}})).unwrap();
|
||||
assert_eq!(ys(&s, &kids), [55.0, 55.0, 80.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_and_inspect_name_the_lines() {
|
||||
let mut s = Session::new();
|
||||
let (f, _) = wrapping(&mut s);
|
||||
let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": f})).unwrap().to_string();
|
||||
assert!(css(&mut s).contains("align-content: flex-start;"), "{}", css(&mut s));
|
||||
s.execute("node.set", &json!({"id": f, "props": {"crossAlign": "center"}})).unwrap();
|
||||
assert!(css(&mut s).contains("align-content: center;"));
|
||||
s.execute("node.set", &json!({"id": f, "props": {"contentAlign": "spaceBetween"}})).unwrap();
|
||||
assert!(css(&mut s).contains("align-content: space-between;"));
|
||||
let rows = s.execute("inspect.properties", &json!({"id": f})).unwrap().to_string();
|
||||
assert!(rows.contains("Auto (space between)"), "{rows}");
|
||||
// One line has no lines to align.
|
||||
s.execute("node.set", &json!({"id": f, "props": {"wrap": false}})).unwrap();
|
||||
assert!(!css(&mut s).contains("align-content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_keeps_its_own_line_alignment() {
|
||||
let mut s = Session::new();
|
||||
let (f, _) = wrapping(&mut s);
|
||||
let comp = id(s.execute("component.create", &json!({"ids": [f]})).unwrap());
|
||||
let inst = id(s.execute("instance.create", &json!({"component": comp, "x": 200, "y": 0})).unwrap());
|
||||
s.execute("node.set", &json!({"id": inst, "props": {"contentAlign": "spaceBetween"}})).unwrap();
|
||||
// A change to the main reaches the instance; its own alignment stays.
|
||||
s.execute("node.set", &json!({"id": comp, "props": {"padding": 2}})).unwrap();
|
||||
assert_eq!((content(&s, comp), content(&s, inst)), (ContentAlign::Auto, ContentAlign::SpaceBetween));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_and_fig_carry_the_setting() {
|
||||
let frame = |extra: Value| {
|
||||
let mut f = json!({"id": "1:2", "name": "Wrap", "type": "FRAME", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 100, "y": 100},
|
||||
"layoutMode": "HORIZONTAL", "layoutWrap": "WRAP", "children": []});
|
||||
f.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
json!({"name": "File", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [f]}]}})
|
||||
};
|
||||
let read = |file: &Value| {
|
||||
let (doc, _) = figcraft_format::rest::import(file, &figcraft_format::rest::no_images).unwrap();
|
||||
doc
|
||||
};
|
||||
let of = |doc: &figcraft_doc::Document| doc.nodes().find(|n| n.name == "Wrap").unwrap().layout.as_ref().unwrap().content_align;
|
||||
assert_eq!(of(&read(&frame(json!({})))), ContentAlign::Auto);
|
||||
assert_eq!(of(&read(&frame(json!({"counterAxisAlignContent": "AUTO"})))), ContentAlign::Auto);
|
||||
let spread = read(&frame(json!({"counterAxisAlignContent": "SPACE_BETWEEN"})));
|
||||
assert_eq!(of(&spread), ContentAlign::SpaceBetween);
|
||||
// Written to `.fig` and read back, both values survive.
|
||||
for doc in [spread, read(&frame(json!({})))] {
|
||||
let (bytes, _) = figcraft_format::fig::export(&doc, None).unwrap();
|
||||
let (back, _) = figcraft_format::fig::import(&bytes, "x").unwrap();
|
||||
assert_eq!(of(&back), of(&doc));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_values_are_refused_not_fatal() {
|
||||
let junk =
|
||||
[json!(null), json!(true), json!(-1), json!(1e308), json!(""), json!("sideways"), json!([1, 2]), json!({"a": 1}), json!("x".repeat(10_000))];
|
||||
for k in ["contentAlign", "counterAxisAlignContent"] {
|
||||
for j in &junk {
|
||||
let mut s = Session::new();
|
||||
let (f, kids) = wrapping(&mut s);
|
||||
for target in [f, kids[0]] {
|
||||
let r = s.execute("node.set", &json!({"id": target, "props": {k: j}}));
|
||||
assert!(!matches!(r, Err(EngineError::Internal { .. })), "{k} = {j}: {r:?}");
|
||||
}
|
||||
assert_eq!(content(&s, f), ContentAlign::Auto, "{k} = {j}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,27 @@ fn along_the_flow_skips_hidden_and_absolute_siblings() {
|
||||
assert_eq!(kids(&s, col), vec![c1, c0], "Right does nothing in a lone column");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_item_wrapped_lines_move_and_drop_by_line() {
|
||||
let mut s = Session::new();
|
||||
// Full-width items: one per row.
|
||||
let row = al(&mut s, None, "horizontal", 160.0, 200.0);
|
||||
set(&mut s, row, json!({"wrap": true}));
|
||||
let k: Vec<NodeId> = (0..2).map(|_| rect(&mut s, row, 160.0, 20.0)).collect();
|
||||
assert_eq!(s.doc().unwrap().world_bounds(k[1]).unwrap().y0, 30.0, "second row");
|
||||
let slot = crate::drop::auto_layout_slot(s.doc().unwrap(), row, figcraft_geom::Point::new(10.0, 35.0), &[]);
|
||||
assert_eq!(slot, Some((1, Some([-2.0, 30.0, -2.0, 50.0]))));
|
||||
arrow(&mut s, &[k[1]], 0, -1);
|
||||
assert_eq!(kids(&s, row), vec![k[1], k[0]]);
|
||||
|
||||
// Full-height items in a vertical wrap: one per column.
|
||||
let col = al(&mut s, None, "vertical", 200.0, 160.0);
|
||||
set(&mut s, col, json!({"wrap": true}));
|
||||
let c: Vec<NodeId> = (0..3).map(|_| rect(&mut s, col, 20.0, 160.0)).collect();
|
||||
arrow(&mut s, &[c[2]], -1, 0);
|
||||
assert_eq!(kids(&s, col), vec![c[0], c[2], c[1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_rows_move_up_and_down_a_row() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -73,3 +73,52 @@ fn autosaves_keep_their_flag_in_the_file() {
|
||||
assert!(!autosave(&mut t));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_that_save_themselves_still_get_checkpoints() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-autosave-on-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("auto.figcraft").to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
s.execute("pref.autoSave", &json!({"on": true})).unwrap();
|
||||
rect(&mut s, 0.0);
|
||||
s.execute("file.save", &json!({"path": path})).unwrap();
|
||||
assert!(autosave(&mut s), "saved, but changed since the last version");
|
||||
assert!(!autosave(&mut s), "no change since the checkpoint");
|
||||
// Just opened: nothing to checkpoint.
|
||||
let mut t = Session::empty();
|
||||
t.execute("pref.autoSave", &json!({"on": true})).unwrap();
|
||||
t.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert!(!autosave(&mut t));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_encoded_elsewhere_is_saved_as_of_its_snapshot() {
|
||||
use crate::cmd::file::FileSnapshot;
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-snapshot-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("snap.figcraft").to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.save", &json!({"path": path})).unwrap();
|
||||
rect(&mut s, 0.0);
|
||||
let file = FileSnapshot::of(s.ds().unwrap());
|
||||
let bytes = file.encode().unwrap();
|
||||
rect(&mut s, 50.0);
|
||||
assert!(file.save(&mut s, &bytes).unwrap());
|
||||
assert!(s.ds().unwrap().is_dirty(), "the edit since the snapshot is still unsaved");
|
||||
let mut t = Session::empty();
|
||||
t.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 10.0, "the file is the snapshot");
|
||||
// Saved meanwhile: the older bytes never overwrite the newer file.
|
||||
let file = FileSnapshot::of(s.ds().unwrap());
|
||||
let bytes = file.encode().unwrap();
|
||||
rect(&mut s, 100.0);
|
||||
s.execute("file.save", &json!({})).unwrap();
|
||||
assert!(!file.save(&mut s, &bytes).unwrap());
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
let mut t = Session::empty();
|
||||
t.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 110.0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -32,17 +32,17 @@ struct Wire {
|
||||
struct Link(Arc<Mutex<Wire>>);
|
||||
|
||||
impl LiveLink for Link {
|
||||
fn host(&mut self, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
fn host(&mut self, _: u64, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
Err("no".into())
|
||||
}
|
||||
fn join(&mut self, _: &str, _: &str) -> Result<(), String> {
|
||||
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
|
||||
Err("no".into())
|
||||
}
|
||||
fn leave(&mut self) {}
|
||||
fn presence(&mut self, _: &Presence) {}
|
||||
fn send(&mut self, _: ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<Event> {
|
||||
std::mem::take(&mut self.0.lock().unwrap().answers)
|
||||
fn leave(&mut self, _: u64) {}
|
||||
fn presence(&mut self, _: u64, _: &Presence) {}
|
||||
fn send(&mut self, _: u64, _: ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<(u64, Event)> {
|
||||
std::mem::take(&mut self.0.lock().unwrap().answers).into_iter().map(|e| (0, e)).collect()
|
||||
}
|
||||
fn plugins(&mut self, _: &str, req: PluginReq) -> Result<PluginReply, String> {
|
||||
self.0.lock().unwrap().waited += 1;
|
||||
@@ -112,6 +112,7 @@ fn answer(s: &mut Session, wire: &Arc<Mutex<Wire>>) -> usize {
|
||||
NetRequest::Library { req, .. } => NetReply::Library(Ok(library(&mut w, req))),
|
||||
NetRequest::Upload(_) => NetReply::Upload(Err("no".into())),
|
||||
NetRequest::Hosting { .. } => NetReply::Hosting(Err("no".into())),
|
||||
NetRequest::Share { .. } => NetReply::Share(Err("no".into())),
|
||||
};
|
||||
w.answers.push(Event::Net { job: *job, reply: Box::new(reply) });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::plugins_tests::{OnRefusal, install, manifest, script};
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn id(value: &serde_json::Value) -> crate::NodeId {
|
||||
crate::params::id_of(&value["id"]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_closes_documents_opened_inside_it() {
|
||||
let mut s = Session::new();
|
||||
let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let before_journal = s.journal.clone();
|
||||
let before_history = s.ds().unwrap().history.undo.len();
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"label": "Open then fail",
|
||||
"commands": [
|
||||
{"command": "file.new", "params": {"name": "Temporary"}},
|
||||
{"command": "shape.rectangle", "params": {"x": 20, "y": 20, "width": 10, "height": 10}},
|
||||
{"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.docs.len(), 1);
|
||||
assert_eq!(s.active, 0);
|
||||
assert!(s.doc().unwrap().contains(original));
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), before_history);
|
||||
assert_eq!(s.journal, before_journal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"name": "Second"})).unwrap();
|
||||
let before_history: Vec<_> = s.docs.iter().map(|ds| ds.history.undo.len()).collect();
|
||||
let calls = [
|
||||
("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})),
|
||||
("doc.nextTab", json!({})),
|
||||
("shape.rectangle", json!({"x": 20, "y": 0, "width": 10, "height": 10})),
|
||||
("node.set", json!({"id": 999999, "props": {"name": "must fail"}})),
|
||||
];
|
||||
install(&mut s, manifest("org.test.rollback", "write", &[]), &script(&calls, OnRefusal::Fail, ""));
|
||||
s.message = Some("keep this".into());
|
||||
let before_journal = s.journal.clone();
|
||||
let before_message = s.message.clone();
|
||||
// The new file is the one in front when the plugin starts.
|
||||
let before_active = s.active;
|
||||
|
||||
let result = s.execute("plugin.run", &json!({"id": "org.test.rollback"}));
|
||||
|
||||
assert!(matches!(result, Err(EngineError::Other(_))));
|
||||
assert_eq!(s.docs.iter().map(|ds| ds.doc.node(ds.page).map_or(0, |page| page.children.len())).collect::<Vec<_>>(), vec![0, 0]);
|
||||
assert_eq!(s.active, before_active, "the tab in front is put back");
|
||||
assert_eq!(s.docs.iter().map(|ds| ds.history.undo.len()).collect::<Vec<_>>(), before_history);
|
||||
assert_eq!(s.journal, before_journal);
|
||||
assert_eq!(s.message, before_message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_keeps_the_redo_steps() {
|
||||
let mut s = Session::new();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
assert!(s.undo().unwrap());
|
||||
assert_eq!(s.undo_depth().1, 1);
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"commands": [
|
||||
{"command": "shape.rectangle", "params": {"x": 20, "y": 20, "width": 10, "height": 10}},
|
||||
{"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.undo_depth().1, 1, "the redo step survives the failed batch");
|
||||
assert!(s.redo().unwrap());
|
||||
}
|
||||
Loaded 100 of 475 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user