mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-14 06:44:43 +00:00
Compare commits
1272
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
960e479d9e | ||
|
|
e2000bbafc | ||
|
|
efbe359abb | ||
|
|
6d85f14238 | ||
|
|
d6e8a6e874 | ||
|
|
8972340a4e | ||
|
|
334b388aa3 | ||
|
|
16a78cc11e | ||
|
|
87e0c8a7da | ||
|
|
7d5de76236 | ||
|
|
6ba0223636 | ||
|
|
c48102350b | ||
|
|
fc7a744519 | ||
|
|
e66780a39b | ||
|
|
8d790607ff | ||
|
|
92b83521af | ||
|
|
32735549c4 | ||
|
|
80f916326f | ||
|
|
9ec80bf2c2 | ||
|
|
a0e937918c | ||
|
|
e3169e5a61 | ||
|
|
630e0dd403 | ||
|
|
0e3876e6d6 | ||
|
|
5fa586306a | ||
|
|
ce8a224c9e | ||
|
|
a86897dc91 | ||
|
|
cb7966556c | ||
|
|
c1a4f2d954 | ||
|
|
0ab000b74b | ||
|
|
28de91d9ae | ||
|
|
35da7b7cff | ||
|
|
74c4a84da3 | ||
|
|
123e46550f | ||
|
|
44e64d8d47 | ||
|
|
bb532a5790 | ||
|
|
f6f44dcc53 | ||
|
|
96a82edecf | ||
|
|
f143260c3a | ||
|
|
bb93e5e433 | ||
|
|
da96d54157 | ||
|
|
a1758c163f | ||
|
|
b1d00f0802 | ||
|
|
efb95b29bf | ||
|
|
9257f97d69 | ||
|
|
2e12bdfd91 | ||
|
|
f53f37dfe0 | ||
|
|
59f3b3b733 | ||
|
|
77f4271061 | ||
|
|
8f3afbf6a1 | ||
|
|
1c79c1646f | ||
|
|
bf3fc3cc6e | ||
|
|
8b198ea364 | ||
|
|
d9c924c3ba | ||
|
|
92290dd208 | ||
|
|
7c68ebf183 | ||
|
|
fc37c808aa | ||
|
|
23463f719b | ||
|
|
49586b1c47 | ||
|
|
b5a315a748 | ||
|
|
bcd5b6e632 | ||
|
|
bbe1a12eeb | ||
|
|
594dc8c6cf | ||
|
|
dd6bd3fe1c | ||
|
|
9b711e212b | ||
|
|
b5038726c4 | ||
|
|
9c6920cf7d | ||
|
|
2bcd004071 | ||
|
|
f979878d9e | ||
|
|
5f0fe486db | ||
|
|
eb7ead9455 | ||
|
|
64efe12e6f | ||
|
|
4bb30782ff | ||
|
|
ab59877559 | ||
|
|
77ffc57432 | ||
|
|
4e7256bf6a | ||
|
|
8a46478e81 | ||
|
|
11ecf3e887 | ||
|
|
b82369201d | ||
|
|
e3b20b6540 | ||
|
|
b17bea9195 | ||
|
|
287b506bc1 | ||
|
|
e123a2efe7 | ||
|
|
bae2f057a1 | ||
|
|
05da6b2125 | ||
|
|
5617c94167 | ||
|
|
c3fe69c629 | ||
|
|
9ee0c7f572 | ||
|
|
629c297de9 | ||
|
|
0f95ce3d5b | ||
|
|
a7d0648958 | ||
|
|
309271851f | ||
|
|
f99fdea3b6 | ||
|
|
5d32c522d2 | ||
|
|
cc4799061e | ||
|
|
5b5a4855d4 | ||
|
|
ddde77db0a | ||
|
|
89529b3d00 | ||
|
|
ca0e689834 | ||
|
|
4c1546eed2 | ||
|
|
b19a81084e | ||
|
|
20a16dfd5c | ||
|
|
40fe1a623e | ||
|
|
4b030f027a | ||
|
|
6369b6b7a5 | ||
|
|
9d9cb9915a | ||
|
|
1abb9083c7 | ||
|
|
4788c6b819 | ||
|
|
e8a6033199 | ||
|
|
54e3587fd8 | ||
|
|
ba4a736d5b | ||
|
|
163e840fc3 | ||
|
|
b5904ad4c2 | ||
|
|
413ec40932 | ||
|
|
d32e971685 | ||
|
|
f8fc12b981 | ||
|
|
e4e872a489 | ||
|
|
d43375b69c | ||
|
|
227e182f68 | ||
|
|
f6090dde80 | ||
|
|
fbb4a22e01 | ||
|
|
7d0ecd2294 | ||
|
|
948c6fb8e6 | ||
|
|
da1f0a0484 | ||
|
|
baf93b7756 | ||
|
|
9c2988d408 | ||
|
|
c13c4301be | ||
|
|
df99f364a7 | ||
|
|
f22e2ed825 | ||
|
|
4e8f258300 | ||
|
|
8aaff7bc22 | ||
|
|
a8fa1f0fb4 | ||
|
|
e8b7917002 | ||
|
|
7b478052d1 | ||
|
|
bc35f02ecb | ||
|
|
66a8227bb4 | ||
|
|
7814c32a05 | ||
|
|
fbc660b8b7 | ||
|
|
08880f6e26 | ||
|
|
704eb1cc0a | ||
|
|
d54d107924 | ||
|
|
cbb95c6412 | ||
|
|
7b22438739 | ||
|
|
a1369310d4 | ||
|
|
85c036624d | ||
|
|
b9bfc51273 | ||
|
|
bf917a4c4f | ||
|
|
b51c9b68d7 | ||
|
|
b23d6b917b | ||
|
|
54ddb2a200 | ||
|
|
6382be2197 | ||
|
|
4c21d2df39 | ||
|
|
3335612c12 | ||
|
|
3afd896e2e | ||
|
|
0e0c3c7be4 | ||
|
|
3ed4578112 | ||
|
|
16f6b92519 | ||
|
|
f3c211eca5 | ||
|
|
088693cfd3 | ||
|
|
9ea757736f | ||
|
|
ab5a508538 | ||
|
|
8519af2539 | ||
|
|
4f1da1ebe6 | ||
|
|
ec9bb5f978 | ||
|
|
b29e17f5c0 | ||
|
|
458cd5b141 | ||
|
|
7ca8c80a8f | ||
|
|
20e4028afa | ||
|
|
a9975ccc22 | ||
|
|
8c7f40fc18 | ||
|
|
21d5090db2 | ||
|
|
9877b8e6cc | ||
|
|
22ef4fdd99 | ||
|
|
409de1a762 | ||
|
|
e358c695cc | ||
|
|
997444cdb6 | ||
|
|
3d0a93c1a1 | ||
|
|
ce615ada10 | ||
|
|
97f2c99d6b | ||
|
|
381ec85203 | ||
|
|
7543759150 | ||
|
|
7614567502 | ||
|
|
f2ede63dd9 | ||
|
|
d087645a00 | ||
|
|
75784f24d6 | ||
|
|
08c3f8f944 | ||
|
|
bb1fd20114 | ||
|
|
ba496eddca | ||
|
|
ec512a7767 | ||
|
|
742e911652 | ||
|
|
99a8ef0e26 | ||
|
|
1fdd95070e | ||
|
|
bb793470f1 | ||
|
|
109b90c0aa | ||
|
|
cfbb5a8f50 | ||
|
|
9fddc4b058 | ||
|
|
fb0e25a258 | ||
|
|
6325a323ee | ||
|
|
7126deaed0 | ||
|
|
203ec13e03 | ||
|
|
4cc2230013 | ||
|
|
b8aa1ef8f0 | ||
|
|
17da39a5ea | ||
|
|
95483273e3 | ||
|
|
163c293b8c | ||
|
|
cecc2865b0 | ||
|
|
44a504ddcf | ||
|
|
be38c68ac5 | ||
|
|
113d7ec106 | ||
|
|
afa431b47a | ||
|
|
8450d18c8e | ||
|
|
5a4b7c2795 | ||
|
|
e1da3f36bb | ||
|
|
94291f13e8 | ||
|
|
624795ba38 | ||
|
|
3d109bae1e | ||
|
|
80c8c7fa87 | ||
|
|
b744ff3e14 | ||
|
|
bafc814712 | ||
|
|
5738ce673c | ||
|
|
0008fc7901 | ||
|
|
479313bf67 | ||
|
|
07eb207d7c | ||
|
|
25899735a8 | ||
|
|
09427e72be | ||
|
|
2f14eeda60 | ||
|
|
46ce6102d8 | ||
|
|
1e1b59280d | ||
|
|
918a28a4f4 | ||
|
|
97cd06fd14 | ||
|
|
ee99a89e08 | ||
|
|
9c7b315301 | ||
|
|
b55f009198 | ||
|
|
ca3832c0d9 | ||
|
|
3e02bffbbc | ||
|
|
4b12a82839 | ||
|
|
258d6f61c0 | ||
|
|
df42e380f2 | ||
|
|
d2367d1eac | ||
|
|
ccddaa74cd | ||
|
|
82a50dad1b | ||
|
|
6a163721dc | ||
|
|
41c123657e | ||
|
|
0108a7c2f5 | ||
|
|
b7c9c712b1 | ||
|
|
710c95e527 | ||
|
|
6e7b63bc16 | ||
|
|
3e83ae44b7 | ||
|
|
40d7ce14ce | ||
|
|
b930ea5871 | ||
|
|
1b66fec3ec | ||
|
|
e17fccd7ab | ||
|
|
9f895a37d5 | ||
|
|
7af44512e7 | ||
|
|
9e4dcc9204 | ||
|
|
692f45123c | ||
|
|
855bbb328f | ||
|
|
fd02772d22 | ||
|
|
2af54a26fb | ||
|
|
d2dfe2ef20 | ||
|
|
139e0ba308 | ||
|
|
5d40b3f42f | ||
|
|
be212f00e6 | ||
|
|
be9e72f74d | ||
|
|
3b7b28c03c | ||
|
|
caf0f44d90 | ||
|
|
4e4bf54bb6 | ||
|
|
6f90717e71 | ||
|
|
147a374350 | ||
|
|
017517fbb9 | ||
|
|
f97789ade1 | ||
|
|
c530b4067d | ||
|
|
544b2455c9 | ||
|
|
4a54aa733f | ||
|
|
63dc418cc2 | ||
|
|
847e610550 | ||
|
|
79ac16a256 | ||
|
|
85c5286d39 | ||
|
|
3878f89bd6 | ||
|
|
a0d1162976 | ||
|
|
23d0d9e49e | ||
|
|
0a3f2341a8 | ||
|
|
98a1561183 | ||
|
|
35cc685e90 | ||
|
|
39f30d63c8 | ||
|
|
7fcb193f2a | ||
|
|
c3cb8c3e0e | ||
|
|
02e8f644fd | ||
|
|
54458cfb20 | ||
|
|
309bac6185 | ||
|
|
72990373c4 | ||
|
|
aec3a22fdf | ||
|
|
7a5bd57611 | ||
|
|
7558ac0444 | ||
|
|
bc10bb190d | ||
|
|
f03fc476c8 | ||
|
|
33c0f94cc8 | ||
|
|
ba4c502ada | ||
|
|
972e228f41 | ||
|
|
b778ba17cd | ||
|
|
bbf0d62863 | ||
|
|
4506f49ad4 | ||
|
|
a3e9af917d | ||
|
|
32bc822451 | ||
|
|
4d9d12ea13 | ||
|
|
a6659dcdfc | ||
|
|
2c3ea03c2e | ||
|
|
d8ff1441b1 | ||
|
|
9ae3223d22 | ||
|
|
d71f24741a | ||
|
|
c7261cb907 | ||
|
|
d3327d79f3 | ||
|
|
4707cb1579 | ||
|
|
018b8166b2 | ||
|
|
86e91d0b71 | ||
|
|
d1dad2e1c0 | ||
|
|
fec902b7d5 | ||
|
|
057bf9a289 | ||
|
|
f1d336249a | ||
|
|
55aa0165fd | ||
|
|
836d6f2fa6 | ||
|
|
1c3e175f66 | ||
|
|
aff3c4f18b | ||
|
|
adb23f0ff1 | ||
|
|
53c8308b66 | ||
|
|
a4c7e259ab | ||
|
|
de2915908c | ||
|
|
34b08ac8bf | ||
|
|
e9565813ac | ||
|
|
c87baa37cc | ||
|
|
911831fb89 | ||
|
|
be47f99534 | ||
|
|
0b08ea1127 | ||
|
|
61ccfe18a8 | ||
|
|
d1125130f6 | ||
|
|
ddd42b36bd | ||
|
|
6743c68203 | ||
|
|
caa3c5afce | ||
|
|
8d3a3e6171 | ||
|
|
d7a27c593e | ||
|
|
7d51d0515d | ||
|
|
13fdae3d44 | ||
|
|
ef535e49b0 | ||
|
|
e35f8eb1b7 | ||
|
|
55fbcd0d7e | ||
|
|
8629a6f586 | ||
|
|
99e5db399b | ||
|
|
247530b60e | ||
|
|
ae89635e00 | ||
|
|
633455c380 | ||
|
|
846a9f7214 | ||
|
|
1c158ed81b | ||
|
|
c3a21a6ab3 | ||
|
|
976bbb84ef | ||
|
|
78fd30ab5f | ||
|
|
4458eb8802 | ||
|
|
d1619f57bd | ||
|
|
eac260f160 | ||
|
|
a819e0a188 | ||
|
|
3a9610d480 | ||
|
|
ac606a2d43 | ||
|
|
104b80cd58 | ||
|
|
55a0712b11 | ||
|
|
bec13a4d07 | ||
|
|
f90becadab | ||
|
|
6a334fa354 | ||
|
|
2a3ea27605 | ||
|
|
d654a14720 | ||
|
|
d828e6379e | ||
|
|
e46dda1036 | ||
|
|
1768be2965 | ||
|
|
0f8e29bc9f | ||
|
|
47c2b228a5 | ||
|
|
d712f2ffd1 | ||
|
|
9b489a052e | ||
|
|
c8e93ddaaa | ||
|
|
52e3fe2967 | ||
|
|
78222e35d2 | ||
|
|
04e04f3275 | ||
|
|
67610bbba4 | ||
|
|
401af64bba | ||
|
|
27fedf0aa8 | ||
|
|
abd03d9f51 | ||
|
|
bcfaa3daef | ||
|
|
db43dc5a4a | ||
|
|
cf6bd4eeb2 | ||
|
|
e9be17427f | ||
|
|
47438dd4a2 | ||
|
|
84a86e1d84 | ||
|
|
c846f157dc | ||
|
|
1fe78aec66 | ||
|
|
1f0d9a9107 | ||
|
|
dd980c884f | ||
|
|
3f5f673ebc | ||
|
|
c7acd1a81d | ||
|
|
f7188de93d | ||
|
|
37419afe14 | ||
|
|
d574eaac1d | ||
|
|
154baa4467 | ||
|
|
1e7983a550 | ||
|
|
bc5a2b8fa7 | ||
|
|
e25c48cd56 | ||
|
|
42242e3c8b | ||
|
|
89c5d3dfe6 | ||
|
|
7fd936c8ba | ||
|
|
cbf6d2df41 | ||
|
|
cc12b8a3ec | ||
|
|
7ee2772da8 | ||
|
|
694c3114e8 | ||
|
|
a7352c1860 | ||
|
|
913634bec8 | ||
|
|
4a038a5f63 | ||
|
|
7b9ebaacbb | ||
|
|
76f0edce08 | ||
|
|
a7da760f9c | ||
|
|
f33ffb4367 | ||
|
|
e03c2a65f7 | ||
|
|
43bc7a3032 | ||
|
|
dc184083ef | ||
|
|
1c16aeae09 | ||
|
|
a672a6650f | ||
|
|
9265d554af | ||
|
|
c8b611a802 | ||
|
|
1d928a189c | ||
|
|
03bb164b5c | ||
|
|
91c80a19cc | ||
|
|
68b19422ad | ||
|
|
9a69f79861 | ||
|
|
0a01188c31 | ||
|
|
6afcfc2641 | ||
|
|
cae69a7ae8 | ||
|
|
6ce46b3532 | ||
|
|
a3c5025186 | ||
|
|
b84ee179d4 | ||
|
|
c6d6433473 | ||
|
|
825cb1e7af | ||
|
|
bb8b809d46 | ||
|
|
5962b630a1 | ||
|
|
df66ddd3a4 | ||
|
|
e39d24a32e | ||
|
|
c532053396 | ||
|
|
b8f97a5f4f | ||
|
|
2c350ca7fd | ||
|
|
8b3beac6bf | ||
|
|
ba433cc7b0 | ||
|
|
84ffdfc786 | ||
|
|
137425ab25 | ||
|
|
468bc5734d | ||
|
|
8e52c8cac2 | ||
|
|
8495309ea2 | ||
|
|
420ef78842 | ||
|
|
d254d5a6ca | ||
|
|
acfc0352c3 | ||
|
|
725e2c6913 | ||
|
|
e77a02993e | ||
|
|
88925c2f82 | ||
|
|
89174280d1 | ||
|
|
cc10c0de55 | ||
|
|
6b52bddc5b | ||
|
|
8aa5fd1a3b | ||
|
|
f84616702d | ||
|
|
af27ff7d2a | ||
|
|
17758458ef | ||
|
|
2efdeb593b | ||
|
|
96091494a5 | ||
|
|
ac4b98dbd7 | ||
|
|
ab1ae981f3 | ||
|
|
9417d13b26 | ||
|
|
c542af4efe | ||
|
|
23fbd8f935 | ||
|
|
adde034a7c | ||
|
|
64423c58bc | ||
|
|
6cdfe2ab8e | ||
|
|
2e3949f0ba | ||
|
|
fdd4eb5cad | ||
|
|
18b8bc11e9 | ||
|
|
b64ec66244 | ||
|
|
82ae9b1f59 | ||
|
|
1336d449d3 | ||
|
|
98e47487f4 | ||
|
|
28a23a9d1d | ||
|
|
2ebc496d07 | ||
|
|
6a76d4fa85 | ||
|
|
c448b85779 | ||
|
|
41abce53b7 | ||
|
|
05ecbe8d49 | ||
|
|
bacca0a0f0 | ||
|
|
e8fc6d410e | ||
|
|
05d4f1903b | ||
|
|
feeb9f71db | ||
|
|
7482410753 | ||
|
|
3365ae0370 | ||
|
|
0fe7e1e7db | ||
|
|
450faf7269 | ||
|
|
b67b71732f | ||
|
|
cbbe735db0 | ||
|
|
00b0b08680 | ||
|
|
99d097636e | ||
|
|
e17f58ba85 | ||
|
|
e912a2efe8 | ||
|
|
1ede34074e | ||
|
|
e86cae4fe0 | ||
|
|
59d3d7e0f8 | ||
|
|
2945b3042c | ||
|
|
98fcf2472b | ||
|
|
6f2fb6cce8 | ||
|
|
74c2088292 | ||
|
|
432a3e4016 | ||
|
|
c7cba0d626 | ||
|
|
b96e007a2c | ||
|
|
a2e9b5ade2 | ||
|
|
6f8c2a657e | ||
|
|
e2d9e7b493 | ||
|
|
3b66750a5a | ||
|
|
0232031bbe | ||
|
|
734f8efedd | ||
|
|
8a66e7bdc5 | ||
|
|
fec54d9361 | ||
|
|
f5112a3d52 | ||
|
|
234339a786 | ||
|
|
7f7921260c | ||
|
|
b6d98e28dc | ||
|
|
fba23e9ddb | ||
|
|
532b0f7321 | ||
|
|
02b231db6d | ||
|
|
313386c1ac | ||
|
|
e8260b1b7a | ||
|
|
3c73045088 | ||
|
|
753d073f1b | ||
|
|
7da7ed226e | ||
|
|
e095ef1fe1 | ||
|
|
7d67d13b62 | ||
|
|
93fc6fd082 | ||
|
|
1503f0743c | ||
|
|
b377fab0a5 | ||
|
|
7bf814f6b4 | ||
|
|
6f0e14a271 | ||
|
|
990e621ec4 | ||
|
|
5e2913db34 | ||
|
|
415722439a | ||
|
|
b3a2891c2e | ||
|
|
54bb194531 | ||
|
|
5ea5590673 | ||
|
|
01ce23ca76 | ||
|
|
5b963dce08 | ||
|
|
abfc0071f1 | ||
|
|
7de7dbc991 | ||
|
|
84dc9c4f22 | ||
|
|
f6bfd6953f | ||
|
|
da76c806cc | ||
|
|
ff418eaaac | ||
|
|
b044038857 | ||
|
|
32266dcd93 | ||
|
|
cd8a88733f | ||
|
|
a6b35f32e0 | ||
|
|
efe4911eb0 | ||
|
|
58d891f63d | ||
|
|
62ade9b9ad | ||
|
|
330f80fcf5 | ||
|
|
da5fe1359b | ||
|
|
546c09b3ba | ||
|
|
2fe28bf0a0 | ||
|
|
d7da6555f1 | ||
|
|
c6ad0bd55d | ||
|
|
2446ed8ddb | ||
|
|
8d97b16e90 | ||
|
|
e4d06b2f63 | ||
|
|
6b88e2ea64 | ||
|
|
62308e247e | ||
|
|
3a8e6e2860 | ||
|
|
45b154e40b | ||
|
|
5fcf82abfc | ||
|
|
58923a22e8 | ||
|
|
426217327b | ||
|
|
946004f0d6 | ||
|
|
bbfc408ab8 | ||
|
|
00e6fc1d59 | ||
|
|
58de7d4dab | ||
|
|
c1347d28c5 | ||
|
|
22a821c742 | ||
|
|
45e1c38d60 | ||
|
|
5c0b4e3325 | ||
|
|
ef684c5df4 | ||
|
|
e21becfa85 | ||
|
|
c1e86b7f02 | ||
|
|
25e9be3936 | ||
|
|
22ac265435 | ||
|
|
9e16cddda8 | ||
|
|
6020d94d55 | ||
|
|
a3e8c86331 | ||
|
|
9f01b21a54 | ||
|
|
5633453f5b | ||
|
|
d8df6f6895 | ||
|
|
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 | ||
|
|
df674d0d53 | ||
|
|
18a7a063fe | ||
|
|
9f52819fe9 | ||
|
|
ce472a251c | ||
|
|
a9bcc48df7 | ||
|
|
9e2596e975 | ||
|
|
1b4db71957 | ||
|
|
3b5c5d563b | ||
|
|
b0f56ed169 | ||
|
|
dc97118c6f | ||
|
|
8c90ef962a | ||
|
|
d27f45e063 | ||
|
|
9aeb9fb752 | ||
|
|
4adbd6cee9 | ||
|
|
60fa813eb2 | ||
|
|
569a3b3669 | ||
|
|
40955aae7c | ||
|
|
d1d8c3f652 | ||
|
|
8303cfa56f | ||
|
|
554821afef | ||
|
|
0e25cf4c26 | ||
|
|
3065cd6da9 | ||
|
|
ac1a192a3a | ||
|
|
9daf39e24c | ||
|
|
1aa499c84f | ||
|
|
0f4c79b629 | ||
|
|
805a121e15 | ||
|
|
e1d9f24312 | ||
|
|
c5a8420089 | ||
|
|
7d7a724764 | ||
|
|
52eb736241 | ||
|
|
be3912f986 | ||
|
|
4484c2d1b0 | ||
|
|
2cac36a4e2 | ||
|
|
6d3e7aad20 | ||
|
|
b962603707 | ||
|
|
11455e0fe9 | ||
|
|
a911ab9614 | ||
|
|
afdb21bad6 | ||
|
|
e8b181de39 | ||
|
|
0efddc3894 | ||
|
|
12d43b1f99 | ||
|
|
41d465a678 | ||
|
|
d8e64a73bf | ||
|
|
771f7e869f | ||
|
|
a4156d2910 | ||
|
|
657a8f4df6 | ||
|
|
ab10e37d43 | ||
|
|
5be9a8caef | ||
|
|
1ac0e475c1 | ||
|
|
107aa27d0a | ||
|
|
167e06f695 | ||
|
|
cc26c29217 | ||
|
|
a00b2e0583 | ||
|
|
ba3455cc6e | ||
|
|
23bdebd973 | ||
|
|
d9584410af | ||
|
|
c4536f412b | ||
|
|
7fd44641f2 | ||
|
|
3723fcffbd | ||
|
|
120b6830ff | ||
|
|
a52e2bb6b3 | ||
|
|
8408cc72b8 | ||
|
|
0b40964202 | ||
|
|
3489e01a22 | ||
|
|
8538dd740f | ||
|
|
d2606b3f51 | ||
|
|
4b29d8c34d | ||
|
|
d331db00aa | ||
|
|
b525fde8d1 | ||
|
|
f40c600e67 | ||
|
|
9c2d61f6ae | ||
|
|
9c1d65d924 | ||
|
|
e4fe308cdf | ||
|
|
5d1c24d061 | ||
|
|
01daf92fc3 | ||
|
|
d4394e87cc | ||
|
|
c56a65f58b | ||
|
|
924310c124 | ||
|
|
0404448f74 | ||
|
|
4ee413acde | ||
|
|
f05eecdaa0 | ||
|
|
8cccc48516 | ||
|
|
1f6010e07c | ||
|
|
90d7521840 | ||
|
|
c6a3f25224 | ||
|
|
665a578957 | ||
|
|
44ac32db0c | ||
|
|
f948e08fbc | ||
|
|
65f5771a6e | ||
|
|
b83a0de7a8 | ||
|
|
33d59932f9 | ||
|
|
c8ba7b4a4b | ||
|
|
049f97a4c3 | ||
|
|
62a291fcb8 | ||
|
|
7459cc7f86 | ||
|
|
ce6726aad3 | ||
|
|
692d52c514 | ||
|
|
d27ae11d15 | ||
|
|
0a5eceedf1 | ||
|
|
b640069961 | ||
|
|
9f935718fe | ||
|
|
1c863aa9f5 | ||
|
|
891f0acb3c | ||
|
|
55c05338a1 | ||
|
|
4e3fd82bd1 | ||
|
|
41b08a2393 | ||
|
|
730e377418 | ||
|
|
175fdfa428 | ||
|
|
d60a27b675 | ||
|
|
fd6a7e0ba3 | ||
|
|
db8bf6ba33 | ||
|
|
e3c52b56dd | ||
|
|
fd24a6b883 | ||
|
|
4d9cde8fee | ||
|
|
f8458b70a8 | ||
|
|
0a84dee777 | ||
|
|
8cfd43f1c0 | ||
|
|
46316c9895 | ||
|
|
0e1ccb55d2 | ||
|
|
ec8a506353 | ||
|
|
eb741a362d | ||
|
|
97e422226f | ||
|
|
8ba09001cd | ||
|
|
35005a571d | ||
|
|
97d09369d1 | ||
|
|
69154a03f1 | ||
|
|
9809caaa30 | ||
|
|
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 | ||
|
|
cdcf197cad | ||
|
|
59fe9b5ea8 | ||
|
|
862d73bf9f | ||
|
|
9dcfe4ecbc | ||
|
|
2fe7a9449a | ||
|
|
916ee3276c | ||
|
|
97f9c2fc5d | ||
|
|
4a2d19f0db | ||
|
|
a7bcc6d6ff | ||
|
|
f4d53a659b | ||
|
|
3074db1cbb | ||
|
|
4f9023d80f | ||
|
|
825bb708df | ||
|
|
55db465d8e | ||
|
|
e9e2d87405 | ||
|
|
44128f7e97 | ||
|
|
7a5885cde9 | ||
|
|
782c9757f3 | ||
|
|
5454bdc244 | ||
|
|
843b00b22f | ||
|
|
9fa99f8594 | ||
|
|
85d97a831c | ||
|
|
7d0f9d7518 | ||
|
|
4588832c80 | ||
|
|
bc5b474293 | ||
|
|
18e3070d4c | ||
|
|
3062c6c4d6 | ||
|
|
4376f659ac | ||
|
|
0ba8e30fbc | ||
|
|
fdf397d7dc | ||
|
|
d1fcf69b7b | ||
|
|
49010614fd | ||
|
|
778b8c6807 | ||
|
|
1593b163fb | ||
|
|
102429e6b5 | ||
|
|
6ce65b24f4 | ||
|
|
d0c1c12969 | ||
|
|
a4f4869443 | ||
|
|
1e0641a0bd | ||
|
|
a57976e88f | ||
|
|
3c28f605d1 | ||
|
|
76a67e7ee5 | ||
|
|
330b237c9c | ||
|
|
7689af50d6 | ||
|
|
d769320d45 | ||
|
|
01c0b38669 | ||
|
|
c3440472a0 | ||
|
|
b61122ccca | ||
|
|
6a96a6039c | ||
|
|
b56b57a4fc | ||
|
|
4fbe8d2976 | ||
|
|
8c731ddedc | ||
|
|
c3e2caf101 | ||
|
|
524b538908 | ||
|
|
72b826d65b | ||
|
|
0d125374d3 | ||
|
|
4c7cf899f4 | ||
|
|
745e599118 | ||
|
|
ffc01cbac9 | ||
|
|
d2e0ac40aa | ||
|
|
4ed5190d7f | ||
|
|
3ce80b25ab | ||
|
|
ff3981de73 | ||
|
|
f32e8c5d2d | ||
|
|
4812b3ebfa | ||
|
|
2c51dbe4e3 | ||
|
|
261225340f | ||
|
|
0444dc39a0 | ||
|
|
01463fb78e | ||
|
|
3acffcd362 | ||
|
|
2ea8c3f86f | ||
|
|
9ba207e900 | ||
|
|
6551f870b6 | ||
|
|
7a9e9de38c | ||
|
|
96d62f2653 | ||
|
|
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 | ||
|
|
48b62a8b95 | ||
|
|
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 | ||
|
|
3d90d8edfe | ||
|
|
87b9a3a9ce | ||
|
|
12d4dbfbc9 | ||
|
|
3edb15c417 | ||
|
|
93a75a4720 | ||
|
|
01a0f4f332 | ||
|
|
b87d2e476f | ||
|
|
2aa3ca3c45 | ||
|
|
39d0f2d872 | ||
|
|
7a8e5373b1 | ||
|
|
c02e864873 | ||
|
|
3c03f370f9 | ||
|
|
8efb66ba19 | ||
|
|
65e52aa7b9 | ||
|
|
1ddc2c338b | ||
|
|
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
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -0,0 +1,32 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directories:
|
||||
- "/"
|
||||
- "/plugins"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
+16
-71
@@ -1,9 +1,7 @@
|
||||
# CI for pull requests and main: format, clippy, tests, dependency layering, asset attribution and
|
||||
# the wasm32 check (the same gates as `cargo xtask ci`). Linux only: macOS is built and tested only
|
||||
# by release.yml (the org's macOS runners are kept for releases; craftrules release/playbook.md),
|
||||
# and Windows/FreeBSD packaging have their own workflows.
|
||||
name: CI
|
||||
|
||||
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
|
||||
# suite's shared gate workflow in craft-common-actions.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -13,74 +11,21 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
|
||||
# debug info: faster codegen and links, and a smaller target dir.
|
||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||
|
||||
# A newer push to a PR branch cancels its older run. Runs on main are never cancelled, so every
|
||||
# main state that gets a run is checked to the end.
|
||||
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: test (ubuntu-latest)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Linux system dependencies (egui/winit/rfd)
|
||||
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 libgtk-3-dev
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
|
||||
# Only main saves caches; PRs restore main's, so PR caches don't evict main's.
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Format
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
|
||||
# Without craft-fonts (the optional CRAFT_FONTS_DIR input, which .cargo/config.toml points at
|
||||
# a sibling checkout that CI doesn't have): everything must build and pass without it.
|
||||
- name: Test
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
- name: Dependency layering
|
||||
run: cargo run --locked -q -p xtask -- layers
|
||||
|
||||
- name: Asset attribution
|
||||
run: cargo run --locked -q -p xtask -- assets
|
||||
|
||||
wasm:
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: wasm32
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
cache-on-failure: true
|
||||
# The engine, the UI crate and apps/figcraft-web must keep compiling for the browser.
|
||||
- name: Check wasm-safe crates
|
||||
run: cargo run --locked -q -p xtask -- wasm
|
||||
gate:
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# The craft-fonts commit release.yml builds with. Needs fonts/latin-manifest.txt (Inter):
|
||||
# crates/text/build.rs fails a build that requires craft-fonts without it. Only craft-fonts'
|
||||
# pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. Re-pin
|
||||
# once one commit has both (storytold/craft-fonts#42).
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
packages: >-
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
libgtk-3-dev
|
||||
@@ -1,21 +1,22 @@
|
||||
# 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
|
||||
# or dependency change: a drifted dependency still shows up within a day. PRs that change the
|
||||
# FreeBSD packaging itself still run it.
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
|
||||
# request: a drifted dependency still shows up within a day, and always before a release ships.
|
||||
# PRs that change the FreeBSD workflow or packaging itself still run it.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 2 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
schedule:
|
||||
- cron: "17 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -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: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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:
|
||||
nix:
|
||||
# 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/nix.yml@main
|
||||
@@ -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
|
||||
+30
-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,40 @@ 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
|
||||
# Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only
|
||||
# craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter.
|
||||
# Re-pin once one commit has both (storytold/craft-fonts#42).
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
has-server: true
|
||||
# The browser has no system fonts: without craft-fonts the web build can't draw text (#885).
|
||||
# crates/text/build.rs keeps the web build to Inter.
|
||||
web-craft-fonts: 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 }}
|
||||
@@ -0,0 +1,20 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -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
|
||||
@@ -81,6 +81,7 @@ code.
|
||||
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
### Quality gates (before every commit)
|
||||
|
||||
@@ -25,6 +25,7 @@ Release builds embed fonts from the optional craft-fonts build input
|
||||
| Family | Licence | Upstream |
|
||||
|---|---|---|
|
||||
| Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter |
|
||||
| Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) |
|
||||
| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
|
||||
| Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts |
|
||||
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
|
||||
|
||||
@@ -81,6 +81,7 @@ code.
|
||||
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
### Quality gates (before every commit)
|
||||
|
||||
Generated
+339
-262
File diff suppressed because it is too large.
Load diff
+17
-5
@@ -6,7 +6,7 @@ exclude = ["plugins"]
|
||||
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.0"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -32,6 +32,12 @@ figcraft-mcp = { path = "crates/mcp" }
|
||||
figcraft-collab-net = { path = "crates/collab-net" }
|
||||
figcraft-plugin = { path = "crates/plugin" }
|
||||
|
||||
# Which paths stored in a document may be followed (craft-libs).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
# Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the
|
||||
# same native titlebar per desktop (craft-libs). The `egui` feature adds `decorate`/`decorations`.
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
thiserror = "2"
|
||||
@@ -39,6 +45,8 @@ serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
regex = "1.13"
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
proptest = "1"
|
||||
imbl = { version = "7", features = ["serde"] }
|
||||
postcard = { version = "1", default-features = false, features = ["alloc"] }
|
||||
@@ -59,7 +67,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"
|
||||
@@ -95,11 +103,15 @@ todo = "deny"
|
||||
unimplemented = "deny"
|
||||
unreachable = "deny"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
# Workspace crates keep file:line in panic backtraces; dependencies carry no debug info. Both make
|
||||
# the test build faster and the target dir smaller.
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
|
||||
@@ -20,3 +20,34 @@ licence texts.
|
||||
Rust crate dependencies keep their own licenses (MIT, Apache-2.0, BSD, ISC, Zlib, OFL for egui's
|
||||
default fonts, CDLA-Permissive-2.0 for the root certificate list webpki-roots compiles in, and
|
||||
similar permissive licenses); see each crate's metadata.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -7,10 +7,14 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.figcraft.png" width="128" height="128" alt="FigCraft icon: an engraved chameleon on a mint-green tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">FigCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Interface design; an open-source, clean-room reimplementation of Figma, rebuilt in pure Rust.</b><br>
|
||||
<b>Interface design and prototyping: open source, collaborative and built in pure Rust.</b><br>
|
||||
Frames, auto layout, components and variants, vector networks, styles, variables and prototypes,<br>
|
||||
in a native app that opens instantly and works offline. Open source, and yours.
|
||||
</p>
|
||||
@@ -20,6 +24,9 @@
|
||||
<img alt="macOS · Windows · Linux · FreeBSD · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-native-0EA5C6?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
|
||||
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_figcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/figcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -62,7 +69,7 @@
|
||||
<tr>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🎛️ Familiar by design</h3>
|
||||
The toolbar, layers panel, Design panel, menus and shortcuts sit where your hands expect them. If you know Figma, you already know FigCraft.
|
||||
The toolbar, layers panel, Design panel, menus and shortcuts sit right where your hands expect them.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>⚡ Native and fast</h3>
|
||||
@@ -70,7 +77,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 +198,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 +234,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)).
|
||||
- **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).
|
||||
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and Georgian 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 three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.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 +258,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/).
|
||||
@@ -258,7 +269,32 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs
|
||||
Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
|
||||
|
||||
> [!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).
|
||||
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing proprietary tools for real work, by our own estimate. Nearly all tracked interface design actions in the menu checklist are 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, and hasn't been checked against real design 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
|
||||
|
||||
@@ -319,8 +355,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and FigCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Figma is a trademark of Figma, Inc. FigCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Figma, Inc.; the name is used only to describe the workflows FigCraft is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="https://raw.githubusercontent.com/storytold/vectorcraft/main/docs/brand/artcraft-mark.png" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+192
-21
@@ -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 (community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **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%** (212 / 212) | 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
|
||||
@@ -50,7 +50,7 @@ against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40–
|
||||
~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured
|
||||
fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h),
|
||||
basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h);
|
||||
native speakers' reviews of the six language drafts need people.
|
||||
native speakers' reviews of the nine language drafts need people.
|
||||
|
||||
## By dimension
|
||||
|
||||
@@ -58,12 +58,12 @@ 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) |
|
||||
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
|
||||
| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
|
||||
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) |
|
||||
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
|
||||
@@ -86,7 +86,7 @@ native speakers' reviews of the six language drafts need people.
|
||||
| Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) |
|
||||
| Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) |
|
||||
| Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Dev Mode / inspect | 96% | 72% | 0–1 | [dev-mode-parity](docs/dev-mode-parity.md) |
|
||||
| Dev Mode / inspect | 97% | 76% | 2–4 | [dev-mode-parity](docs/dev-mode-parity.md) |
|
||||
| Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) |
|
||||
@@ -99,44 +99,112 @@ native speakers' reviews of the six language drafts need people.
|
||||
| Language | Code | Strings | Status |
|
||||
|---|---|---|---|
|
||||
| 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) |
|
||||
| Simplified Chinese | zh-Hans | 95% | draft (needs native review) |
|
||||
| Spanish | es, es-419 | 100% (es-419: an overlay of 101 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) |
|
||||
| Indonesian | id | 0% | none |
|
||||
| French | fr | 95% | draft (needs native review) |
|
||||
| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) |
|
||||
| Indonesian | id | 99% | draft (needs native review) |
|
||||
| Japanese | ja | 99% | draft (needs native review) |
|
||||
| German | de | 99% | draft (needs native review) |
|
||||
| Korean | ko | 99% | draft (needs native review) |
|
||||
| German | de | 96% | draft (needs native review) |
|
||||
| Korean | ko | 95% | draft (needs native review) |
|
||||
| Vietnamese | vi | 0% | none |
|
||||
|
||||
No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
|
||||
Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). 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 ▸
|
||||
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸
|
||||
Language; Traditional Chinese has no catalog; CJK UI
|
||||
text uses an installed CJK font, loaded only when needed; adding a language:
|
||||
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
|
||||
IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
|
||||
IME) already works (macOS IME composition fixed for #747, not yet checked on a Mac). Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
|
||||
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).
|
||||
4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi, Vietnamese and Arabic ([localization-parity](docs/localization-parity.md)).
|
||||
5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h.
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged.
|
||||
- **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged.
|
||||
- **2026-10-15** · Batch step references (M0.9, #400): a `command.batch` step can use the id an earlier step made (`{"$step": n}`, a reserved key), so a nested tree takes one call and one undo step. Numbers unchanged.
|
||||
- **2026-10-15** · Removing someone from a live session (M9, #338): the host or the file's owner removes a person from the avatar's menu or with `collab.removePerson`; they keep their copy and can't rejoin while the session runs. Numbers unchanged.
|
||||
- **2026-10-15** · Languages (M10, #257, #263, #469, #477, #288): Russian (one/few/many plurals) and Turkish catalogs reviewed by native speakers, Czech and Indonesian drafts, the Japanese draft completed; Preferences ▸ Language lists 12 languages and Latin American Spanish. Numbers unchanged.
|
||||
- **2026-10-15** · Zoom previews (M8, #312): when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it. Numbers unchanged.
|
||||
- **2026-10-15** · Web clipboard (M1, #375): copy and paste in the browser go through the system clipboard. Numbers unchanged.
|
||||
- **2026-10-15** · Alt-drag copies snap to their original and Duplicate (Cmd+D) repeats the last copy's offset (M1, #283). Numbers unchanged.
|
||||
- **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged.
|
||||
- **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-15** · Dev Mode tail (M6, gap 16): generated layer trees start with the import lines of the connected components they call, once each per language; Compare changes takes any saved version as the before; a behaviour suite made the Dev Mode canvas inspect-only (no moving, resizing, drawing or editing; menus and shortcuts refuse design edits with a note), added Changed to the ready list and refused statuses on layers inside frames; read-only plugins show their panels from the Inspect tab. Dev Mode 97% / 76%.
|
||||
- **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 +325,109 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged |
|
||||
| 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Removing someone from a live session (#338): right-click a person's avatar ▸ Remove *name* from session, or `collab.removePerson {client}`, when you host the session or own the file on a server; the session ends for them (they keep their copy), the code or link they hold no longer gets them back in while it runs, and everyone else stays. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Pages list (#272, #258): a long pages list scrolls in its own area, so the layers below stay in view; page and layer drops count only inside the list's visible rows. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Variables export in the browser (#368): Export… ▸ Variables JSON / CSS custom properties did nothing in the web build and now arrives as a download; importing there still needs a file picker. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Font picker keys (#352, #345): Up and Down step through the font families from the current one, keeping the highlight in view and previewing it on the canvas; Enter applies it, Escape leaves the text as it was. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Leaving Design ends the pen path (#308, #307): switching to Prototype or Inspect while the Pen draws ends the path and goes back to Move (pressing Design again keeps drawing), and a click on the toolbar no longer reaches the canvas under it. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Vector edit mode keeps the canvas still (#322): the edit mode hints in the Design panel wrap, so entering edit mode and picking a region no longer shift the canvas by about 25 points in the middle of the click (which dragged the region picked). Numbers unchanged |
|
||||
| 2026-10-15 | minor | Drop shadows under a background blur (#317, #255): a layer with both casts its shadows from what it paints (a translucent fill casts a lighter shadow than an opaque one) and reads the backdrop only after drawing them, so a shadow shown behind transparent areas is seen through the blur. Numbers unchanged |
|
||||
| 2026-10-15 | minor | When the window can't open (#306, #261): Windows release builds have no console, so a graphics failure at start quit without a word; the error is now logged and shown in a message box in the interface language, with what to try (unset `WGPU_BACKEND`, update the driver, OpenGL) and where to report it, skipped with `--control`. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Crashes on Windows (#271, #314, #337; #224 and two more): a text field focused before it was drawn aborted the screen-reader layer; double-clicking a page, creating a variable collection and renaming a style folder no longer close the app. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Deeply nested layers (#330, #210): one depth guard (256) on the three recursive layout paths, so a deep file can't run the layout out of stack. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Image memory budgets (#326; #203, #189): decoded images and a file's images have budgets; the decode cache evicts its least recently used entries. Numbers unchanged |
|
||||
| 2026-10-15 | minor | macOS file dialogs (#253, #250) no longer re-enter the event loop; a dialog's result goes to the file and window it was opened for, and dialogs are parented to the main window. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Recovery copies (#297, #303, #331, #336; #202, #185, #187): a copy counts only after its write succeeds and a failed one is retried; saves and closes wait for the write in flight; copies keep version history and branches; a recovered copy stays until this run has its own. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Text edits (#280, #278) no longer roll back earlier changes; a drag released off the canvas ends at the pointer's last canvas position. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Rollback (#282, #301; #196): a failed command rolls back during a drag or other gesture too; a failed batch keeps the redo steps. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Atomic save and export (#284): staging files are uniquely named and created exclusively, so a file or link already at the old fixed staging name is never written through, and two writers of one file no longer share one. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Alt-drag copies (#283) snap to their original, and Duplicate (Cmd+D) then repeats the last copy's offset. Numbers unchanged |
|
||||
| 2026-10-15 | minor | The pen (#277) shows its Bezier handles while a point is dragged out. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Corner handles (#293, #292): a press on a corner handle resizes instead of picking the corner-radius handle. Numbers unchanged |
|
||||
| 2026-10-15 | minor | New text (#372, #362) takes a readable colour from the frame it is drawn in, not only the page (text drawn in a white frame on a dark page was white). Numbers unchanged |
|
||||
| 2026-10-15 | minor | Outline stroke (#386) works on every selected layer. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Send to back (#444) counts per parent when the selection spans frames. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Align buttons (#276) are off when the selection has nothing to align to. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Gradient type icons (#275) show their gradient. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Corner resize cursors (#388) stay diagonal at any proportions; the text selection highlight (#390) stays inside the box on the last line. Numbers unchanged |
|
||||
| 2026-10-15 | minor | New pages (#311, #295) take the current page's background; the theme still decides a new file's page. Numbers unchanged |
|
||||
| 2026-10-15 | minor | A slice's export preview (#265) shows the content under it. Numbers unchanged |
|
||||
| 2026-10-15 | minor | SVG export (#327, #325) keeps a nested frame's fill and stroke inside its parent's clip. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Home screen (#318, #324): recent files notice files deleted while it is open; a long path no longer draws over the file name. Numbers unchanged |
|
||||
| 2026-10-15 | minor | The Small caps sample in Type settings (#355) draws from capitals, no longer a box where the UI font lacks the small-capital letter. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Web clipboard (#375): copy and paste in the browser go through the system clipboard, so a paste after a copy brings the copied layers instead of whatever text the clipboard held. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Zoom previews (#312): when renders from scratch are slow, a new zoom first shows the whole view coarsely instead of the page colour, then the refined image; images of another file or page never show. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Hidden windows (#313, #332; #269): a minimised or covered window answers control and MCP requests (commands, inspection, `ui.render`) at once, each window on its own; screenshots and input still wait for it to be on screen. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Server (#340, #302): a compaction cut short closes the file instead of confirming lost edits (#183); each team library version is kept in its own file (#206). Numbers unchanged |
|
||||
| 2026-10-15 | minor | Counters (#343, #344, #353; #309): collab sequence numbers, layer name counters and opacity digits can't overflow. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Commands refuse bad indexes and ids (#475, #384, #310): `page.select` and `doc.switch`, `effect.set` and `export.update` / `export.remove`, `page.move` with a non-page id. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Find and replace (#358, #361) changes every occurrence, case-insensitively, also when the replacement contains the search text. Numbers unchanged |
|
||||
| 2026-10-15 | minor | The gradient shorthand (#254, #252) is read by the engine, so the CLI, control channel and MCP share it. Numbers unchanged |
|
||||
| 2026-10-15 | minor | .fig import (#279, #281): resized instances lay out their layers by their constraints (#266); explicit variable modes whose collection is named by its asset key, as library collections are, come across (#267). Numbers unchanged |
|
||||
| 2026-10-15 | minor | Colour emoji (#300, #268) draw from their own colour tables (sbix and CBDT/EBDT bitmap strikes, COLR v0 layers), a colour face tried first; COLR v1 still draws outlines. Numbers unchanged |
|
||||
| 2026-10-15 | minor | macOS (#290, #289): `.figcraft` files open from Finder, Open With and the Dock. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Windows (#294, #291): the first preferred language alone picks the UI language. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Unix font discovery (#474) follows `XDG_DATA_HOME`. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Linux packaging (#346, #328): the window id matches the desktop entry (FreeBSD keeps its class); the RPM requires glibc 2.35. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Tests (#472, #471, #339): the rename shortcut test, the hanging punctuation test and the font-scanning tests no longer depend on the platform, font or test order. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Languages (#257, #263, #469, #477, #288): Russian (with one/few/many plural forms) and Turkish catalogs, reviewed by native speakers; Czech and Indonesian drafts; the Japanese draft completed. 2,177 entries now (1,587 UI strings, 559 command labels, 31 menu wordings); strings added since a catalog was written read English there. Localization 39% and 29–51 h unchanged at this rounding (Indonesian is now a draft, while the five older drafts lack 88 newer entries) |
|
||||
| 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged |
|
||||
| 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. |
|
||||
| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |
|
||||
| 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged |
|
||||
| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged |
|
||||
| 2026-10-20 | minor | Release fonts (#885): the web release embeds craft-fonts again (Inter), so the browser version draws text and lists fonts; it had none since releases moved to the shared workflow. The craft-fonts pin goes back to the commit with the Latin manifest (the Georgian change had moved it to one without Inter, which would have failed the next release). Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged |
|
||||
| 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
|
||||
| 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 | Dev Mode tail (M6, gap 16): import lines for connected components inside generated layer trees (definitions, slots and swapped-in instances too); `dev.compare {version}` and the compare view's Compare with menu compare with any saved version; behaviour suite (5 tests; 4 failed before the fixes): an inspect-only Dev Mode canvas with menus, shortcuts, nudges, opacity keys and paste refusing design edits, toolbar drawing tools returning to the design, Changed in the ready list (`inspect.readyForDev {status: changed}`), statuses only on sections and top-level frames; read-only plugins' panels from the Inspect tab. Dev Mode 96%/72% → 97%/76%, 0–1 h → 2–4 h (measurements kept on the canvas and a read-only layers panel counted as missing); header totals not recomputed |
|
||||
| 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" });
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value`
|
||||
//! options, and boolean `--flag`s. Unknown options are usage errors.
|
||||
//! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s.
|
||||
//! Unknown options are usage errors.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -8,7 +9,7 @@ use crate::{CliError, CliResult};
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Args {
|
||||
pub positional: Vec<String>,
|
||||
values: BTreeMap<String, String>,
|
||||
values: BTreeMap<String, Vec<String>>,
|
||||
flags: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -34,7 +35,7 @@ impl Args {
|
||||
Some(v) => v,
|
||||
None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?,
|
||||
};
|
||||
a.values.insert(name.to_string(), v);
|
||||
a.values.entry(name.to_string()).or_default().push(v);
|
||||
} else if flags.contains(&name) && inline.is_none() {
|
||||
a.flags.push(name.to_string());
|
||||
} else {
|
||||
@@ -45,7 +46,12 @@ impl Args {
|
||||
}
|
||||
|
||||
pub fn value(&self, k: &str) -> Option<String> {
|
||||
self.values.get(k).cloned()
|
||||
self.values.get(k).and_then(|v| v.last()).cloned()
|
||||
}
|
||||
|
||||
/// Every value of an option that may be given more than once, in order.
|
||||
pub fn values(&self, k: &str) -> &[String] {
|
||||
self.values.get(k).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
pub fn flag(&self, k: &str) -> bool {
|
||||
@@ -62,9 +68,9 @@ impl Args {
|
||||
/// PDF print options: `--bleed LENGTH` (points, or "3mm", "0.125in"), `--crop-marks`,
|
||||
/// `--bleed-marks`, `--cmyk`.
|
||||
pub fn print(&self) -> CliResult<figcraft_doc::PrintSettings> {
|
||||
let bleed = match self.values.get("bleed") {
|
||||
let bleed = match self.value("bleed") {
|
||||
None => 0.0,
|
||||
Some(s) => figcraft_doc::PrintSettings::parse_length(s)
|
||||
Some(s) => figcraft_doc::PrintSettings::parse_length(&s)
|
||||
.filter(|b| *b <= figcraft_doc::PrintSettings::MAX_BLEED)
|
||||
.ok_or_else(|| CliError::Usage(format!("--bleed takes points or a length like 3mm or 0.125in (up to 2in), got `{s}`")))?,
|
||||
};
|
||||
@@ -72,7 +78,7 @@ impl Args {
|
||||
}
|
||||
|
||||
pub fn scale(&self) -> CliResult<f64> {
|
||||
match self.values.get("scale") {
|
||||
match self.value("scale") {
|
||||
None => Ok(1.0),
|
||||
Some(s) => s
|
||||
.trim_end_matches('x')
|
||||
@@ -85,7 +91,7 @@ impl Args {
|
||||
|
||||
/// A comma-separated list of whole numbers (`--layers 1000,10_000`).
|
||||
pub fn usizes(&self, k: &str, default: &[usize]) -> CliResult<Vec<usize>> {
|
||||
match self.values.get(k) {
|
||||
match self.value(k) {
|
||||
None => Ok(default.to_vec()),
|
||||
Some(s) => s
|
||||
.split(',')
|
||||
@@ -110,6 +116,10 @@ mod tests {
|
||||
assert_eq!(a.value("o").as_deref(), Some("out"));
|
||||
assert_eq!(a.scale().unwrap(), 2.0);
|
||||
assert!(a.flag("json"));
|
||||
let a = Args::parse(&v(&["--root", "a", "--root=b"]), &["root"], &[]).unwrap();
|
||||
assert_eq!(a.values("root"), ["a", "b"]);
|
||||
assert_eq!(a.value("root").as_deref(), Some("b"));
|
||||
assert!(a.values("none").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+150
-16
@@ -29,16 +29,27 @@ fn image_format(e: &str) -> Option<ExportFormat> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes through [`figcraft_engine::file_access`] when automation roots are in force, so a
|
||||
/// confined run can only write inside a write root (#833).
|
||||
///
|
||||
/// Unconfined it writes with `std::fs::write`, as it always did. `file_access::write` stages the
|
||||
/// bytes and renames them into place, which **replaces** a symbolic link at `path` instead of
|
||||
/// writing through it to its target -- a change of behaviour for anyone whose output path is a
|
||||
/// link, and nothing to do with confinement. Inside a root the staged write is the right one:
|
||||
/// `Roots::check` has already refused a link that leads out of the root.
|
||||
fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
|
||||
if let Some(dir) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| CliError::Failed(format!("{}: {e}", dir.display())))?;
|
||||
figcraft_engine::file_access::create_dir_all(dir).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
}
|
||||
if figcraft_engine::file_access::current().is_some() {
|
||||
return figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string()));
|
||||
}
|
||||
std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
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 +113,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())?,
|
||||
@@ -155,6 +166,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
|
||||
Some(json!({"id": p, "name": pn.name, "nodes": nodes, "topLevel": pn.children.len(), "frames": frames}))
|
||||
})
|
||||
.collect();
|
||||
let missing = figcraft_engine::fonts::missing(&doc);
|
||||
let v = json!({
|
||||
"file": path,
|
||||
"name": doc.name,
|
||||
@@ -164,6 +176,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
|
||||
"variables": doc.variables.variables.len(),
|
||||
"images": doc.images.len(),
|
||||
"notes": notes,
|
||||
"missingFonts": missing.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::<Vec<_>>(),
|
||||
});
|
||||
if as_json {
|
||||
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
@@ -187,6 +200,10 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
|
||||
println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]);
|
||||
}
|
||||
}
|
||||
for m in &missing {
|
||||
let layers = if m.layers == 1 { "text layer" } else { "text layers" };
|
||||
println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family, m.layers);
|
||||
}
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
@@ -238,7 +255,7 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
|
||||
println!("{out}");
|
||||
return Ok(ExitCode::SUCCESS);
|
||||
}
|
||||
std::fs::create_dir_all(out).map_err(|e| CliError::Failed(format!("{out}: {e}")))?;
|
||||
figcraft_engine::file_access::create_dir_all(out).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
let ids = match target {
|
||||
Some(t) => vec![t],
|
||||
None => {
|
||||
@@ -323,7 +340,8 @@ pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
|
||||
}
|
||||
|
||||
pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult<ExitCode> {
|
||||
let text = std::fs::read_to_string(script).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
|
||||
let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?;
|
||||
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
|
||||
let mut s = match input {
|
||||
Some(path) => {
|
||||
@@ -333,17 +351,32 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
|
||||
}
|
||||
None => Session::new(),
|
||||
};
|
||||
for (i, (cmd, params)) in steps.iter().enumerate() {
|
||||
let r = s.execute(cmd, params);
|
||||
s.ui_requests.clear();
|
||||
match r {
|
||||
Ok(v) => println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})),
|
||||
Err(e) => {
|
||||
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string()}));
|
||||
eprintln!("error: step {i} ({cmd}) failed: {e}");
|
||||
return Ok(ExitCode::from(1));
|
||||
}
|
||||
// One undo step and all-or-nothing, the way the same commands behave through `command.batch`
|
||||
// (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and
|
||||
// a step that fails takes the whole script back instead of leaving the earlier steps applied.
|
||||
// The result lines are held back until the script is through, so none of them reports work that
|
||||
// was rolled back.
|
||||
let mut results: Vec<Value> = Vec::with_capacity(steps.len());
|
||||
let mut failed_at = 0;
|
||||
let outcome = s.grouped(&format!("run {script}"), |s| {
|
||||
for (i, (cmd, params)) in steps.iter().enumerate() {
|
||||
failed_at = i;
|
||||
let v = s.batch_step(&results, i, cmd, params)?;
|
||||
s.ui_requests.clear();
|
||||
results.push(v);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
if let Err(e) = outcome {
|
||||
let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str());
|
||||
let i = failed_at;
|
||||
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true}));
|
||||
eprintln!("error: step {i} ({cmd}) failed: {e}");
|
||||
eprintln!("the script's document changes were rolled back");
|
||||
return Ok(ExitCode::from(1));
|
||||
}
|
||||
for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() {
|
||||
println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v}));
|
||||
}
|
||||
if let Some(out) = out {
|
||||
let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
@@ -379,4 +412,105 @@ 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();
|
||||
}
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
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-{tag}-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step
|
||||
/// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it
|
||||
/// as "no parent" and the layer landed on the page — with every step reporting success.
|
||||
#[test]
|
||||
fn a_script_puts_a_layer_in_a_layer_an_earlier_step_made() {
|
||||
let dir = temp_dir("run-step");
|
||||
let script = dir.join("script.json");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"[
|
||||
{"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
|
||||
{"command": "node.create", "params": {"type": "rectangle", "name": "child", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}}
|
||||
]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let out = dir.join("out.figcraft");
|
||||
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
|
||||
|
||||
let mut s = Session::empty();
|
||||
s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let page = s.page().unwrap();
|
||||
let top = doc.node(page).unwrap().children.clone();
|
||||
assert_eq!(top.len(), 1, "only the frame is top level, not the rectangle too");
|
||||
assert_eq!(doc.node(top[0]).unwrap().name, "Card");
|
||||
let inner = doc.node(top[0]).unwrap().children.clone();
|
||||
assert_eq!(inner.len(), 1, "the rectangle went inside the frame");
|
||||
assert_eq!(doc.node(inner[0]).unwrap().name, "child");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A step that fails takes the whole script back: nothing is written, and the failure says so.
|
||||
#[test]
|
||||
fn a_failed_step_leaves_no_output() {
|
||||
let dir = temp_dir("run-rollback");
|
||||
let script = dir.join("script.json");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"[
|
||||
{"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
|
||||
{"command": "node.set", "params": {"ids": [99999], "props": {"name": "nope"}}}
|
||||
]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let out = dir.join("out.figcraft");
|
||||
// The failure is the script's, not the call's: the CLI reports it and exits non-zero.
|
||||
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
|
||||
assert!(!out.exists(), "a script that failed writes nothing");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,9 @@ USAGE:
|
||||
figcraft-cli convert <in> <out>
|
||||
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
|
||||
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
|
||||
(info, export, convert, render and run also take --automation-read-root DIR / --automation-write-root DIR)
|
||||
figcraft-cli commands [--json] [--filter TEXT]
|
||||
figcraft-cli mcp [--attach PORT]
|
||||
figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
|
||||
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
|
||||
figcraft-cli help | --help | --version
|
||||
@@ -42,8 +43,33 @@ crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines)
|
||||
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
|
||||
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
|
||||
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
|
||||
`--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
|
||||
must exist) confine the files a subcommand touches: reads only inside a read or write root, writes
|
||||
only inside a write root, everything else refused -- so giving no write root denies writing
|
||||
altogether. They are taken by `mcp`, to confine the agent's commands, and by `info`, `export`,
|
||||
`convert`, `render` and `run`, to confine that one run; without them a subcommand reads and writes
|
||||
as the user who started it, as before. A running app confines itself: start it with
|
||||
`figcraft --control PORT` and the same flags (`--attach` refuses them).
|
||||
See docs/cli.md and docs/mcp.md.";
|
||||
|
||||
/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
|
||||
/// unconfined, as before; giving either confines every file the subcommand touches (#833).
|
||||
const ROOT_FLAGS: [&str; 2] = ["automation-read-root", "automation-write-root"];
|
||||
|
||||
fn roots_of(a: &Args) -> CliResult<Option<figcraft_engine::file_access::Roots>> {
|
||||
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
|
||||
if read.is_empty() && write.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?))
|
||||
}
|
||||
|
||||
/// Runs `f` confined to the roots `a` carries.
|
||||
fn confined<R>(a: &Args, f: impl FnOnce() -> CliResult<R>) -> CliResult<R> {
|
||||
let roots = roots_of(a)?.map(std::sync::Arc::new);
|
||||
figcraft_engine::file_access::confined(roots.as_ref(), f)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
match run(&argv) {
|
||||
@@ -94,35 +120,38 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
"info" => {
|
||||
let a = Args::parse(rest, &[], &["json"])?;
|
||||
let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?;
|
||||
let file = a.one_positional("info <file>")?;
|
||||
files::info(&file, a.flag("json"))
|
||||
confined(&a, || files::info(&file, a.flag("json")))
|
||||
}
|
||||
"export" => {
|
||||
let a = Args::parse(rest, &["format", "scale", "page", "node", "o", "out", "bleed"], &["crop-marks", "bleed-marks", "cmyk"])?;
|
||||
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = Args::parse(rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?;
|
||||
let file = a.one_positional("export <file> -o <out>")?;
|
||||
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o <out>".into()))?;
|
||||
let target =
|
||||
files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? };
|
||||
files::export(&file, &out, &target)
|
||||
confined(&a, || files::export(&file, &out, &target))
|
||||
}
|
||||
"convert" => {
|
||||
let a = Args::parse(rest, &[], &[])?;
|
||||
let a = Args::parse(rest, &ROOT_FLAGS, &[])?;
|
||||
match a.positional.as_slice() {
|
||||
[i, o] => files::convert(i, o),
|
||||
[i, o] => confined(&a, || files::convert(i, o)),
|
||||
_ => Err(CliError::Usage("convert <in> <out>".into())),
|
||||
}
|
||||
}
|
||||
"render" => {
|
||||
let a = Args::parse(rest, &["o", "out", "scale", "page"], &[])?;
|
||||
let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = Args::parse(rest, &valued, &[])?;
|
||||
let file = a.one_positional("render <file> -o <out.png>")?;
|
||||
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o <out.png>".into()))?;
|
||||
files::render(&file, &out, a.scale()?, a.value("page").as_deref())
|
||||
confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref()))
|
||||
}
|
||||
"run" => {
|
||||
let a = Args::parse(rest, &["in", "out"], &[])?;
|
||||
let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = Args::parse(rest, &valued, &[])?;
|
||||
let script = a.one_positional("run <script.json|.jsonl>")?;
|
||||
files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())
|
||||
confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref()))
|
||||
}
|
||||
"commands" => {
|
||||
let a = Args::parse(rest, &["filter"], &["json"])?;
|
||||
@@ -133,11 +162,20 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
"mcp" => {
|
||||
let a = Args::parse(rest, &["attach"], &[])?;
|
||||
let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = Args::parse(rest, &valued, &[])?;
|
||||
if !a.positional.is_empty() {
|
||||
return Err(CliError::Usage("mcp takes no positional arguments".into()));
|
||||
}
|
||||
mcp(a.value("attach").as_deref())
|
||||
let attach = a.value("attach");
|
||||
// The app runs the commands: only its own roots can confine them.
|
||||
if attach.is_some() && ROOT_FLAGS.iter().any(|f| !a.values(f).is_empty()) {
|
||||
return Err(CliError::Usage(
|
||||
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
mcp(attach.as_deref(), roots_of(&a)?)
|
||||
}
|
||||
"plugin" => {
|
||||
let a = Args::parse(rest, &["server"], &[])?;
|
||||
@@ -205,7 +243,7 @@ fn publish_plugin(folder: &str, server: &str) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
fn mcp(attach: Option<&str>) -> CliResult<ExitCode> {
|
||||
fn mcp(attach: Option<&str>, roots: Option<figcraft_engine::file_access::Roots>) -> CliResult<ExitCode> {
|
||||
let backend: Box<dyn figcraft_mcp::Backend> = match attach {
|
||||
Some(p) => {
|
||||
let addr = if p.contains(':') {
|
||||
@@ -220,7 +258,10 @@ fn mcp(attach: Option<&str>) -> CliResult<ExitCode> {
|
||||
None => Box::new(figcraft_mcp::Headless::new()),
|
||||
};
|
||||
eprintln!("figcraft MCP server ({}) on stdio", backend.describe());
|
||||
let mut server = figcraft_mcp::Server::new(backend);
|
||||
if let Some(r) = &roots {
|
||||
eprintln!("file access confined to {}", r.to_json());
|
||||
}
|
||||
let mut server = figcraft_mcp::Server::new(backend).with_roots(roots);
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
server.serve(stdin.lock(), stdout.lock()).map_err(|e| format!("stdio: {e}"))?;
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
//! The binary end to end.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
fn bin() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-cli-test-{}-{name}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_arguments_exit_2_without_panicking() {
|
||||
for args in [
|
||||
&[][..],
|
||||
&["bogus"],
|
||||
&["export"],
|
||||
&["export", "x.figcraft"],
|
||||
&["info", "--nope"],
|
||||
&["render", "a", "-o"],
|
||||
&["perf", "--shapes", "lots"],
|
||||
&["mcp", "--attach", "notaport"],
|
||||
&["convert", "a"],
|
||||
] {
|
||||
let out = bin().args(args).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains("USAGE"), "{args:?}");
|
||||
assert!(!err.contains("panicked"));
|
||||
}
|
||||
let out = bin().args(["info", "/no/such/file.figcraft"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_export_info_convert() {
|
||||
let d = tmp("run");
|
||||
let script = d.join("s.jsonl");
|
||||
std::fs::write(&script, "{\"command\":\"frame.create\",\"params\":{\"x\":0,\"y\":0,\"width\":100,\"height\":50,\"name\":\"A\",\"props\":{\"fill\":\"#ff0000\"}}}\n{\"command\":\"frame.create\",\"params\":{\"x\":200,\"y\":0,\"width\":30,\"height\":30,\"name\":\"B\"}}\n").unwrap();
|
||||
let file = d.join("doc.figcraft");
|
||||
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines.iter().all(|l| l["ok"] == true));
|
||||
|
||||
let info = bin().args(["info", "--json"]).arg(&file).output().unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap();
|
||||
assert_eq!(v["pages"][0]["frames"].as_array().unwrap().len(), 2);
|
||||
|
||||
let dir = d.join("png");
|
||||
assert!(bin().arg("export").arg(&file).arg("-o").arg(&dir).args(["--scale", "2"]).status().unwrap().success());
|
||||
let a = std::fs::read(dir.join("A.png")).unwrap();
|
||||
assert_eq!(&a[16..24], &[0, 0, 0, 200, 0, 0, 0, 100]);
|
||||
assert!(dir.join("B.png").exists());
|
||||
|
||||
let svg = d.join("b.svg");
|
||||
assert!(bin().arg("export").arg(&file).arg("-o").arg(&svg).args(["--node", "B"]).status().unwrap().success());
|
||||
assert!(std::fs::read_to_string(&svg).unwrap().contains("<svg"));
|
||||
|
||||
let json = d.join("doc.json");
|
||||
assert!(bin().arg("convert").arg(&file).arg(&json).status().unwrap().success());
|
||||
let png = d.join("doc.png");
|
||||
assert!(bin().arg("convert").arg(&json).arg(&png).status().unwrap().success());
|
||||
assert!(std::fs::read(&png).unwrap().starts_with(b"\x89PNG"));
|
||||
|
||||
// A failing step: nonzero exit, error line printed.
|
||||
let bad = d.join("bad.json");
|
||||
std::fs::write(&bad, r#"[{"command":"select.all"},{"command":"nope"}]"#).unwrap();
|
||||
let out = bin().arg("run").arg(&bad).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("\"ok\":false"));
|
||||
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();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","method":"notifications/initialized"}}"#).unwrap();
|
||||
writeln!(stdin, "garbage").unwrap();
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{{"name":"create_node","arguments":{{"type":"ellipse"}}}}}}"#)
|
||||
.unwrap();
|
||||
}
|
||||
drop(child.stdin.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines[0]["result"]["protocolVersion"], "2025-06-18");
|
||||
assert_eq!(lines[1]["error"]["code"], -32700);
|
||||
assert_eq!(lines[2]["result"]["isError"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_json_lists_everything() {
|
||||
let out = bin().args(["commands", "--json"]).output().unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert!(v.as_array().unwrap().len() > 200);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
//! The binary end to end.
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
fn bin() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-cli-test-{}-{name}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_arguments_exit_2_without_panicking() {
|
||||
for args in [
|
||||
&[][..],
|
||||
&["bogus"],
|
||||
&["export"],
|
||||
&["export", "x.figcraft"],
|
||||
&["info", "--nope"],
|
||||
&["render", "a", "-o"],
|
||||
&["perf", "--shapes", "lots"],
|
||||
&["mcp", "--attach", "notaport"],
|
||||
// An attached app confines itself: roots go to `figcraft --control`.
|
||||
&["mcp", "--attach", "7979", "--automation-read-root", "."],
|
||||
&["mcp", "--automation-write-root"],
|
||||
&["convert", "a"],
|
||||
] {
|
||||
let out = bin().args(args).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains("USAGE"), "{args:?}");
|
||||
assert!(!err.contains("panicked"));
|
||||
}
|
||||
let out = bin().args(["info", "/no/such/file.figcraft"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_export_info_convert() {
|
||||
let d = tmp("run");
|
||||
let script = d.join("s.jsonl");
|
||||
std::fs::write(&script, "{\"command\":\"frame.create\",\"params\":{\"x\":0,\"y\":0,\"width\":100,\"height\":50,\"name\":\"A\",\"props\":{\"fill\":\"#ff0000\"}}}\n{\"command\":\"frame.create\",\"params\":{\"x\":200,\"y\":0,\"width\":30,\"height\":30,\"name\":\"B\"}}\n").unwrap();
|
||||
let file = d.join("doc.figcraft");
|
||||
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines.iter().all(|l| l["ok"] == true));
|
||||
|
||||
let info = bin().args(["info", "--json"]).arg(&file).output().unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap();
|
||||
assert_eq!(v["pages"][0]["frames"].as_array().unwrap().len(), 2);
|
||||
|
||||
let dir = d.join("png");
|
||||
assert!(bin().arg("export").arg(&file).arg("-o").arg(&dir).args(["--scale", "2"]).status().unwrap().success());
|
||||
let a = std::fs::read(dir.join("A.png")).unwrap();
|
||||
assert_eq!(&a[16..24], &[0, 0, 0, 200, 0, 0, 0, 100]);
|
||||
assert!(dir.join("B.png").exists());
|
||||
|
||||
let svg = d.join("b.svg");
|
||||
assert!(bin().arg("export").arg(&file).arg("-o").arg(&svg).args(["--node", "B"]).status().unwrap().success());
|
||||
assert!(std::fs::read_to_string(&svg).unwrap().contains("<svg"));
|
||||
|
||||
let json = d.join("doc.json");
|
||||
assert!(bin().arg("convert").arg(&file).arg(&json).status().unwrap().success());
|
||||
let png = d.join("doc.png");
|
||||
assert!(bin().arg("convert").arg(&json).arg(&png).status().unwrap().success());
|
||||
assert!(std::fs::read(&png).unwrap().starts_with(b"\x89PNG"));
|
||||
|
||||
// A failing step: nonzero exit, error line printed.
|
||||
let bad = d.join("bad.json");
|
||||
std::fs::write(&bad, r#"[{"command":"select.all"},{"command":"nope"}]"#).unwrap();
|
||||
let out = bin().arg("run").arg(&bad).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("\"ok\":false"));
|
||||
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();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","method":"notifications/initialized"}}"#).unwrap();
|
||||
writeln!(stdin, "garbage").unwrap();
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{{"name":"create_node","arguments":{{"type":"ellipse"}}}}}}"#)
|
||||
.unwrap();
|
||||
}
|
||||
drop(child.stdin.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines[0]["result"]["protocolVersion"], "2025-06-18");
|
||||
assert_eq!(lines[1]["error"]["code"], -32700);
|
||||
assert_eq!(lines[2]["result"]["isError"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_over_stdio_confined_to_automation_roots() {
|
||||
let d = tmp("roots");
|
||||
for sub in ["in", "out", "outside"] {
|
||||
std::fs::create_dir_all(d.join(sub)).unwrap();
|
||||
}
|
||||
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
|
||||
// A root that isn't a folder stops the server before it serves.
|
||||
let out = bin().args(["mcp", "--automation-read-root", &p("missing")]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("--automation-read-root"));
|
||||
let mut child = bin()
|
||||
.args(["mcp", "--automation-read-root", &p("in"), "--automation-write-root", &p("out")])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
let call = |id: u32, tool: &str, args: serde_json::Value| {
|
||||
serde_json::json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": tool, "arguments": args}}).to_string()
|
||||
};
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
|
||||
writeln!(stdin, "{}", call(2, "create_node", serde_json::json!({"type": "ellipse"}))).unwrap();
|
||||
writeln!(stdin, "{}", call(3, "save_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap();
|
||||
writeln!(stdin, "{}", call(4, "save_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap();
|
||||
writeln!(stdin, "{}", call(5, "open_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap();
|
||||
writeln!(stdin, "{}", call(6, "open_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap();
|
||||
}
|
||||
drop(child.stdin.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(lines.len(), 6);
|
||||
assert!(lines[0]["result"]["instructions"].as_str().unwrap().contains("only these folders may be read"));
|
||||
let error = |i: usize| lines[i]["result"]["isError"] == true;
|
||||
assert!(!error(1) && error(2) && !error(3) && error(4) && !error(5), "{lines:?}");
|
||||
assert!(lines[2]["result"]["content"][0]["text"].as_str().unwrap().contains("automation path rejected"));
|
||||
assert!(d.join("out/a.figcraft").is_file() && !d.join("outside/a.figcraft").exists());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_json_lists_everything() {
|
||||
let out = bin().args(["commands", "--json"]).output().unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert!(v.as_array().unwrap().len() > 200);
|
||||
}
|
||||
|
||||
/// The automation roots confine the file subcommands too, not just `mcp` (#833): a read outside a
|
||||
/// root, and a write outside a write root, are refused and leave no file behind. Without the flags
|
||||
/// the same commands behave as they always did.
|
||||
#[test]
|
||||
fn file_subcommands_are_confined_to_automation_roots() {
|
||||
let d = tmp("file-roots");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
for sub in ["in", "out"] {
|
||||
std::fs::create_dir_all(d.join(sub)).unwrap();
|
||||
}
|
||||
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
|
||||
let out_of = |o: &std::process::Output| String::from_utf8_lossy(&o.stderr).into_owned();
|
||||
|
||||
// A document to work on, made unconfined.
|
||||
std::fs::write(d.join("in/make.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap();
|
||||
let made = bin().args(["run", &p("in/make.json"), "--out", &p("in/doc.figcraft")]).output().unwrap();
|
||||
assert!(made.status.success(), "{}", out_of(&made));
|
||||
|
||||
// Inside the roots: renders as usual.
|
||||
let ok = bin()
|
||||
.args(["render", &p("in/doc.figcraft"), "-o", &p("out/ok.png"), "--automation-read-root", &p("in"), "--automation-write-root", &p("out")])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(ok.status.success(), "{}", out_of(&ok));
|
||||
assert!(d.join("out/ok.png").is_file());
|
||||
|
||||
// A write outside the write root is refused, and nothing is written.
|
||||
let escaped = d.join("escaped.png");
|
||||
let refused = bin()
|
||||
.args([
|
||||
"render",
|
||||
&p("in/doc.figcraft"),
|
||||
"-o",
|
||||
&escaped.to_string_lossy(),
|
||||
"--automation-read-root",
|
||||
&p("in"),
|
||||
"--automation-write-root",
|
||||
&p("out"),
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!refused.status.success());
|
||||
assert!(out_of(&refused).contains("may be written"), "{}", out_of(&refused));
|
||||
assert!(!escaped.exists(), "the refused render wrote a file anyway");
|
||||
|
||||
// No write root at all: writing is refused outright, reading still works.
|
||||
let no_write = bin().args(["render", &p("in/doc.figcraft"), "-o", &p("out/no.png"), "--automation-read-root", &p("in")]).output().unwrap();
|
||||
assert!(!no_write.status.success());
|
||||
assert!(out_of(&no_write).contains("no folder may be written"), "{}", out_of(&no_write));
|
||||
|
||||
// A read outside every root is refused: `info` on the document with only `out` readable.
|
||||
let read_refused = bin().args(["info", &p("in/doc.figcraft"), "--automation-read-root", &p("out")]).output().unwrap();
|
||||
assert!(!read_refused.status.success());
|
||||
assert!(out_of(&read_refused).contains("may be read"), "{}", out_of(&read_refused));
|
||||
|
||||
// `convert` and `run` take them as well, and a root that is not a folder is refused.
|
||||
let missing = bin().args(["convert", &p("in/doc.figcraft"), &p("out/c.svg"), "--automation-read-root", &p("missing")]).output().unwrap();
|
||||
assert!(!missing.status.success());
|
||||
assert!(out_of(&missing).contains("automation-read-root"), "{}", out_of(&missing));
|
||||
|
||||
// Unconfined, the same render still writes wherever it is told.
|
||||
let plain = bin().args(["render", &p("in/doc.figcraft"), "-o", &escaped.to_string_lossy()]).output().unwrap();
|
||||
assert!(plain.status.success(), "{}", out_of(&plain));
|
||||
assert!(escaped.is_file());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// An output path that is a symbolic link is written through, to its target, as `std::fs::write`
|
||||
/// always did. Routing the CLI's writes through `file_access` for #833 would otherwise have
|
||||
/// replaced the link with a regular file, because that write stages the bytes and renames them
|
||||
/// into place.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_unconfined_write_follows_a_symbolic_link() {
|
||||
let d = tmp("symlink-out");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(d.join("real")).unwrap();
|
||||
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
|
||||
std::fs::write(d.join("in.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap();
|
||||
let made = bin().args(["run", &p("in.json"), "--out", &p("doc.figcraft")]).output().unwrap();
|
||||
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
|
||||
|
||||
let target = d.join("real/target.png");
|
||||
std::fs::write(&target, b"placeholder").unwrap();
|
||||
let link = d.join("link.png");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
|
||||
let out = bin().args(["render", &p("doc.figcraft"), "-o", &link.to_string_lossy()]).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link is still a link");
|
||||
let written = std::fs::read(&target).unwrap();
|
||||
assert!(written.starts_with(b"\x89PNG"), "the target holds the render, not the placeholder");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Depth refusal through the actual CLI, including its exit status and destination writes.
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Headless runs lay text out with the installed fonts, like the desktop app, unless
|
||||
//! `--no-system-fonts` or `FIGCRAFT_NO_SYSTEM_FONTS=1` opts out.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
@@ -45,3 +44,25 @@ fn headless_runs_resolve_installed_families_unless_opted_out() {
|
||||
assert_eq!(run_one("face", face.clone(), &[], false)["family"], json!(installed), "text uses the installed family");
|
||||
assert_ne!(run_one("face", face, &["--no-system-fonts"], false)["family"], json!(installed), "opted out, it falls back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_lists_the_fonts_a_file_uses_that_are_missing() {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-cli-missing-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let (script, file) = (d.join("make.json"), d.join("doc.figcraft"));
|
||||
let text =
|
||||
|family: &str| json!({"command": "text.create", "params": {"text": "Hamburgefonstiv", "x": 0, "y": 0, "props": {"fontFamily": family}}});
|
||||
std::fs::write(&script, json!([text("FigCraft Missing CLI"), text("FigCraft Missing CLI")]).to_string()).unwrap();
|
||||
let cli = || Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
|
||||
let made = cli().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
|
||||
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
|
||||
|
||||
let out = cli().arg("info").arg(&file).arg("--json").output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let v: Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(v["missingFonts"], json!([{"family": "FigCraft Missing CLI", "layers": 2}]));
|
||||
|
||||
let out = cli().arg("info").arg(&file).output().unwrap();
|
||||
let shown = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}");
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! The crate's integration tests, built as one test binary.
|
||||
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod cli;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod export_depth;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod fonts;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod pdf;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod pdf_print;
|
||||
@@ -1,5 +1,4 @@
|
||||
//! `figcraft-cli export --format pdf` and `convert … .pdf` end to end.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! `figcraft-cli export` with PDF print options: bleed, marks, CMYK; bad lengths are usage errors.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -135,9 +135,23 @@ impl Config {
|
||||
/// abuse contact, or one whose clients would all count as its proxy).
|
||||
pub fn warnings(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let client_max = NetLimits::default().max_server_message >> 20;
|
||||
if usize::try_from(self.file_mb()).is_ok_and(|mb| mb >= client_max) {
|
||||
out.push(format!(
|
||||
"max_file_mb is {}: the app opens files of up to {} MiB, so a file that large can't be opened",
|
||||
self.file_mb(),
|
||||
client_max - 1
|
||||
));
|
||||
}
|
||||
if !self.open_publish {
|
||||
return out;
|
||||
}
|
||||
if self.file_mb() > 64 {
|
||||
out.push(format!(
|
||||
"open_publish is on with max_file_mb = {}: anyone may send messages that large, and the memory set aside for reading and queueing them grows to eight times it; keep it at 64 or less on a public server",
|
||||
self.file_mb()
|
||||
));
|
||||
}
|
||||
if self.operator_contact.trim().is_empty() {
|
||||
out.push("open_publish is on but operator_contact is empty: people can't reach you about abuse; set it (and terms_url)".into());
|
||||
}
|
||||
@@ -168,7 +182,7 @@ impl Config {
|
||||
return Err("`idle_days` must be from 0 to 3650".into());
|
||||
}
|
||||
let terms = self.terms_url.trim();
|
||||
if !terms.is_empty() && !(terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) {
|
||||
if !(terms.is_empty() || terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) {
|
||||
return Err("`terms_url` must be a web address (https://…)".into());
|
||||
}
|
||||
if self.operator_contact.chars().count() > 120 || self.operator_contact.chars().any(char::is_control) || terms.len() > 500 {
|
||||
@@ -253,8 +267,9 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest message a client may send when open to the public: a whole file (or plugin bundle)
|
||||
/// and a margin, not the core's 64 MiB.
|
||||
/// Largest message a client may send: a whole file (or plugin bundle) and a margin. Open to the
|
||||
/// public this is also the cap (strangers' files stay small); a private server never goes below
|
||||
/// the core's 64 MiB, and a `max_file_mb` above it raises the cap with it.
|
||||
fn open_message(&self) -> usize {
|
||||
usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX)
|
||||
}
|
||||
@@ -265,9 +280,9 @@ impl Config {
|
||||
fn core(&self) -> figcraft_collab::Limits {
|
||||
let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() };
|
||||
if !self.open_publish {
|
||||
return base;
|
||||
return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base };
|
||||
}
|
||||
let max = self.open_message().min(base.max_message_bytes);
|
||||
let max = self.open_message();
|
||||
let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX);
|
||||
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base }
|
||||
}
|
||||
@@ -279,21 +294,24 @@ impl Config {
|
||||
pub fn net(&self) -> NetLimits {
|
||||
let base = NetLimits { max_connections: self.max_connections, trusted_proxy: self.proxy().ok().flatten(), ..NetLimits::default() };
|
||||
if !self.open_publish {
|
||||
return base;
|
||||
let max = base.max_client_message.max(self.open_message());
|
||||
return NetLimits { max_client_message: max, max_queued_bytes: base.max_queued_bytes.max(max.saturating_add(1 << 20)), ..base };
|
||||
}
|
||||
let max = self.open_message().min(base.max_client_message);
|
||||
let max = self.open_message();
|
||||
// Shared budgets: one block of addresses (an IPv4 address, an IPv6 /48) gets at most an
|
||||
// eighth of each, and its biggest consumers are dropped first.
|
||||
let (queued, reading) = (256usize << 20, 192usize << 20);
|
||||
// eighth of each, and its biggest consumers are dropped first. Each still fits one whole
|
||||
// message, or a file as large as `max_file_mb` could never arrive.
|
||||
let one = max.saturating_add(1 << 20);
|
||||
let (queued, reading) = ((256usize << 20).max(one.saturating_mul(8)), (192usize << 20).max(one.saturating_mul(8)));
|
||||
NetLimits {
|
||||
max_per_address: self.max_connections_per_ip,
|
||||
max_client_message: max,
|
||||
max_queued_bytes: max.saturating_add(1 << 20),
|
||||
max_queued_bytes: one,
|
||||
max_queued_block: queued / 8,
|
||||
max_queued_total: queued,
|
||||
max_reading_block: max.saturating_add(1 << 20).min(reading / 8),
|
||||
max_reading_block: one.min(reading / 8),
|
||||
max_reading_total: reading,
|
||||
max_in_flight: 128 << 20,
|
||||
max_in_flight: (128usize << 20).max(one),
|
||||
reserve_for_sessions: self.max_connections / 8,
|
||||
request_idle: std::time::Duration::from_secs(20),
|
||||
..base
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
//! length (u32 LE), CRC-32 (u32 LE), one [`Journal`] entry in the wire encoding.
|
||||
//!
|
||||
//! Compaction writes the next generation's snapshot, then an empty log of that generation. A log
|
||||
//! whose generation isn't the snapshot's is from before a compaction that was cut short: what it
|
||||
//! holds is in the snapshot already. A log ends at its first damaged record (a write cut short).
|
||||
//! whose generation isn't the snapshot's is usually from before a compaction that was cut short
|
||||
//! (what it holds is in the snapshot already), but no checksum covers either generation, so the
|
||||
//! store replays such a log anyway and reports any change the snapshot lacked. A log ends at its
|
||||
//! first damaged record (a write cut short).
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Read, Write};
|
||||
|
||||
@@ -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")));
|
||||
@@ -413,7 +413,7 @@ fn open_mode_has_safe_defaults_and_warns_the_operator() {
|
||||
assert_eq!(open.net().request_idle, Duration::from_secs(20), "idle one-shot requests go quickly");
|
||||
assert_eq!(open.net().reserve_for_sessions, 512 / 8);
|
||||
assert_eq!(open.net().max_client_message, 17 << 20);
|
||||
assert_eq!(private.net().max_client_message, NetLimits::default().max_client_message, "unchanged when private");
|
||||
assert_eq!(private.net().max_client_message, 65 << 20, "private: a 64 MiB file and a margin");
|
||||
let w = open.warnings().join("\n");
|
||||
assert!(w.contains("operator_contact is empty") && w.contains("trusted_proxy"), "{w}");
|
||||
let set = Config { operator_contact: "[email protected]".into(), trusted_proxy: Some("127.0.0.1".into()), max_file_mb: Some(8), ..open };
|
||||
@@ -493,3 +493,34 @@ fn open_connections_never_reach_what_needs_the_key() {
|
||||
assert!(!format!("{info:?}").contains(&k));
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_above_64_mib_fit_every_limit() {
|
||||
// `max_file_mb` may go up to 4096: every cap a whole file's message meets on its way in must
|
||||
// follow it, or the upload is dropped by the transport before the server can say why.
|
||||
for open_publish in [false, true] {
|
||||
let cfg = Config { open_publish, max_file_mb: Some(256), public_url: Some("wss://figcraft.example.com/".into()), ..Config::default() };
|
||||
let (net, core, file) = (cfg.net(), cfg.store().core, 256usize << 20);
|
||||
assert!(net.max_client_message > file, "open={open_publish}: {}", net.max_client_message);
|
||||
assert!(net.max_queued_bytes > file && net.max_reading_block > file && net.max_in_flight > file, "open={open_publish}: {net:?}");
|
||||
assert!(net.max_reading_total >= net.max_reading_block && net.max_queued_total >= net.max_queued_block);
|
||||
assert!(core.max_message_bytes > file, "open={open_publish}: {}", core.max_message_bytes);
|
||||
}
|
||||
// A client takes such a file too, but caps what any server may make it buffer at 1 GiB.
|
||||
assert_eq!(NetLimits::default().max_server_message, 1 << 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_files_warn_the_operator() {
|
||||
let public_url = Some("wss://figcraft.example.com/".into());
|
||||
let warns = |open_publish, mb| {
|
||||
Config { open_publish, max_file_mb: Some(mb), public_url: public_url.clone(), ..Config::default() }.warnings().join(
|
||||
"
|
||||
",
|
||||
)
|
||||
};
|
||||
assert!(!warns(true, 64).contains("max_file_mb"), "{}", warns(true, 64));
|
||||
assert!(warns(true, 65).contains("open_publish is on with max_file_mb = 65"), "{}", warns(true, 65));
|
||||
assert!(!warns(false, 1023).contains("max_file_mb"), "{}", warns(false, 1023));
|
||||
assert!(warns(false, 1024).contains("can't be opened"), "{}", warns(false, 1024));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -261,7 +261,9 @@ fn fuzzed_frames_dont_hurt_the_server() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = ws.read();
|
||||
// read() returns Result<Message>, not a lock guard; the fuzz loop ends here on purpose and
|
||||
// the close frame from the server is not interesting.
|
||||
let _ = ws.read(); // NOSONAR
|
||||
}
|
||||
// Still serving, the file intact.
|
||||
let mut b = join(&p.edit, "Bo");
|
||||
|
||||
@@ -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,13 +434,13 @@ 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)
|
||||
&& log.generation != generation
|
||||
{
|
||||
log::info!("file at {}: its log predates the last compaction; it is ignored", path.display());
|
||||
log::info!("file at {}: its log isn't of its snapshot's generation; it is replayed over it when the file opens", path.display());
|
||||
}
|
||||
let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(path.join(n)).ok()).map(|m| m.len()).sum();
|
||||
Ok(Entry { meta, links, log: None, bytes, forced: 0 })
|
||||
@@ -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;
|
||||
@@ -973,8 +1126,32 @@ impl Store {
|
||||
fresh_log = true;
|
||||
}
|
||||
}
|
||||
// From before a compaction that was cut short: the snapshot has it all.
|
||||
Ok(_) => {}
|
||||
// From before a compaction that was cut short, the snapshot has it all; or a generation
|
||||
// field damaged, which no checksum covers. Replaying tells them apart without trusting
|
||||
// it: entries the snapshot holds are skipped, so only edits it lacks change anything.
|
||||
Ok(log) => {
|
||||
let before = server.seq();
|
||||
let mut stopped = None;
|
||||
for entry in log.entries {
|
||||
if let Err(e) = server.replay(entry) {
|
||||
stopped = Some(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before));
|
||||
if recovered > 0 {
|
||||
issues.push(format!(
|
||||
"file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \
|
||||
{recovered} changes the snapshot didn't; they were recovered"
|
||||
));
|
||||
}
|
||||
if let Some(e) = stopped {
|
||||
issues.push(format!(
|
||||
"file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \
|
||||
making sense after {recovered} newer changes ({e}); the rest was dropped"
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(why) => issues.push(format!("file {file}: {why}; starting a new log")),
|
||||
}
|
||||
for i in issues {
|
||||
@@ -990,23 +1167,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 +1250,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 +1270,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 +1286,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 +1313,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 +1333,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 +1392,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 +1451,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 +1472,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 +1514,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 +1563,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,138 @@
|
||||
//! A generation field damaged after an abrupt stop must not make the server drop a log that still
|
||||
//! holds confirmed edits (#408): the log is replayed over the snapshot, which only the changes the
|
||||
//! snapshot lacks survive, and the damage is reported. A genuine leftover of a compaction cut short
|
||||
//! still restores silently.
|
||||
|
||||
use figcraft_collab::message::{Access, ClientMsg};
|
||||
use figcraft_collab::{Client, Outgoing, Replica, Server};
|
||||
use figcraft_collab_net::hub::Backend;
|
||||
use figcraft_doc::Document;
|
||||
use figcraft_server::config::Config;
|
||||
use figcraft_server::disk::{self, LOG, SNAPSHOT};
|
||||
use figcraft_server::store::{FILES, Store};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("figcraft-header-recovery-{}", figcraft_collab_net::token::file_id().unwrap()));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn page_name(server: &Server) -> String {
|
||||
let doc = server.document();
|
||||
doc.node(doc.first_page().unwrap()).unwrap().name.clone()
|
||||
}
|
||||
|
||||
/// A published file whose first page was renamed "Kept", flushed to its log (not compacted), then
|
||||
/// the store dropped without stopping (an abrupt stop). Returns the file id and the final state.
|
||||
fn renamed_and_stopped(data: &Path) -> (String, Server) {
|
||||
let mut store = Store::open(data, Config::default().store()).unwrap();
|
||||
let snapshot = Replica::new(Document::new("Fixture")).snapshot().unwrap();
|
||||
let (info, _, _) = store.publish("Fixture", &snapshot).unwrap();
|
||||
let id = info.id;
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let ClientMsg::Hello(hello) = Client::hello("f", "", "Editor", "#FF8800") else { panic!("not a hello") };
|
||||
let (_, messages) = server.join_as(hello, Access::Edit).unwrap();
|
||||
let Some(Outgoing::To(_, welcome)) = messages.into_iter().next() else { panic!("no welcome") };
|
||||
let mut client = Client::join(welcome).unwrap();
|
||||
store.changed(&id, &mut server);
|
||||
let mut edited = client.document().clone();
|
||||
let page = edited.first_page().unwrap();
|
||||
edited.node_mut(page).unwrap().name = "Kept".into();
|
||||
let change = client.edit(&edited).unwrap().unwrap();
|
||||
assert!(!server.receive(client.id(), change).is_empty());
|
||||
store.changed(&id, &mut server);
|
||||
assert!(store.flush().is_empty(), "the edit must reach disk");
|
||||
assert_eq!(page_name(&server), "Kept");
|
||||
let dir = data.join(FILES).join(&id);
|
||||
assert!(!disk::read_log(&fs::read(dir.join(LOG)).unwrap()).unwrap().entries.is_empty());
|
||||
assert_eq!(disk::decode_snapshot(&fs::read(dir.join(SNAPSHOT)).unwrap()).unwrap().1.seq, 0, "the rename is only in the log");
|
||||
drop(store);
|
||||
(id, server)
|
||||
}
|
||||
|
||||
/// Reopen the data directory: the file's first page name and the problems reported.
|
||||
fn reopen(data: &Path, id: &str) -> (String, Vec<String>) {
|
||||
let mut store = Store::open(data, Config::default().store()).unwrap();
|
||||
let server = store.open(id).unwrap();
|
||||
(page_name(&server), store.problems().to_vec())
|
||||
}
|
||||
|
||||
fn flip(path: &Path, at: usize) {
|
||||
let mut bytes = fs::read(path).unwrap();
|
||||
bytes[at] ^= 1;
|
||||
fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
/// The generation field's first byte in both headers (after the magic and the format byte).
|
||||
const GENERATION_AT: usize = 5;
|
||||
|
||||
#[test]
|
||||
fn an_intact_log_restores_its_edits_without_a_report() {
|
||||
let fixture = Fixture::new();
|
||||
let (id, _) = renamed_and_stopped(&fixture.0);
|
||||
let (name, problems) = reopen(&fixture.0, &id);
|
||||
assert_eq!(name, "Kept");
|
||||
assert!(problems.is_empty(), "{problems:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_log_generation_keeps_the_edits_and_is_reported() {
|
||||
let fixture = Fixture::new();
|
||||
let (id, _) = renamed_and_stopped(&fixture.0);
|
||||
let log = fixture.0.join(FILES).join(&id).join(LOG);
|
||||
flip(&log, GENERATION_AT);
|
||||
// The records themselves still read: only the header's generation changed.
|
||||
let read = disk::read_log(&fs::read(&log).unwrap()).unwrap();
|
||||
assert!(read.damaged.is_none() && !read.entries.is_empty());
|
||||
let (name, problems) = reopen(&fixture.0, &id);
|
||||
assert_eq!(name, "Kept", "the flushed rename must survive");
|
||||
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}");
|
||||
// And it stays: the recovered edit is now in the file's snapshot.
|
||||
assert_eq!(reopen(&fixture.0, &id).0, "Kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_snapshot_generation_keeps_the_edits_and_is_reported() {
|
||||
let fixture = Fixture::new();
|
||||
let (id, _) = renamed_and_stopped(&fixture.0);
|
||||
let snapshot = fixture.0.join(FILES).join(&id).join(SNAPSHOT);
|
||||
flip(&snapshot, GENERATION_AT);
|
||||
assert!(disk::check_snapshot(&fs::read(&snapshot).unwrap()).is_ok(), "the generation is outside the checksum");
|
||||
let (name, problems) = reopen(&fixture.0, &id);
|
||||
assert_eq!(name, "Kept", "the flushed rename must survive");
|
||||
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}");
|
||||
assert_eq!(reopen(&fixture.0, &id).0, "Kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_log_format_is_still_reported() {
|
||||
let fixture = Fixture::new();
|
||||
let (id, _) = renamed_and_stopped(&fixture.0);
|
||||
flip(&fixture.0.join(FILES).join(&id).join(LOG), 4);
|
||||
let (_, problems) = reopen(&fixture.0, &id);
|
||||
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("format")), "{problems:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_log_left_by_a_compaction_cut_short_restores_without_a_report() {
|
||||
let fixture = Fixture::new();
|
||||
let (id, server) = renamed_and_stopped(&fixture.0);
|
||||
// The next snapshot was written (it holds the rename); the old log wasn't replaced yet.
|
||||
let bytes = disk::encode_snapshot(2, &server.state().unwrap()).unwrap();
|
||||
fs::write(fixture.0.join(FILES).join(&id).join(SNAPSHOT), bytes).unwrap();
|
||||
let (name, problems) = reopen(&fixture.0, &id);
|
||||
assert_eq!(name, "Kept");
|
||||
assert!(problems.is_empty(), "a leftover log is expected, not damage: {problems:?}");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! The crate's integration tests, built as one test binary.
|
||||
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod header_recovery;
|
||||
mod problem_retention;
|
||||
@@ -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", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
|
||||
@@ -14,7 +14,16 @@
|
||||
//! join from other machines.
|
||||
//!
|
||||
//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU.
|
||||
//!
|
||||
//! URL fragment: `#join=<invite link or join code>` joins that live session once the page has
|
||||
//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last.
|
||||
//! The link carries the session's secret; a fragment is never sent to the web host, but the address
|
||||
//! bar and the browser history keep it, so the URL is as private as the link itself.
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod query;
|
||||
#[cfg(test)]
|
||||
mod query_tests;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
//! The page's URL: the invite link its fragment names.
|
||||
|
||||
/// The invite link in `#join=<link>` (`fragment` is `location.hash`, with or without its `#`), still
|
||||
/// escaped. The fragment, unlike the query, never leaves the browser, so the link's secret stays out
|
||||
/// of the web host's logs and `Referer` headers. `join` takes the rest of the fragment, so a pasted
|
||||
/// `figcraft://join?host=…&file=…&token=…` keeps its own `&`s. Anything longer than an escaped invite
|
||||
/// link can be is ignored; what is left is checked by `collab.join` like a link the desktop app is
|
||||
/// opened with.
|
||||
pub fn join_link(fragment: &str) -> Option<&str> {
|
||||
// An invite link is at most 2 KiB; every byte of it escaped is three.
|
||||
const MAX: usize = 3 * 2048;
|
||||
let fragment = fragment.strip_prefix('#').unwrap_or(fragment);
|
||||
let start = if fragment.starts_with("join=") { 0 } else { fragment.find("&join=")? + 1 };
|
||||
fragment.get(start + "join=".len()..).filter(|link| !link.is_empty() && link.len() <= MAX)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! `#join=` in the page's URL.
|
||||
|
||||
use crate::query::join_link;
|
||||
|
||||
#[test]
|
||||
fn join_takes_the_rest_of_the_fragment() {
|
||||
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
|
||||
assert_eq!(join_link(&format!("#join={link}")), Some(link), "a pasted link keeps its own &s");
|
||||
assert_eq!(join_link(&format!("#x=1&join={link}")), Some(link), "after other entries");
|
||||
assert_eq!(join_link("#join=figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), Some("figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), "escaped, decoded later");
|
||||
assert_eq!(join_link("join=K7M-2QX-9TD"), Some("K7M-2QX-9TD"), "a join code, no leading #");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_join_without_the_entry() {
|
||||
assert_eq!(join_link(""), None);
|
||||
assert_eq!(join_link("#"), None);
|
||||
assert_eq!(join_link("#join="), None);
|
||||
assert_eq!(join_link("#rejoin=x"), None, "only the whole entry name");
|
||||
assert_eq!(join_link("#xjoin=x&a=1"), None);
|
||||
assert_eq!(join_link("?join=x"), None, "the query isn't read: it goes to the web host");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlong_link_is_ignored() {
|
||||
let long = format!("#join=figcraft://join?host=h&file=f&token={}", "a".repeat(7000));
|
||||
assert_eq!(join_link(&long), None);
|
||||
assert_eq!(join_link(&format!("#join={}", "é".repeat(4000))), None, "length counts bytes");
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The browser shell: web `Services` and the eframe web runner.
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_ui_egui::{FigApp, Services};
|
||||
use figcraft_ui_egui::{ContentsFuture, FigApp, Services};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
const CANVAS_ID: &str = "figcraft_canvas";
|
||||
@@ -40,7 +40,13 @@ 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 mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
|
||||
// `#join=<link>`: join that session, as opening an invite link does on the desktop.
|
||||
let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default();
|
||||
if let Some(link) = crate::query::join_link(&hash) {
|
||||
let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string());
|
||||
figcraft_ui_egui::live::open_invite(&mut fig, &link);
|
||||
}
|
||||
Ok(Box::new(WebShell { fig }))
|
||||
}),
|
||||
)
|
||||
@@ -69,14 +75,46 @@ 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. Files are opened and imported through the browser's file picker, which hands
|
||||
/// over their contents (pickers that need a path return nothing, 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 {
|
||||
/// An `<input type="file">` taking `exts`, clicked; its file is read once picked.
|
||||
fn pick_open_contents(&mut self, _title: &str, exts: &[&str]) -> Option<ContentsFuture> {
|
||||
let accept = exts.iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
|
||||
Some(Box::pin(async move {
|
||||
pick_file(&accept).await.unwrap_or_else(|e| {
|
||||
log::error!("couldn't open a file: {e:?}");
|
||||
None
|
||||
})
|
||||
}))
|
||||
}
|
||||
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 +146,116 @@ impl Services for WebServices {
|
||||
held
|
||||
}
|
||||
}
|
||||
|
||||
/// The file picked in the browser's file picker (`accept`: the extensions it offers), with its
|
||||
/// name; `None` when the picker is cancelled, `Some(Err)` with what to tell the user when the file
|
||||
/// can't be read.
|
||||
async fn pick_file(accept: &str) -> Result<Option<Result<(String, Vec<u8>), String>>, 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 input: web_sys::HtmlInputElement = document.create_element("input")?.dyn_into()?;
|
||||
input.set_type("file");
|
||||
input.set_accept(accept);
|
||||
input.set_hidden(true);
|
||||
// Some browsers (older Safari) open the picker only for an input in the page.
|
||||
body.append_child(&input)?;
|
||||
let (closed, picker) = PickerClosed::new(&window, input.clone());
|
||||
input.click();
|
||||
let result = wasm_bindgen_futures::JsFuture::from(closed).await;
|
||||
let file = input.files().and_then(|files| files.get(0));
|
||||
drop(picker);
|
||||
result?;
|
||||
let Some(file) = file else { return Ok(None) };
|
||||
let name = file.name();
|
||||
if let Err(why) = figcraft_ui_egui::browser_can_read(&name, file.size()) {
|
||||
return Ok(Some(Err(why)));
|
||||
}
|
||||
Ok(Some(match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
|
||||
Ok(buffer) => Ok((name, js_sys::Uint8Array::new(&buffer).to_vec())),
|
||||
Err(e) => {
|
||||
log::error!("couldn't read {name}: {e:?}");
|
||||
Err(figcraft_ui_egui::unreadable(&name))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// How long after the page gets focus back the file picker counts as closed (milliseconds): a
|
||||
/// picked file's `change` can come just after the focus, and must win. A `change` later than this
|
||||
/// (a slow cloud-backed file in a browser that never sends `cancel`) counts as a cancel.
|
||||
const PICKER_FOCUS_SETTLE_MS: i32 = 1_000;
|
||||
|
||||
/// Settles a file picker's promise once: on `change` (a file was picked), on `cancel` (closed
|
||||
/// without one) or shortly after the page gets focus back, for browsers that never send `cancel`
|
||||
/// (Safari before 16.4), where the pick would otherwise stay pending and block every later one.
|
||||
/// Dropping it (also when the pick is abandoned) removes the input, the focus listener and its timer.
|
||||
struct PickerClosed {
|
||||
window: web_sys::Window,
|
||||
input: web_sys::HtmlInputElement,
|
||||
_settle: wasm_bindgen::closure::Closure<dyn FnMut()>,
|
||||
on_focus: wasm_bindgen::closure::Closure<dyn FnMut()>,
|
||||
timer: std::rc::Rc<std::cell::Cell<Option<i32>>>,
|
||||
}
|
||||
|
||||
impl PickerClosed {
|
||||
fn new(window: &web_sys::Window, input: web_sys::HtmlInputElement) -> (js_sys::Promise, Self) {
|
||||
let resolve = std::rc::Rc::new(std::cell::RefCell::new(None::<js_sys::Function>));
|
||||
let slot = resolve.clone();
|
||||
let closed = js_sys::Promise::new(&mut |res, _rej| *slot.borrow_mut() = Some(res));
|
||||
let settle = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
|
||||
if let Some(res) = resolve.borrow_mut().take() {
|
||||
let _ = res.call0(&wasm_bindgen::JsValue::NULL);
|
||||
}
|
||||
});
|
||||
let _ = input.add_event_listener_with_callback("change", settle.as_ref().unchecked_ref());
|
||||
let _ = input.add_event_listener_with_callback("cancel", settle.as_ref().unchecked_ref());
|
||||
let timer = std::rc::Rc::new(std::cell::Cell::new(None));
|
||||
let (w, t, f) = (window.clone(), timer.clone(), settle.as_ref().unchecked_ref::<js_sys::Function>().clone());
|
||||
let on_focus = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
|
||||
if t.get().is_none() {
|
||||
t.set(w.set_timeout_with_callback_and_timeout_and_arguments_0(&f, PICKER_FOCUS_SETTLE_MS).ok());
|
||||
}
|
||||
});
|
||||
let _ = window.add_event_listener_with_callback("focus", on_focus.as_ref().unchecked_ref());
|
||||
(closed, Self { window: window.clone(), input, _settle: settle, on_focus, timer })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PickerClosed {
|
||||
fn drop(&mut self) {
|
||||
if let Some(t) = self.timer.take() {
|
||||
self.window.clear_timeout_with_handle(t);
|
||||
}
|
||||
let _ = self.window.remove_event_listener_with_callback("focus", self.on_focus.as_ref().unchecked_ref());
|
||||
self.input.remove();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -16,12 +16,16 @@ figcraft-ui-egui = { workspace = true }
|
||||
figcraft-collab-net = { workspace = true }
|
||||
figcraft-format = { workspace = true }
|
||||
figcraft-text = { workspace = true }
|
||||
# Shared decision of who draws the window frame on Linux (compositor vs. app), replacing the
|
||||
# bespoke Wayland xdg-decoration probe. See `[workspace.dependencies]`: pinned to the unmerged
|
||||
# craft-libs#217 branch and must be repointed to a merged `rev` before this PR is mergeable.
|
||||
craft-window = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true, features = ["image-data"] }
|
||||
arboard = { workspace = true, features = ["image-data", "wayland-data-control"] }
|
||||
webbrowser = { workspace = true }
|
||||
image = { workspace = true }
|
||||
env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] }
|
||||
@@ -31,8 +35,20 @@ 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 }
|
||||
|
||||
[build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Use vector window controls without a title-font renderer or embedded Cantarell. The compositor
|
||||
# probe that used to need raw-window-handle and wayland-client here now lives in craft-window.
|
||||
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
|
||||
@@ -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,16 +1,26 @@
|
||||
//! The command line: `figcraft [--control <port>] [files…] [figcraft://join?…]`. An invite link
|
||||
//! The command line: `figcraft [--control <port>] [--automation-read-root <dir>]…
|
||||
//! [--automation-write-root <dir>]… [files…] [figcraft://join?…]`. An invite link
|
||||
//! (what the system passes when a `figcraft://` link is opened) joins that session once the window
|
||||
//! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the
|
||||
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links).
|
||||
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links). Any other
|
||||
//! `--flag` is unknown and stops the app at startup (#836): a mistyped root flag must never leave
|
||||
//! the app running unconfined. After a bare `--`, every argument is a file.
|
||||
|
||||
/// What the app was started with.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct Args {
|
||||
/// `--control <port>`: the JSON-lines control server.
|
||||
/// `--control <port>` (or `--control=<port>`): the JSON-lines control server.
|
||||
pub control: Option<u16>,
|
||||
pub files: Vec<String>,
|
||||
/// The last invite link given.
|
||||
pub join: Option<String>,
|
||||
/// `--automation-read-root <dir>` / `--automation-write-root <dir>` (each repeatable; `=` works
|
||||
/// too): the folders the app's file access is confined to. A flag without a value gives `""`,
|
||||
/// which is refused at startup.
|
||||
pub read_roots: Vec<String>,
|
||||
pub write_roots: Vec<String>,
|
||||
/// `--flags` the app doesn't know, as given.
|
||||
pub unknown: Vec<String>,
|
||||
}
|
||||
|
||||
/// An invite link rather than a file (`figcraft:` links, in any case).
|
||||
@@ -49,13 +59,27 @@ pub fn parse(args: &[String]) -> Args {
|
||||
let mut out = Args::default();
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
if a == "--control" {
|
||||
out.control = it.next().and_then(|p| p.parse().ok());
|
||||
if a == "--" {
|
||||
out.files.extend(it.by_ref().cloned());
|
||||
break;
|
||||
}
|
||||
let (flag, inline) = a.split_once('=').map_or((a.as_str(), None), |(f, v)| (f, Some(v.to_string())));
|
||||
let roots = match flag {
|
||||
"--automation-read-root" => Some(&mut out.read_roots),
|
||||
"--automation-write-root" => Some(&mut out.write_roots),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(roots) = roots {
|
||||
roots.push(inline.or_else(|| it.next().cloned()).unwrap_or_default());
|
||||
} else if flag == "--control" {
|
||||
out.control = inline.or_else(|| it.next().cloned()).and_then(|p| p.parse().ok());
|
||||
} else if is_invite(a) {
|
||||
out.join = Some(a.clone());
|
||||
} else if let Some(path) = file_url_path(a) {
|
||||
out.files.push(path);
|
||||
} else if !a.starts_with("--") {
|
||||
} else if a.starts_with("--") {
|
||||
out.unknown.push(a.clone());
|
||||
} else {
|
||||
out.files.push(a.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,44 @@ fn args(a: &[&str]) -> Vec<String> {
|
||||
#[test]
|
||||
fn files_and_the_control_port() {
|
||||
assert_eq!(parse(&args(&[])), Args::default());
|
||||
let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"]));
|
||||
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), join: None });
|
||||
let a = parse(&args(&["--control", "4242", "a.figcraft", "b.svg"]));
|
||||
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), ..Args::default() });
|
||||
// A missing or bad port is ignored, not taken as a file.
|
||||
assert_eq!(parse(&args(&["--control"])), Args::default());
|
||||
assert_eq!(parse(&args(&["--control", "nope"])).control, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_port_accepts_the_equals_form() {
|
||||
let a = parse(&args(&["--control=7980", "a.figcraft"]));
|
||||
assert_eq!(a, Args { control: Some(7980), files: args(&["a.figcraft"]), ..Args::default() });
|
||||
// A bad port is ignored, not taken as a file.
|
||||
assert_eq!(parse(&args(&["--control=nope"])), Args::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_roots_repeat_and_are_never_files() {
|
||||
let a = parse(&args(&["--automation-read-root", "in", "--automation-write-root=out", "--automation-read-root=more", "a.figcraft"]));
|
||||
assert_eq!(a.read_roots, args(&["in", "more"]));
|
||||
assert_eq!(a.write_roots, args(&["out"]));
|
||||
assert_eq!(a.files, args(&["a.figcraft"]));
|
||||
// A flag without its folder is kept as an empty one, which startup refuses.
|
||||
assert_eq!(parse(&args(&["--automation-write-root"])).write_roots, args(&[""]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_flags_are_refused_never_ignored() {
|
||||
// #836: a mistyped root flag (or one an older release lacks) must stop the app, not start it
|
||||
// unconfined. Both forms, with and without `=`.
|
||||
let a = parse(&args(&["--automation-write-roots", "out", "--automation-write-dir=out", "--verbose", "a.figcraft"]));
|
||||
assert_eq!(a.unknown, args(&["--automation-write-roots", "--automation-write-dir=out", "--verbose"]));
|
||||
assert!(a.write_roots.is_empty());
|
||||
// Single-dash arguments are left as they were (files); after `--` everything is a file.
|
||||
let a = parse(&args(&["--control", "1", "--", "--odd.figcraft", "--automation-write-root=x"]));
|
||||
assert_eq!(a.files, args(&["--odd.figcraft", "--automation-write-root=x"]));
|
||||
assert!(a.unknown.is_empty() && a.write_roots.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invite_links_join_instead_of_opening() {
|
||||
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
|
||||
|
||||
@@ -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,282 @@
|
||||
#![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);
|
||||
// The server closes this connection after answering an invalid request, and shutting down
|
||||
// a socket whose peer has already closed is ENOTCONN on macOS and the BSDs where Linux
|
||||
// returns Ok. Closing the client is what the worker is waiting for, so the result is moot:
|
||||
// the other shutdowns in this file run while the peer is still open and stay checked.
|
||||
let _ = client.shutdown(Shutdown::Both);
|
||||
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);
|
||||
}
|
||||
+221
-24
@@ -6,21 +6,39 @@
|
||||
//! `--control <port>` (or `FIGCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":0,"y":0,"width":100,"height":100}}}`
|
||||
//! → `{"id":1,"ok":true,"result":{…}}`. Methods: `figcraft_ui_egui::control`.
|
||||
//!
|
||||
//! `--automation-read-root <dir>` / `--automation-write-root <dir>` (each repeatable) confine the
|
||||
//! files the window and the control channel read and write (`figcraft_engine::file_access`); the
|
||||
//! app's own folders (settings, recovery copies, installed plugins) and opening the files given on
|
||||
//! the command line are not. A root that isn't an existing folder stops the app at startup, and so
|
||||
//! does any flag the app doesn't know (a mistyped root flag must not start the app unconfined).
|
||||
#![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 +56,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 +70,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,16 +100,19 @@ 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) {
|
||||
with_app(|fig| services::save_state(fig));
|
||||
if let Some(r) = recovery::lock(&self.recovery).take() {
|
||||
r.finish();
|
||||
}
|
||||
// With the app out of reach (busy), its recovery copies stay for the next start.
|
||||
let copies = recovery::lock(&self.recovery).take();
|
||||
with_app(|fig| services::shutdown(fig, services::config_dir().as_deref(), copies));
|
||||
// Everything is kept: end before the windows' teardown, where AppKit's Touch Bar support
|
||||
// throws on the content view AccessKit restores (craft-libs#3, #781).
|
||||
#[cfg(target_os = "macos")]
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +128,23 @@ fn main() -> eframe::Result {
|
||||
return Ok(());
|
||||
}
|
||||
let args = args::parse(&args);
|
||||
if let Some(flag) = args.unknown.first() {
|
||||
eprintln!(
|
||||
"FigCraft: unknown option {flag}\nUsage: figcraft [--control PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... [FILE | figcraft://join?...]..."
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
let roots = match (
|
||||
args.read_roots.is_empty() && args.write_roots.is_empty(),
|
||||
figcraft_engine::file_access::Roots::new(&args.read_roots, &args.write_roots),
|
||||
) {
|
||||
(true, _) => None,
|
||||
(false, Ok(r)) => Some(Arc::new(r)),
|
||||
(false, Err(e)) => {
|
||||
eprintln!("FigCraft: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
let port = args.control.or_else(|| std::env::var("FIGCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()));
|
||||
// System fonts: index them in the background, then wake the UI (an idle window draws no frames)
|
||||
// to lay text out again with them.
|
||||
@@ -127,26 +169,41 @@ fn main() -> eframe::Result {
|
||||
log::error!("{f}: {e}");
|
||||
}
|
||||
}
|
||||
let opened: Vec<u64> = session.docs.iter().map(|d| d.uid).collect();
|
||||
// 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() {
|
||||
let first_run = svc.prefs.is_none() && args.join.is_none();
|
||||
if session.docs.is_empty() && opens_new_file(first_run, session.prefs.start_on_home, port.is_some()) {
|
||||
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.automation_roots = roots;
|
||||
fig.ui.home = false;
|
||||
fig.note_recovered(recovered);
|
||||
// Files named on the command line are the user's too: their missing fonts are listed.
|
||||
fig.ui.font_checks = opened;
|
||||
fig.note_recovered(recovered.copies);
|
||||
if let Some(link) = &args.join {
|
||||
figcraft_ui_egui::live::open_invite(&mut fig, link);
|
||||
}
|
||||
@@ -155,24 +212,164 @@ 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);
|
||||
// The tab strip is the title bar: macOS keeps its window buttons inset into it; elsewhere the
|
||||
// window is frameless and FigCraft draws the buttons and resize edges.
|
||||
if cfg!(target_os = "macos") {
|
||||
viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false);
|
||||
fig.ui.title_bar = figcraft_ui_egui::TitleBar::Inset;
|
||||
} else {
|
||||
viewport = viewport.with_decorations(false);
|
||||
fig.ui.title_bar = figcraft_ui_egui::TitleBar::Custom;
|
||||
}
|
||||
// Who draws the window frame is decided once, before the viewport is built, by the shared
|
||||
// craft-window crate (the rest of the Craft suite uses it too). On macOS the system frame is
|
||||
// kept with FigCraft's inset traffic lights; on Windows it is the system frame; on Linux the
|
||||
// decision follows the desktop: a Wayland compositor with server-side decorations (KDE, Sway,
|
||||
// Hyprland, COSMIC) or an X11 window manager keeps the native frame, while GNOME's Mutter —
|
||||
// which draws no server-side frame — leaves it to the app's own custom title bar. This replaces
|
||||
// the old bespoke xdg-decoration probe that always drew FigCraft's custom title bar on KDE.
|
||||
fig.ui.title_bar = desktop_title_bar();
|
||||
viewport = window_chrome(viewport, fig.ui.title_bar);
|
||||
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
|
||||
}
|
||||
|
||||
/// With nothing to open, whether the window starts in a new file rather than on the home screen.
|
||||
/// A first run (no settings kept yet) shows the home screen's tips and templates, and later runs
|
||||
/// start in a new file unless the preference asks for Home, which `--control` overrides: its
|
||||
/// callers expect a document to act on.
|
||||
fn opens_new_file(first_run: bool, start_on_home: bool, control: bool) -> bool {
|
||||
!first_run && (!start_on_home || control)
|
||||
}
|
||||
|
||||
/// The title bar FigCraft starts with, decided by the shared craft-window crate so every Craft app
|
||||
/// agrees on who draws the frame per desktop. The override variable is `FIGCRAFT_TITLEBAR`
|
||||
/// (`app` / `system`). macOS keeps its inset traffic lights unchanged (craft-window returns the
|
||||
/// desktop frame there); Windows keeps the system frame; on Linux the compositor/window manager
|
||||
/// decides (KDE/Sway/Hyprland/COSMIC and X11 keep the native frame, GNOME gets the custom one).
|
||||
fn desktop_title_bar() -> figcraft_ui_egui::TitleBar {
|
||||
let choice = craft_window::Choice::probe(craft_window::Prefix::new("FIGCRAFT"));
|
||||
title_bar_for(choice.into_frame())
|
||||
}
|
||||
|
||||
/// Map craft-window's [`craft_window::Frame`] onto FigCraft's own [`figcraft_ui_egui::TitleBar`].
|
||||
///
|
||||
/// `Frame::App` means the app draws its own frame — FigCraft's custom title bar. `Frame::Desktop`
|
||||
/// means the desktop draws it, but FigCraft keeps each platform's existing look there:
|
||||
/// - macOS: the inset title bar with its traffic lights (`TitleBar::Inset`), unchanged.
|
||||
/// - Windows: FigCraft's own custom title bar (`TitleBar::Custom`), unchanged — Windows never used
|
||||
/// the plain native frame, and craft-window returning `Desktop` on Windows must not change that.
|
||||
/// - Linux and the BSDs: the compositor's native frame (`TitleBar::Native`). This is the only
|
||||
/// behaviour change: KDE and the other server-side-decoration compositors now keep their frame.
|
||||
fn title_bar_for(frame: craft_window::Frame) -> figcraft_ui_egui::TitleBar {
|
||||
use figcraft_ui_egui::TitleBar;
|
||||
match frame {
|
||||
craft_window::Frame::Desktop if cfg!(target_os = "macos") => TitleBar::Inset,
|
||||
// Windows kept FigCraft's own title bar before this change; keep it.
|
||||
craft_window::Frame::Desktop if cfg!(target_os = "windows") => TitleBar::Custom,
|
||||
craft_window::Frame::Desktop => TitleBar::Native,
|
||||
// The app draws the frame (GNOME's Wayland session, or a `FIGCRAFT_TITLEBAR=app` override).
|
||||
craft_window::Frame::App(_) => TitleBar::Custom,
|
||||
}
|
||||
}
|
||||
|
||||
fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::TitleBar) -> egui::ViewportBuilder {
|
||||
use figcraft_ui_egui::TitleBar;
|
||||
match title_bar {
|
||||
TitleBar::Inset => viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false),
|
||||
TitleBar::Native => viewport.with_decorations(true),
|
||||
TitleBar::Custom => viewport.with_decorations(false),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod title_bar_tests {
|
||||
use figcraft_ui_egui::TitleBar;
|
||||
|
||||
/// craft-window's app frame (GNOME's Wayland session, or a `FIGCRAFT_TITLEBAR=app` override)
|
||||
/// becomes FigCraft's custom title bar on every platform, and `window_chrome` then drops the
|
||||
/// decorations so FigCraft draws its own.
|
||||
#[test]
|
||||
fn the_app_frame_maps_to_the_custom_title_bar() {
|
||||
let layout = craft_window::Layout::default();
|
||||
let bar = super::title_bar_for(craft_window::Frame::App(layout));
|
||||
assert_eq!(bar, TitleBar::Custom);
|
||||
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
|
||||
assert_eq!(viewport.decorations, Some(false));
|
||||
}
|
||||
|
||||
/// craft-window's desktop frame keeps each platform's existing look: macOS its inset traffic
|
||||
/// lights, Windows FigCraft's own custom title bar (unchanged), and Linux/BSD the compositor's
|
||||
/// native frame. The Linux case is the only behaviour change: KDE and the other
|
||||
/// server-side-decoration compositors now keep their native frame.
|
||||
#[test]
|
||||
fn the_desktop_frame_is_mapped_per_platform() {
|
||||
let bar = super::title_bar_for(craft_window::Frame::Desktop);
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(bar, TitleBar::Inset);
|
||||
} else if cfg!(target_os = "windows") {
|
||||
// Windows kept its own title bar before and must keep it now.
|
||||
assert_eq!(bar, TitleBar::Custom);
|
||||
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
|
||||
assert_eq!(viewport.decorations, Some(false));
|
||||
} else {
|
||||
assert_eq!(bar, TitleBar::Native);
|
||||
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
|
||||
assert_eq!(viewport.decorations, Some(true));
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS is untouched by this change: its inset title bar with the system traffic lights stays,
|
||||
/// and `window_chrome` still asks for the full-size content view rather than toggling the
|
||||
/// window decorations.
|
||||
#[test]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_keeps_its_inset_title_bar() {
|
||||
let bar = super::desktop_title_bar();
|
||||
assert_eq!(bar, TitleBar::Inset);
|
||||
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
|
||||
assert_eq!(viewport.fullsize_content_view, Some(true));
|
||||
assert_eq!(viewport.title_shown, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod start_tests {
|
||||
use super::opens_new_file;
|
||||
|
||||
#[test]
|
||||
fn the_home_preference_starts_on_home_except_for_control_callers() {
|
||||
assert!(opens_new_file(false, false, false), "a new file by default");
|
||||
assert!(!opens_new_file(false, true, false), "Home when the preference asks");
|
||||
assert!(opens_new_file(false, true, true), "--control still starts with a document");
|
||||
assert!(!opens_new_file(true, false, false), "a first run shows the home screen");
|
||||
}
|
||||
}
|
||||
+313
-55
@@ -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,229 @@ 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 = lock_folder(&dir).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)) });
|
||||
}
|
||||
|
||||
/// Quitting: a normal quit ([`finish`](Self::finish)) when the unsaved-changes question was
|
||||
/// `asked` or nothing is unsaved. Otherwise (macOS's Quit menu ends the app without asking) the
|
||||
/// unsaved documents are copied and the copies stay for the next start, as after a crash.
|
||||
pub fn quit(mut self, s: &Session, asked: bool) {
|
||||
if asked || !s.docs.iter().any(|d| needs_copy(s, d)) {
|
||||
return self.finish();
|
||||
}
|
||||
self.write(s, true);
|
||||
self.settle(s);
|
||||
}
|
||||
|
||||
/// 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,55 +275,147 @@ 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";
|
||||
|
||||
/// How long the startup waits, once, for folder locks that may free themselves (#334).
|
||||
#[cfg(unix)]
|
||||
const LOCK_WAIT: std::time::Duration = std::time::Duration::from_millis(100);
|
||||
|
||||
/// Lock the folder `dir` (`WouldBlock` while another process holds it).
|
||||
fn lock_folder(dir: &Path) -> Result<File, std::fs::TryLockError> {
|
||||
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).map_err(std::fs::TryLockError::Error)?;
|
||||
lock.try_lock()?;
|
||||
Ok(lock)
|
||||
}
|
||||
|
||||
/// 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() });
|
||||
}
|
||||
}
|
||||
};
|
||||
let (mut free, mut held) = (Vec::new(), Vec::new());
|
||||
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.
|
||||
let Ok(lock) = File::options().write(true).create(true).truncate(false).open(path.join(LOCK)) else { continue };
|
||||
if lock.try_lock().is_err() {
|
||||
continue;
|
||||
match lock_folder(&path) {
|
||||
Ok(lock) => free.push((path, lock)),
|
||||
Err(std::fs::TryLockError::WouldBlock) => held.push(path),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
// A child between its `fork` and its `exec` shares the run's open descriptors, and a `flock`
|
||||
// lasts while any of them is open, so an ended run's folder can look held for a moment. Wait
|
||||
// once, however many folders are held, and try those again (#334). Windows doesn't pass the
|
||||
// lock to child processes.
|
||||
#[cfg(unix)]
|
||||
if !held.is_empty() {
|
||||
std::thread::sleep(LOCK_WAIT);
|
||||
free.extend(held.into_iter().filter_map(|path| lock_folder(&path).ok().map(|lock| (path, lock))));
|
||||
}
|
||||
for (path, lock) in free {
|
||||
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,329 @@ 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 quitting_without_the_unsaved_question_keeps_copies_for_the_next_start() {
|
||||
let root = root("quitunasked");
|
||||
// macOS's Quit menu: nothing asked, so the unsaved document comes back next time.
|
||||
let s = edited();
|
||||
Recovery::start(&root).unwrap().quit(&s, false);
|
||||
let mut next = Session::empty();
|
||||
assert_eq!(recover(&root, &mut next), 1);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
// Asked (saved or discarded on purpose), or nothing unsaved: nothing is left.
|
||||
for (s, asked) in [(edited(), true), (Session::new(), false)] {
|
||||
Recovery::start(&root).unwrap().quit(&s, asked);
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "asked: {asked}");
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/// #334: a lock that frees itself is not a run that is still going. A child between its `fork` and
|
||||
/// its `exec` inherits the run's open descriptors and a `flock` lasts while any descriptor for the
|
||||
/// file is open, so a crashed run's copies must still come back once that child has exec'd.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_lock_freed_a_moment_later_is_not_a_run_that_is_still_going() {
|
||||
let root = root("fork-window");
|
||||
crashed(&edited(), &root);
|
||||
// Hold the crashed run's folder lock on a second descriptor, the way a child forked before its
|
||||
// exec holds it, and free it shortly after.
|
||||
let lock = std::fs::File::options().write(true).create(true).truncate(false).open(root.join("crashed").join("lock")).unwrap();
|
||||
lock.try_lock().unwrap();
|
||||
let freed = std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
drop(lock);
|
||||
});
|
||||
// The next start waits for that lock rather than reading the run as still going.
|
||||
let mut next = Session::empty();
|
||||
assert_eq!(recover(&root, &mut next), 1, "the copies come back once the lock frees itself");
|
||||
freed.join().unwrap();
|
||||
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);
|
||||
}
|
||||
+162
-17
@@ -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,36 +136,145 @@ 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";
|
||||
|
||||
pub fn save_state(app: &FigApp) {
|
||||
if let Some(dir) = config_dir() {
|
||||
write_prefs(&dir, &app.session.prefs);
|
||||
/// Everything quitting keeps, all done when it returns (macOS then ends the process): the
|
||||
/// preferences in `dir`, this run's recovery copies ([`Recovery::quit`](crate::recovery::Recovery::quit))
|
||||
/// and the live sessions' ends (one this app hosts tells everyone and stops; a joined one leaves).
|
||||
pub fn shutdown(app: &mut FigApp, dir: Option<&Path>, recovery: Option<crate::recovery::Recovery>) {
|
||||
if let Some(dir) = dir {
|
||||
write_prefs(dir, &app.session.prefs);
|
||||
}
|
||||
// Before the sessions end: a recovered online file's copy waits for its session's outcome.
|
||||
if let Some(r) = recovery {
|
||||
r.quit(&app.session, app.ui.quit);
|
||||
}
|
||||
drop(std::mem::take(&mut app.session.live));
|
||||
}
|
||||
|
||||
/// 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()) })
|
||||
}
|
||||
|
||||
/// How long a clipboard read may wait on the app that copied (Linux and the BSDs).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
const CLIPBOARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
/// Read the clipboard. On Linux and the BSDs the read runs on a helper thread with its own handle
|
||||
/// and gives up after [`CLIPBOARD_TIMEOUT`]: a Wayland read waits on the app that copied with no
|
||||
/// timeout of its own, so an app that hangs must not hang the window too.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn read_clipboard<T: Send + 'static>(_: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option<T>) -> Option<T> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(arboard::Clipboard::new().ok().and_then(|mut c| read(&mut c)));
|
||||
});
|
||||
rx.recv_timeout(CLIPBOARD_TIMEOUT).ok().flatten()
|
||||
}
|
||||
|
||||
/// Read the clipboard (the system's own reads don't wait on the app that copied).
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
fn read_clipboard<T>(c: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option<T>) -> Option<T> {
|
||||
read(c)
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
@@ -168,7 +282,10 @@ impl Services for Desktop {
|
||||
}
|
||||
}
|
||||
fn clipboard_get_text(&mut self) -> Option<String> {
|
||||
self.clipboard.as_mut()?.get_text().ok()
|
||||
read_clipboard(self.clipboard.as_mut()?, |c| c.get_text().ok())
|
||||
}
|
||||
fn clipboard_get_html(&mut self) -> Option<String> {
|
||||
read_clipboard(self.clipboard.as_mut()?, |c| c.get().html().ok())
|
||||
}
|
||||
fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) {
|
||||
if let Some(c) = self.clipboard.as_mut() {
|
||||
@@ -176,7 +293,7 @@ impl Services for Desktop {
|
||||
}
|
||||
}
|
||||
fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec<u8>)> {
|
||||
let img = self.clipboard.as_mut()?.get_image().ok()?;
|
||||
let img = read_clipboard(self.clipboard.as_mut()?, |c| c.get_image().ok())?;
|
||||
let (w, h) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?);
|
||||
// Refuse absurd sizes (and mismatched buffers) rather than allocate.
|
||||
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > 100_000_000 || img.bytes.len() != (w as usize) * (h as usize) * 4 {
|
||||
@@ -187,6 +304,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 +317,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 +343,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,54 @@ 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);
|
||||
}
|
||||
|
||||
/// A live link that only records the sessions it was told to leave.
|
||||
struct Leaves(Arc<Mutex<Vec<u64>>>);
|
||||
|
||||
impl figcraft_engine::live::LiveLink for Leaves {
|
||||
fn host(&mut self, _: u64, _: figcraft_engine::live::HostOptions) -> Result<figcraft_engine::live::SessionInfo, String> {
|
||||
Err("not in this test".into())
|
||||
}
|
||||
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
fn leave(&mut self, session: u64) {
|
||||
self.0.lock().unwrap().push(session);
|
||||
}
|
||||
fn presence(&mut self, _: u64, _: &figcraft_engine::live::Presence) {}
|
||||
fn send(&mut self, _: u64, _: figcraft_engine::figcraft_collab::message::ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<(u64, figcraft_engine::live::Event)> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_keeps_the_preferences_and_ends_the_live_sessions() {
|
||||
let dir = root("shutdown");
|
||||
let mut fig = start(&dir);
|
||||
let left = Arc::new(Mutex::new(Vec::new()));
|
||||
fig.session.live.install(Box::new(Leaves(left.clone())));
|
||||
let joined = fig.run("collab.join", json!({"link": "ws://127.0.0.1:9/?file=f1&token=EditSecretEditSecret00"})).unwrap();
|
||||
fig.session.prefs.user_name = "Ada".into();
|
||||
crate::services::shutdown(&mut fig, Some(&dir), None);
|
||||
assert_eq!(*left.lock().unwrap(), [joined["session"].as_u64().unwrap()], "the session was left");
|
||||
assert!(!fig.session.live.available(), "nothing is left to send on");
|
||||
assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept");
|
||||
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,58 @@
|
||||
#
|
||||
# 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.SiadeLucas]
|
||||
real_name = "Siade Lucas"
|
||||
sync_github_name = true
|
||||
|
||||
[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.alrifatsabbir]
|
||||
real_name = "Al Rifat Sabbir"
|
||||
display_name = "Al Rifat Sabbir"
|
||||
sync_github_name = true
|
||||
|
||||
[people.RevDonz]
|
||||
|
||||
[people.Nael-Nathanael]
|
||||
sync_github_name = true
|
||||
|
||||
[people.volkovskey]
|
||||
real_name = "Andrii Volkov"
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.krinry]
|
||||
display_name = "krinry"
|
||||
sync_github_name = true
|
||||
|
||||
[people.pechnikov2026-star]
|
||||
sync_github_name = true
|
||||
|
||||
[people.MortenHusted]
|
||||
sync_github_name = true
|
||||
|
||||
[people.AganzeFelicite]
|
||||
sync_github_name = true
|
||||
@@ -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,320 @@
|
||||
//! 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 };
|
||||
// read() returns Result<Message>, not a lock guard; this test server deliberately
|
||||
// ignores whatever the client sent first.
|
||||
let _ = ws.read(); // NOSONAR
|
||||
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, too_large, 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);
|
||||
}
|
||||
@@ -107,8 +114,21 @@ impl<S: Socket> Driver<S> {
|
||||
/// A binary message from the host.
|
||||
pub(crate) fn message(&mut self, bytes: &[u8], now: Instant) {
|
||||
let Some(c) = self.conn.as_mut() else { return };
|
||||
if bytes.len() > self.cfg.limits.max_server_message {
|
||||
return self.end(too_large(&self.cfg.limits), now);
|
||||
}
|
||||
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 +253,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();
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::time::{Duration, Instant};
|
||||
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
|
||||
use figcraft_collab::{Client, Limits, Outgoing, Server};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::live::{Event, Status};
|
||||
use figcraft_engine::live::{Event, Status, TOO_LARGE_TO_JOIN};
|
||||
use tungstenite::protocol::WebSocket;
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
@@ -262,6 +262,15 @@ fn refusals_and_the_end_of_the_session_are_final() {
|
||||
assert!(d.done());
|
||||
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if reason.starts_with("The host refused")));
|
||||
}
|
||||
// A message over the size limit: the file is too large for this app, not refused by the host.
|
||||
let inbox = Inbox::new(Arc::new(|| {}));
|
||||
let mut cfg = GuestConfig::new(invite(), "Ada".into());
|
||||
cfg.limits.max_server_message = 2 << 20;
|
||||
let mut d = Driver::start(Script::default(), cfg, inbox.clone(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&vec![0; (2 << 20) + 1], t0);
|
||||
assert!(d.done());
|
||||
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if *reason == TOO_LARGE_TO_JOIN.replace("{mb}", "2")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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, too_large, 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;
|
||||
@@ -337,7 +367,7 @@ fn read_loop(mut ws: WebSocket<Duplex>, ctx: &ReaderCtx) {
|
||||
Ok(Message::Close(f)) => break f.map_or_else(|| closed(0, ""), |f| closed(f.code.into(), &f.reason)),
|
||||
Ok(Message::Text(_)) => break Outcome::Refused("the host sent a text message".into()),
|
||||
Ok(_) => {}
|
||||
Err(tungstenite::Error::Capacity(_)) => break Outcome::Refused("the host sent a message over the size limit".into()),
|
||||
Err(tungstenite::Error::Capacity(_)) => break too_large(&ctx.cfg.limits),
|
||||
Err(e) => break Outcome::Retry(e.to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +203,7 @@ fn pairs(query: &str) -> Result<Vec<(String, String)>, String> {
|
||||
fn with_port(authority: &str, default: u16) -> String {
|
||||
let has_port = match authority.rsplit_once(':') {
|
||||
// `[v6]` alone has colons inside the brackets only.
|
||||
Some((h, p)) => !(h.starts_with('[') && !h.ends_with(']')) && !p.contains(']'),
|
||||
Some((h, p)) => (!h.starts_with('[') || h.ends_with(']')) && !p.contains(']'),
|
||||
None => false,
|
||||
};
|
||||
if has_port { authority.to_string() } else { format!("{authority}:{default}") }
|
||||
@@ -202,7 +220,8 @@ fn valid_host(s: &str) -> bool {
|
||||
return true;
|
||||
}
|
||||
let Some((name, port)) = s.rsplit_once(':') else { return false };
|
||||
port.parse::<u16>().is_ok_and(|p| p != 0)
|
||||
port.bytes().all(|b| b.is_ascii_digit())
|
||||
&& port.parse::<u16>().is_ok_and(|p| p != 0)
|
||||
&& (1..=253).contains(&name.len())
|
||||
&& name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
|
||||
&& !name.starts_with(['-', '.'])
|
||||
@@ -221,6 +240,9 @@ fn percent_decode(s: &str) -> Option<String> {
|
||||
while let Some(&b) = bytes.get(i) {
|
||||
if b == b'%' {
|
||||
let hex = bytes.get(i + 1..i + 3)?;
|
||||
if !hex.iter().all(u8::is_ascii_hexdigit) {
|
||||
return None;
|
||||
}
|
||||
out.push(u8::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok()?);
|
||||
i += 3;
|
||||
} else {
|
||||
|
||||
@@ -123,3 +123,19 @@ proptest! {
|
||||
prop_assert_eq!(Invite::parse(&i.url()), Ok(i));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plus_or_sign_inside_a_percent_escape_is_broken() {
|
||||
assert!(Endpoint::parse("ws://example.org/a/?key=%41").is_ok());
|
||||
for bad in ["%+1", "%-1", "%1", "%+a"] {
|
||||
assert!(Endpoint::parse(&format!("ws://example.org/a/?key={bad}")).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_must_be_all_digits() {
|
||||
assert!(Endpoint::parse("ws://localhost:80/").is_ok());
|
||||
for bad in ["ws://localhost:+80/", "ws://localhost:-80/", "ws://localhost:/"] {
|
||||
assert!(Endpoint::parse(bad).is_err(), "accepted {bad}");
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use std::time::Duration;
|
||||
|
||||
use figcraft_collab::Client;
|
||||
use figcraft_collab::message::{ClientMsg, Peer, Resume, ServerMsg};
|
||||
use figcraft_engine::live::{Event, Role, SessionInfo, Status};
|
||||
use figcraft_engine::live::{Event, Role, SessionInfo, Status, TOO_LARGE_TO_JOIN};
|
||||
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::Invite;
|
||||
@@ -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,42 @@ 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 host sent a message over this app's size limit (the file's welcome, usually): final, and
|
||||
/// not the host's doing.
|
||||
pub(crate) fn too_large(limits: &NetLimits) -> Outcome {
|
||||
Outcome::Failed(TOO_LARGE_TO_JOIN.replace("{mb}", &(limits.max_server_message >> 20).to_string()))
|
||||
}
|
||||
|
||||
/// 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 +137,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 +252,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;
|
||||
@@ -12,7 +12,9 @@ pub struct NetLimits {
|
||||
/// before: its hello.
|
||||
pub max_client_message: usize,
|
||||
pub max_hello: usize,
|
||||
/// Largest message a client accepts from the host (a welcome carries the whole document).
|
||||
/// Largest message a client accepts from the host (a welcome carries the whole document): 1 GiB,
|
||||
/// room for any file up to 1023 MiB and well past the servers' defaults, while a hostile server
|
||||
/// (any link can name one) can't make the app or a browser tab buffer more than that.
|
||||
pub max_server_message: usize,
|
||||
/// Messages a client may send per second on average, and in a burst; beyond that it is
|
||||
/// disconnected.
|
||||
@@ -61,7 +63,7 @@ impl Default for NetLimits {
|
||||
max_connections: 64,
|
||||
max_client_message: figcraft_collab::Limits::default().max_message_bytes,
|
||||
max_hello: 64 << 10,
|
||||
max_server_message: figcraft_collab::Limits::default().max_message_bytes,
|
||||
max_server_message: 1 << 30,
|
||||
rate: 100.0,
|
||||
burst: 400.0,
|
||||
hello_timeout: Duration::from_secs(10),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,12 @@ use std::time::{Duration, Instant};
|
||||
use figcraft_collab::message::{self, ClientMsg, Hello, Presence, ServerMsg, Signal};
|
||||
use figcraft_collab::{Client, Limits};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::live::{Event, PEOPLE, Status};
|
||||
use figcraft_engine::live::{Event, PEOPLE, Status, TOO_LARGE_TO_JOIN};
|
||||
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,62 @@ 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_welcome_over_the_size_limit_says_the_file_is_too_large() {
|
||||
let mut doc = Document::new("B".repeat(1 << 20));
|
||||
doc.repair();
|
||||
let probe = Probe::new();
|
||||
let cfg = HostConfig {
|
||||
addr: SocketAddr::from(([127, 0, 0, 1], 0)),
|
||||
any_port_if_taken: false,
|
||||
file: FILE.into(),
|
||||
token: TOKEN.into(),
|
||||
doc,
|
||||
name: "Hana".into(),
|
||||
limits: fast_limits(),
|
||||
core: Limits::default(),
|
||||
};
|
||||
let mut host = Host::start(cfg, probe.inbox.clone()).unwrap();
|
||||
let invite = Invite::new(host.addr().to_string(), FILE, TOKEN);
|
||||
|
||||
let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into()));
|
||||
guest_cfg.limits = NetLimits { max_server_message: 1 << 20, ..fast_limits() };
|
||||
let mut gp = Probe::new();
|
||||
let _g = Guest::start(guest_cfg, gp.inbox.clone()).unwrap();
|
||||
let too_large = TOO_LARGE_TO_JOIN.replace("{mb}", "1");
|
||||
gp.wait("the file too large to join", |e| e.iter().any(|ev| matches!(ev, Event::Status(Status::Failed { reason }) if *reason == too_large)));
|
||||
assert!(!welcomed(&gp.events));
|
||||
host.stop();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ figcraft-doc = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
imbl = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
postcard = { workspace = true }
|
||||
|
||||
@@ -87,8 +87,9 @@ macro_rules! node_props {
|
||||
($($variant:ident => $field:ident),* $(,)?) => {
|
||||
/// A node property: the tree position (`parent`, `position`) or one saved field of
|
||||
/// [`Node`]. List-valued fields (fills, text runs, vector networks…) are one property each,
|
||||
/// replaced whole (three-way merges line fills, strokes and effects up item by item, see
|
||||
/// [`merge`](crate::merge)). New properties go at the end (the binary form numbers them).
|
||||
/// replaced whole (three-way merges line the lists whose items stand on their own, such as
|
||||
/// fills, up item by item, see [`merge`](crate::merge)). New properties go at the end (the
|
||||
/// binary form numbers them).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Prop {
|
||||
|
||||
@@ -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 && h.bytes().all(|b| b.is_ascii_hexdigit()))?;
|
||||
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,150 @@
|
||||
//! 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?host=10.0.0.1:47900&file=local&token=local-+0000000001",
|
||||
"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;
|
||||
|
||||
Loaded 100 of 635 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user