Compare commits
587
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1b859b66e | ||
|
|
3efe85e981 | ||
|
|
120581aa99 | ||
|
|
d5a9a783b4 | ||
|
|
b3094e7c94 | ||
|
|
c904c43290 | ||
|
|
18a778ec4e | ||
|
|
c89180a1d7 | ||
|
|
be606bb3c9 | ||
|
|
7c65d7de33 | ||
|
|
bb10140964 | ||
|
|
7e83bd4481 | ||
|
|
59bc534ac8 | ||
|
|
daf36f9cdb | ||
|
|
29ba0a6658 | ||
|
|
5a37ab3222 | ||
|
|
163f8bf37a | ||
|
|
9cb66f1e7d | ||
|
|
b303e1f996 | ||
|
|
86647cb6be | ||
|
|
aaa166e5ed | ||
|
|
b507e90943 | ||
|
|
ec8ea5e58b | ||
|
|
76c470fbdf | ||
|
|
b7cb0bc26f | ||
|
|
7b79c160fe | ||
|
|
5cc27f8fa9 | ||
|
|
120a88fa8a | ||
|
|
f7375014b3 | ||
|
|
51acb6843d | ||
|
|
7dc1040867 | ||
|
|
95ef75b4fa | ||
|
|
270febbca8 | ||
|
|
7be6d89a49 | ||
|
|
307fb10bce | ||
|
|
59e8e4ce88 | ||
|
|
fb6d4c1ae5 | ||
|
|
437675b8ed | ||
|
|
32e74bf5aa | ||
|
|
d26473ae9b | ||
|
|
4d61d68e73 | ||
|
|
8abbb1e75a | ||
|
|
82e5ff5585 | ||
|
|
ca5761af97 | ||
|
|
1b8a737494 | ||
|
|
c5da32cd45 | ||
|
|
e13c6f0cd6 | ||
|
|
681c4fd27f | ||
|
|
f5cd28364f | ||
|
|
8e8d008ece | ||
|
|
a970e8a4d5 | ||
|
|
7de195d859 | ||
|
|
e6f2439848 | ||
|
|
e7e3ba5f7a | ||
|
|
129fd3cba8 | ||
|
|
7b87d1ec9b | ||
|
|
a0126d11d1 | ||
|
|
244ed6005b | ||
|
|
05a6052263 | ||
|
|
2699b088e1 | ||
|
|
50d87dceb0 | ||
|
|
45f7733718 | ||
|
|
b602196e5a | ||
|
|
eef8e248cf | ||
|
|
37e5739840 | ||
|
|
4357c5ad32 | ||
|
|
4d0ffad6c9 | ||
|
|
4c3325dad3 | ||
|
|
ce6f5ea7d3 | ||
|
|
d37f7646f6 | ||
|
|
5905a4c64d | ||
|
|
404f0108f1 | ||
|
|
db5823704c | ||
|
|
43677144d1 | ||
|
|
1fb99b17c2 | ||
|
|
bbd349f190 | ||
|
|
cf20119b12 | ||
|
|
261e28f5af | ||
|
|
9bf23bcf01 | ||
|
|
fca1cd395e | ||
|
|
8527de7a4f | ||
|
|
2dda952e09 | ||
|
|
c416ac04fb | ||
|
|
aa25d2cb8d | ||
|
|
4569dbbaf0 | ||
|
|
f2d4f94953 | ||
|
|
c689efe5d9 | ||
|
|
7fb5d05983 | ||
|
|
d247d96909 | ||
|
|
6c2d9acd47 | ||
|
|
b1ddf06b87 | ||
|
|
17b5249fb5 | ||
|
|
841dbbdf41 | ||
|
|
78a5a38997 | ||
|
|
820a4ae6b8 | ||
|
|
90c0c56ec6 | ||
|
|
0c599d2b62 | ||
|
|
c07998b07a | ||
|
|
a66658f8a3 | ||
|
|
ce79b78632 | ||
|
|
4eff05e803 | ||
|
|
3c30cc21e6 | ||
|
|
d9a80ece79 | ||
|
|
79a3f646a9 | ||
|
|
907a300ec8 | ||
|
|
c4e450b7bc | ||
|
|
a9d8eea8e7 | ||
|
|
1e770deca7 | ||
|
|
5b5503965f | ||
|
|
a4b6c4e3b5 | ||
|
|
7b47270fe6 | ||
|
|
55e875cfe3 | ||
|
|
2ac8401a37 | ||
|
|
7c3ce0d163 | ||
|
|
6d618a5c38 | ||
|
|
013cd639c6 | ||
|
|
18ee5fa007 | ||
|
|
7ef7704644 | ||
|
|
4f57afcdbc | ||
|
|
0bf034df3f | ||
|
|
4ee8744ca4 | ||
|
|
24bcc35632 | ||
|
|
cf1993f115 | ||
|
|
e01238959d | ||
|
|
4665c6ea8b | ||
|
|
caf19d82fd | ||
|
|
36a805038e | ||
|
|
7edde50e21 | ||
|
|
8ecb57fe71 | ||
|
|
dc952f67c0 | ||
|
|
063667ab59 | ||
|
|
875d6458c6 | ||
|
|
9c8554b6c5 | ||
|
|
4334c23354 | ||
|
|
5c19b00e77 | ||
|
|
29f270ccf6 | ||
|
|
50805b85bd | ||
|
|
7a1722d5fa | ||
|
|
b4cb94b046 | ||
|
|
80c2d2b1ff | ||
|
|
907a1ab51d | ||
|
|
f4993652c0 | ||
|
|
6a3d2a6ea0 | ||
|
|
4ef9103cca | ||
|
|
d6c481a17d | ||
|
|
a6aae2d4c5 | ||
|
|
0a32d002a2 | ||
|
|
6bb1d7b5f7 | ||
|
|
c1fe70066c | ||
|
|
ad214598a7 | ||
|
|
592f98bdaf | ||
|
|
49aca0ddc7 | ||
|
|
01ac0386c4 | ||
|
|
288315003c | ||
|
|
9a57ff8b95 | ||
|
|
1f0ca8b1bb | ||
|
|
7fbd9d2ff7 | ||
|
|
a25209bceb | ||
|
|
a9d56cc659 | ||
|
|
db7c3760bf | ||
|
|
56edcfc781 | ||
|
|
7516f64464 | ||
|
|
8ace9433da | ||
|
|
e271e48be2 | ||
|
|
a8e0e42050 | ||
|
|
4b7796a960 | ||
|
|
f78ebd7c6a | ||
|
|
6d0d313622 | ||
|
|
39031f7726 | ||
|
|
fa9e8c64b2 | ||
|
|
d583bad57d | ||
|
|
7a8cb0bec0 | ||
|
|
f283152c19 | ||
|
|
2d9ba4b090 | ||
|
|
fe198fb88d | ||
|
|
81ae632de6 | ||
|
|
b93ca71090 | ||
|
|
2e3784abc2 | ||
|
|
cfcd0d3910 | ||
|
|
d1daa291d2 | ||
|
|
40ff1c1667 | ||
|
|
0447b3cb45 | ||
|
|
6e9ca01fc4 | ||
|
|
c9e66e8dc9 | ||
|
|
b0867a9639 | ||
|
|
c7ade06ba4 | ||
|
|
bb4f7ab993 | ||
|
|
7ee8f64f9d | ||
|
|
d228709009 | ||
|
|
0c80c2896c | ||
|
|
ff147074bd | ||
|
|
82343459e1 | ||
|
|
ec0cd3308e | ||
|
|
ce0c9de5ef | ||
|
|
ca1e087edf | ||
|
|
4e2c2ca616 | ||
|
|
0ff3fdc2f2 | ||
|
|
3060a4054e | ||
|
|
929dee81ef | ||
|
|
6eac884b73 | ||
|
|
17709529d0 | ||
|
|
0825dfd5d5 | ||
|
|
efd51a88ce | ||
|
|
10eb95cbf6 | ||
|
|
581a46c5e3 | ||
|
|
fcf1352fc6 | ||
|
|
fae6258595 | ||
|
|
e2d314efd7 | ||
|
|
bb453942d7 | ||
|
|
9460ae4d46 | ||
|
|
03fbe4224f | ||
|
|
4755a52419 | ||
|
|
321e4ca176 | ||
|
|
b5a6cb640f | ||
|
|
53655f0d7a | ||
|
|
c41be59e8c | ||
|
|
b3c16f3478 | ||
|
|
0053b3c9d1 | ||
|
|
24f6e13fc1 | ||
|
|
6f15ba610e | ||
|
|
2f3d92281f | ||
|
|
a2b3c10f11 | ||
|
|
0eecd5afea | ||
|
|
1266c51c51 | ||
|
|
e987a2d431 | ||
|
|
2ffa60dee1 | ||
|
|
77a732d9eb | ||
|
|
1c9286de91 | ||
|
|
f0d4a1a8f4 | ||
|
|
e03035b5d1 | ||
|
|
25e0df6f46 | ||
|
|
4cf7cc39a6 | ||
|
|
2e85b3317a | ||
|
|
907914c01e | ||
|
|
7a0b5c5259 | ||
|
|
fa412a74c9 | ||
|
|
21418acdd0 | ||
|
|
4adb4ef8b9 | ||
|
|
6c2cdd812b | ||
|
|
ac59c42838 | ||
|
|
4555e5f296 | ||
|
|
b67ba12a38 | ||
|
|
314224f4ed | ||
|
|
c40b650276 | ||
|
|
86686c8d10 | ||
|
|
c3a0adb6d0 | ||
|
|
2266e76f01 | ||
|
|
56784648ed | ||
|
|
9c52955add | ||
|
|
e0bf80a1d6 | ||
|
|
dd178a8caf | ||
|
|
829bfc5ecb | ||
|
|
142497cfad | ||
|
|
e48438c4ef | ||
|
|
91b195c1d8 | ||
|
|
fe1a67c5f1 | ||
|
|
2ac807a954 | ||
|
|
ce8288c3ef | ||
|
|
a3328daafb | ||
|
|
ebf5b80405 | ||
|
|
d8dbdb32f5 | ||
|
|
2c26320ab8 | ||
|
|
527b885986 | ||
|
|
c685309bba | ||
|
|
f155b74809 | ||
|
|
d927a96d63 | ||
|
|
b99b5c2b28 | ||
|
|
25ead042c2 | ||
|
|
00784263f0 | ||
|
|
470552b843 | ||
|
|
b3b6c65c2f | ||
|
|
f5eae784e8 | ||
|
|
3dfb475d52 | ||
|
|
2b5dc40667 | ||
|
|
28f88c819c | ||
|
|
11cab6a727 | ||
|
|
1d309236c8 | ||
|
|
05e79d3252 | ||
|
|
2e1427c499 | ||
|
|
539a1b2ad5 | ||
|
|
bb881787bd | ||
|
|
dc64d16d3a | ||
|
|
ef49d76840 | ||
|
|
e495092446 | ||
|
|
aa5e7dc9f6 | ||
|
|
ef4c5de385 | ||
|
|
96e515bad7 | ||
|
|
9fc49dc652 | ||
|
|
62be68452b | ||
|
|
73ca2e2baa | ||
|
|
c90a038699 | ||
|
|
af88c26294 | ||
|
|
5e06c67e87 | ||
|
|
0c9a7eb7c1 | ||
|
|
a0680fd439 | ||
|
|
2b6ea53707 | ||
|
|
1943a80b7a | ||
|
|
99f7e1baf6 | ||
|
|
9bdeca5d91 | ||
|
|
752f0f9eef | ||
|
|
34ad688380 | ||
|
|
bfe921f9ec | ||
|
|
0cc8445541 | ||
|
|
72aa0f0f73 | ||
|
|
6d49edf927 | ||
|
|
2d615e2b5b | ||
|
|
66798bf699 | ||
|
|
7ac4a2520a | ||
|
|
4d3e14d90d | ||
|
|
6639a2bd75 | ||
|
|
c7b47c3e93 | ||
|
|
5dd6ea231f | ||
|
|
e7c4253c71 | ||
|
|
8b8b1b0b87 | ||
|
|
f52578acb9 | ||
|
|
622654ae0b | ||
|
|
b959519136 | ||
|
|
fcc476c9ca | ||
|
|
a1726129e4 | ||
|
|
e4bd9695d1 | ||
|
|
64f7726b4f | ||
|
|
3064ef4318 | ||
|
|
97203addca | ||
|
|
85fe23c44e | ||
|
|
516ac4b4cd | ||
|
|
2ca500aa2f | ||
|
|
370a3ff944 | ||
|
|
cb2506ab61 | ||
|
|
b554509c73 | ||
|
|
27da2328b3 | ||
|
|
a77df5a35e | ||
|
|
7081a23261 | ||
|
|
4636dd9353 | ||
|
|
07dd5a1cd9 | ||
|
|
a76deb63e0 | ||
|
|
729b26d568 | ||
|
|
5d25a5dd35 | ||
|
|
8ef38547a1 | ||
|
|
c7374d1715 | ||
|
|
2da63bafc8 | ||
|
|
fe19c02230 | ||
|
|
54854e33a4 | ||
|
|
3a8705467a | ||
|
|
d3eeacf0b3 | ||
|
|
d806cf59d4 | ||
|
|
b53005c56f | ||
|
|
8c9ee247a0 | ||
|
|
9d4bf9a990 | ||
|
|
0a68bbee35 | ||
|
|
91df18db5c | ||
|
|
cd643fbcfb | ||
|
|
c7dcd52bcf | ||
|
|
f6b382aaee | ||
|
|
007fe3527a | ||
|
|
fe88fd0ae1 | ||
|
|
71fb52ce63 | ||
|
|
c4c171bec9 | ||
|
|
fa7bff6dd0 | ||
|
|
9fcfb5f498 | ||
|
|
bf2e2514fd | ||
|
|
a3e99e705c | ||
|
|
2f48ef1063 | ||
|
|
c315b8cf3c | ||
|
|
8eff531da8 | ||
|
|
625712e757 | ||
|
|
c2dbb5aba3 | ||
|
|
64dc135ec3 | ||
|
|
a70bd20b78 | ||
|
|
9f88b6d71a | ||
|
|
1b2763d4ce | ||
|
|
6b54e60f76 | ||
|
|
44bfbf3b22 | ||
|
|
a224b9a5d5 | ||
|
|
6d8ba314a9 | ||
|
|
ec67c509f4 | ||
|
|
6061ff34bb | ||
|
|
db30cc93eb | ||
|
|
cd90d0b9f6 | ||
|
|
c3cc0d1522 | ||
|
|
a1e25fadb8 | ||
|
|
4b34e03a15 | ||
|
|
6770f596be | ||
|
|
1c906c1a2d | ||
|
|
c5060a8f83 | ||
|
|
5ba36cfdd2 | ||
|
|
f3fb230785 | ||
|
|
c13462c86a | ||
|
|
458e42eade | ||
|
|
9451f1a267 | ||
|
|
f170ffef77 | ||
|
|
c56ee7d57f | ||
|
|
ddd3d8d0bb | ||
|
|
585c88cc85 | ||
|
|
a6c6e24d44 | ||
|
|
df0d82121a | ||
|
|
4dd027d140 | ||
|
|
96cdc3eeaf | ||
|
|
1268cb171e | ||
|
|
fc461d78de | ||
|
|
f7213e29a9 | ||
|
|
286c428e50 | ||
|
|
d88198dc6d | ||
|
|
55eb6c598d | ||
|
|
b7ce8c6713 | ||
|
|
ce08267fdd | ||
|
|
a1f25212da | ||
|
|
75fda19562 | ||
|
|
216127c0a4 | ||
|
|
913ba122e6 | ||
|
|
9bb79bd9b2 | ||
|
|
d23a34c8e4 | ||
|
|
73f4e51fc4 | ||
|
|
a03120331b | ||
|
|
8f5cafffa0 | ||
|
|
0949a6e8e5 | ||
|
|
77a42b703f | ||
|
|
0dfb89a230 | ||
|
|
89b8f67345 | ||
|
|
8635db93bc | ||
|
|
23371ee653 | ||
|
|
173e599cda | ||
|
|
034a078b81 | ||
|
|
794d87b254 | ||
|
|
13d0e895bf | ||
|
|
edfab7888f | ||
|
|
b358def1f9 | ||
|
|
6fc3169668 | ||
|
|
1cd2bfb85e | ||
|
|
d8c3c260de | ||
|
|
a9c12d0adf | ||
|
|
585ead1911 | ||
|
|
edee341c7f | ||
|
|
b4981bbecd | ||
|
|
dafb5618fa | ||
|
|
13aeb3f205 | ||
|
|
59f11ea44a | ||
|
|
7888fe8d3e | ||
|
|
d15f3f09da | ||
|
|
e4061caa3a | ||
|
|
7ee3435171 | ||
|
|
8b61f2fc74 | ||
|
|
99ed72aa66 | ||
|
|
6ceb5280dd | ||
|
|
437219b10b | ||
|
|
00486def73 | ||
|
|
d4a2d94c85 | ||
|
|
80fb150c15 | ||
|
|
89103caf9d | ||
|
|
72dd86459d | ||
|
|
37827c0188 | ||
|
|
3b5876b1be | ||
|
|
9cdaa3fc02 | ||
|
|
124464ef6b | ||
|
|
63f69f1ef8 | ||
|
|
349f555d8c | ||
|
|
7a2993b3d1 | ||
|
|
3b03edf3bb | ||
|
|
2b11289c5c | ||
|
|
1948822f2e | ||
|
|
1a8551c3e6 | ||
|
|
5ced600dd7 | ||
|
|
db3a3b277e | ||
|
|
7239ca897d | ||
|
|
422b0926e8 | ||
|
|
91cfe21b7d | ||
|
|
93d099d68c | ||
|
|
8e1fa661c2 | ||
|
|
46487da6bc | ||
|
|
bc7d9298fa | ||
|
|
20790782a1 | ||
|
|
7bac968c92 | ||
|
|
21019fe3f6 | ||
|
|
bd7c041b6a | ||
|
|
06369ab108 | ||
|
|
2c695ba9e3 | ||
|
|
82a8a70235 | ||
|
|
d814ed235b | ||
|
|
6af604fa77 | ||
|
|
0629ad79ea | ||
|
|
94bb9236a4 | ||
|
|
4d4b9451a1 | ||
|
|
e85a5d16a6 | ||
|
|
5d78c79836 | ||
|
|
f479d102fc | ||
|
|
321967e226 | ||
|
|
0ac3ceffc7 | ||
|
|
ca172cc642 | ||
|
|
551422bfa5 | ||
|
|
f7467f353e | ||
|
|
10939b10e9 | ||
|
|
38ba44adbc | ||
|
|
604f216d7f | ||
|
|
679f207725 | ||
|
|
26945799be | ||
|
|
21d9d83818 | ||
|
|
84c87251bb | ||
|
|
aec139a92a | ||
|
|
911ecf6bab | ||
|
|
6a1abc0cc6 | ||
|
|
61dda0b6de | ||
|
|
c45e37de6c | ||
|
|
e92e1cc731 | ||
|
|
15ca94fd16 | ||
|
|
cb37e0dfa3 | ||
|
|
e443d3aa7b | ||
|
|
6e66669792 | ||
|
|
d5c5c3e8ad | ||
|
|
40578022e7 | ||
|
|
7fcc434fe6 | ||
|
|
96c6c1f86a | ||
|
|
4ba55e78b7 | ||
|
|
77ab34b3a4 | ||
|
|
0a42b05f6b | ||
|
|
92fa684218 | ||
|
|
a5dc5afa06 | ||
|
|
6ddcc4c0e2 | ||
|
|
933295559f | ||
|
|
3fb22a1274 | ||
|
|
ff0ef8a4ba | ||
|
|
3af355ee96 | ||
|
|
107075899c | ||
|
|
08308dab84 | ||
|
|
8358a7df40 | ||
|
|
8c30fb259a | ||
|
|
9e416c0b19 | ||
|
|
22b5e66ce6 | ||
|
|
a003053402 | ||
|
|
f821f78784 | ||
|
|
65891a9c23 | ||
|
|
6895a27db8 | ||
|
|
9d9c125412 | ||
|
|
30b7fed319 | ||
|
|
c751e52c44 | ||
|
|
5b4a1b73b7 | ||
|
|
2bef46c0e2 | ||
|
|
98ba062f22 | ||
|
|
6f9ee8af2b | ||
|
|
7a22655796 | ||
|
|
3d9f6d2990 | ||
|
|
2028896330 | ||
|
|
d5d83378e5 | ||
|
|
4f42154ca1 | ||
|
|
aeca992c24 | ||
|
|
8373d6b1ca | ||
|
|
e1b7bd6ec4 | ||
|
|
907b65afb6 | ||
|
|
3d15c64c2d | ||
|
|
96e89d3d15 | ||
|
|
0cd5d868b6 | ||
|
|
a7ce972416 | ||
|
|
f4c1591880 | ||
|
|
4cd2828d33 | ||
|
|
30b112b444 | ||
|
|
135d63c2b9 | ||
|
|
44666636f5 | ||
|
|
e5caca9205 | ||
|
|
536c6efb91 | ||
|
|
fd83e7e7ce | ||
|
|
2c91988a5c | ||
|
|
a4980043ea | ||
|
|
970a016482 | ||
|
|
6093a9524e | ||
|
|
d5b6999bd0 | ||
|
|
84c947c33e | ||
|
|
fe7b7e36ab | ||
|
|
802383b106 | ||
|
|
e4277b5366 | ||
|
|
1e2ac57f14 | ||
|
|
865c6092c7 | ||
|
|
9c11ceec21 | ||
|
|
de5e11b50f | ||
|
|
8b2ee65791 | ||
|
|
07da7335ec | ||
|
|
e4bb351cd4 | ||
|
|
d90c7c98da | ||
|
|
4a11af6f08 | ||
|
|
71eea9f69f | ||
|
|
3d77b9bd7b | ||
|
|
e3e9c7a486 | ||
|
|
80723789d0 | ||
|
|
83b3f31a02 | ||
|
|
53880f2426 | ||
|
|
00ab332503 | ||
|
|
7776008d6a | ||
|
|
acdd03d1c1 | ||
|
|
87e8565e1a | ||
|
|
cee2d91102 |
@@ -101,6 +101,15 @@ jobs:
|
||||
- name: Validate Python test harness syntax
|
||||
run: git ls-files 'tests/*.py' 'tests_v2/*.py' 'scripts/*.py' | xargs python3 -m py_compile
|
||||
|
||||
- name: Validate CLI event source guards
|
||||
run: python3 tests/test_cli_event_source_guards.py
|
||||
|
||||
- name: Validate Browser WebAuthn credential reply safety
|
||||
run: python3 tests/test_browser_webauthn_credential_reply_guard.py
|
||||
|
||||
- name: Validate debug mobile transport source ownership
|
||||
run: python3 tests/test_debug_mobile_transport_source_ownership.py
|
||||
|
||||
- name: Validate TUI npm package artifact transfer
|
||||
run: python3 tests/test_tui_npm_package_artifact.py
|
||||
|
||||
@@ -259,6 +268,7 @@ jobs:
|
||||
|
||||
- name: Validate SwiftPM lockfile policy
|
||||
run: |
|
||||
python3 tests/test_package_resolved_policy_remote_inputs.py
|
||||
python3 tests/test_check_package_resolved_policy.py
|
||||
python3 scripts/check-package-resolved-policy.py
|
||||
|
||||
@@ -268,6 +278,9 @@ jobs:
|
||||
- name: Validate sidebar lazy-layout guard
|
||||
run: python3 tests/test_ci_sidebar_lazy_layout_guard.py
|
||||
|
||||
- name: Validate focused Dock shortcut routing guard
|
||||
run: python3 tests/test_dock_shortcut_routing_guard.py
|
||||
|
||||
- name: Validate bash prompt bootstrap composes with user PROMPT_COMMAND (starship)
|
||||
run: python3 tests/test_issue_5164_starship_prompt_composition.py
|
||||
|
||||
@@ -1228,6 +1241,7 @@ jobs:
|
||||
name: cmux-ghostty-cli-helper
|
||||
path: ghostty-cli-helper/ghostty
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Retry universal Ghostty CLI helper upload
|
||||
if: steps.upload-ghostty-cli-helper.outcome == 'failure'
|
||||
@@ -1236,6 +1250,7 @@ jobs:
|
||||
name: cmux-ghostty-cli-helper
|
||||
path: ghostty-cli-helper/ghostty
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
overwrite: true
|
||||
|
||||
- name: Select Xcode
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y binutils clang libclang-dev pkg-config
|
||||
sudo apt-get install -y binutils clang libclang-dev musl-tools pkg-config
|
||||
|
||||
- name: Resolve Ghostty Zig version
|
||||
id: ghostty-zig-version
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
if: matrix.cross == true
|
||||
if: matrix.cross == true && runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: cargo install --locked [email protected]
|
||||
|
||||
@@ -145,8 +145,8 @@ jobs:
|
||||
cargo build -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.build_target }}
|
||||
cargo build -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }}
|
||||
|
||||
- name: Build cmux-tui (cross)
|
||||
if: matrix.cross == true
|
||||
- name: Build cmux-tui (Linux cross)
|
||||
if: matrix.cross == true && runner.os == 'Linux'
|
||||
env:
|
||||
CMUX_TUI_DISTRIBUTION_VERSION: ${{ inputs.version }}
|
||||
PACKAGE_NPM: ${{ inputs.package_npm }}
|
||||
@@ -166,6 +166,27 @@ jobs:
|
||||
cargo zigbuild -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.build_target }}
|
||||
cargo zigbuild -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }}
|
||||
|
||||
- name: Build cmux-tui (macOS cross)
|
||||
if: matrix.cross == true && runner.os == 'macOS'
|
||||
env:
|
||||
CMUX_TUI_DISTRIBUTION_VERSION: ${{ inputs.version }}
|
||||
PACKAGE_NPM: ${{ inputs.package_npm }}
|
||||
working-directory: cmux-tui
|
||||
shell: bash
|
||||
run: |
|
||||
# Xcode's macOS SDK natively supports cross-architecture builds.
|
||||
# cargo-zigbuild cannot resolve SDK frameworks when the host is arm64.
|
||||
unset CMUX_GHOSTTY_SRC
|
||||
CMUX_TUI_BUILD_COMMIT="$(git -C .. rev-parse HEAD)"
|
||||
CMUX_TUI_GHOSTTY_COMMIT="$(git -C ../ghostty rev-parse HEAD)"
|
||||
export CMUX_TUI_BUILD_COMMIT CMUX_TUI_GHOSTTY_COMMIT CMUX_TUI_DISTRIBUTION_VERSION
|
||||
if [[ "$PACKAGE_NPM" == "true" ]]; then
|
||||
CMUX_TUI_NPM_BOOTSTRAP_VERSION="$CMUX_TUI_DISTRIBUTION_VERSION"
|
||||
export CMUX_TUI_NPM_BOOTSTRAP_VERSION
|
||||
fi
|
||||
cargo build -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.build_target }}
|
||||
cargo build -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }}
|
||||
|
||||
- name: Stage binary
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
+172
-26
@@ -42,9 +42,17 @@ jobs:
|
||||
npm run build
|
||||
npm test
|
||||
|
||||
valgrind-leak-check:
|
||||
valgrind-leak-check-shard:
|
||||
name: valgrind-leak-check (${{ matrix.shard }})
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 40
|
||||
# The core, remote, and application suites are intentionally serialized
|
||||
# under instrumentation. Isolate them so none can consume another test
|
||||
# binary group's runtime budget.
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [core, remote, tui, remainder]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -76,32 +84,61 @@ jobs:
|
||||
# SIMD codegen stays within what valgrind's instruction emulation
|
||||
# supports (see crates/ghostty-vt-sys/build.rs).
|
||||
CMUX_GHOSTTY_VT_ZIG_CPU: baseline
|
||||
VALGRIND_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
mkdir -p target
|
||||
cargo test --workspace --locked --no-run --message-format=json > target/cargo-test-binaries.jsonl
|
||||
python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
shard = os.environ["VALGRIND_SHARD"]
|
||||
known_shards = {"core", "remote", "tui", "remainder"}
|
||||
if shard not in known_shards:
|
||||
raise SystemExit(f"unknown Valgrind shard: {shard}")
|
||||
|
||||
def shard_for(executable):
|
||||
name = os.path.basename(executable)
|
||||
if re.fullmatch(r"cmux_tui_core-[0-9a-f]+", name):
|
||||
return "core"
|
||||
if re.fullmatch(r"cmux_remote-[0-9a-f]+", name):
|
||||
return "remote"
|
||||
if re.fullmatch(r"cmux_tui-[0-9a-f]+", name):
|
||||
return "tui"
|
||||
return "remainder"
|
||||
|
||||
seen = set()
|
||||
selected = []
|
||||
with open("target/cargo-test-binaries.jsonl", "r", encoding="utf-8") as messages:
|
||||
with open("target/valgrind-test-binaries.txt", "w", encoding="utf-8") as output:
|
||||
for line in messages:
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not message.get("profile", {}).get("test"):
|
||||
continue
|
||||
executable = message.get("executable")
|
||||
if not executable or executable in seen:
|
||||
continue
|
||||
seen.add(executable)
|
||||
print(executable, file=output)
|
||||
for line in messages:
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not message.get("profile", {}).get("test"):
|
||||
continue
|
||||
executable = message.get("executable")
|
||||
if not executable or executable in seen:
|
||||
continue
|
||||
seen.add(executable)
|
||||
if shard_for(executable) == shard:
|
||||
selected.append(executable)
|
||||
|
||||
if not seen:
|
||||
raise SystemExit("cargo did not report any test binaries")
|
||||
print(f"Collected {len(seen)} test binaries", file=sys.stderr)
|
||||
if not selected:
|
||||
raise SystemExit(f"Valgrind shard {shard} selected no test binaries")
|
||||
|
||||
with open("target/valgrind-test-binaries.txt", "w", encoding="utf-8") as output:
|
||||
for executable in selected:
|
||||
print(executable, file=output)
|
||||
|
||||
print(
|
||||
f"Valgrind shard {shard} selected {len(selected)} of {len(seen)} test binaries",
|
||||
file=sys.stderr,
|
||||
)
|
||||
PY
|
||||
|
||||
- name: Run test binaries under valgrind
|
||||
@@ -111,25 +148,99 @@ jobs:
|
||||
# otherwise-correct build; the guarded regression (events serialized
|
||||
# behind a 100ms read poll) inflates far past this bound anyway.
|
||||
CMUX_TEST_WS_LATENCY_BUDGET_MS: "2000"
|
||||
# Process-exit and PTY-reader tests also use bounded polling. Keep
|
||||
# their normal deadlines strict while allowing for instrumentation.
|
||||
# Scale test-fixture deadlines and their observation windows together
|
||||
# under instrumentation. Production defaults and normal CI remain strict.
|
||||
CMUX_TEST_TIMEOUT_SCALE: "4"
|
||||
# Retain the full 128 MiB fairness workload under Valgrind with
|
||||
# explicit instrumentation-only bounds. Normal CI stays strict.
|
||||
CMUX_TEST_PERF_INSTRUMENTED: "valgrind"
|
||||
run: |
|
||||
while IFS= read -r bin; do
|
||||
[ -n "$bin" ] || continue
|
||||
echo "Running valgrind for $bin"
|
||||
if ! valgrind \
|
||||
run_valgrind() {
|
||||
local bin="$1"
|
||||
shift
|
||||
valgrind \
|
||||
"${valgrind_args[@]}" \
|
||||
--error-exitcode=1 \
|
||||
--leak-check=full \
|
||||
--show-leak-kinds=definite \
|
||||
--errors-for-leak-kinds=definite \
|
||||
--track-origins=yes \
|
||||
-- "$bin"; then
|
||||
-- "$bin" "$@"
|
||||
}
|
||||
|
||||
while IFS= read -r bin; do
|
||||
[ -n "$bin" ] || continue
|
||||
echo "Running valgrind for $bin"
|
||||
valgrind_args=(--track-origins=yes)
|
||||
test_args=()
|
||||
case "$(basename "$bin")" in
|
||||
pty-*|cmux_tui_core-*|cmux_tui-[[:xdigit:]]*)
|
||||
# These tests own bounded worker pools, PTYs, sockets, and
|
||||
# deadline-sensitive readers. Valgrind serializes their CPU
|
||||
# work internally, so test-harness parallelism only creates
|
||||
# scheduler starvation and wall-clock timeout races.
|
||||
test_args+=(--test-threads=1)
|
||||
;;
|
||||
cmux_remote-[[:xdigit:]]*)
|
||||
# Remote-runtime tests also own real schedulers, sockets, and
|
||||
# deadline checks. Serial execution prevents the instrumented
|
||||
# harness from starving its own observation deadlines.
|
||||
test_args+=(--test-threads=1)
|
||||
;;
|
||||
terminal_host_recovery-*)
|
||||
# Valgrind instruments this client harness but not the hidden
|
||||
# terminal-host child it launches. The normal-speed child can
|
||||
# fill the socket while the instrumented reader is descheduled,
|
||||
# correctly triggering the production stalled-client timeout
|
||||
# mid-frame. Normal Linux and macOS CI retain this ordering test;
|
||||
# every other recovery case remains under Valgrind.
|
||||
test_args+=(--skip exit_follows_all_final_pty_bytes_on_the_live_stream)
|
||||
;;
|
||||
direct_wss_e2e-*|relay_wss_diagnostic-*)
|
||||
# ring's AES-GCM backend exposes initialized output through a
|
||||
# partially initialized SIMD buffer. Valgrind reports its
|
||||
# padding at Rustls writev. Keep leak and address checks for
|
||||
# TLS integration binaries while scoping undefined-value
|
||||
# suppression to those binaries.
|
||||
valgrind_args=(--undef-value-errors=no)
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$(basename "$bin")" == cmux_remote-[[:xdigit:]]* ]]; then
|
||||
# Iroh's Rustls/ring and noq UDP paths expose initialized data
|
||||
# through buffers with uninitialized SIMD or sockaddr padding.
|
||||
# Run only those tests without undefined-value diagnostics while
|
||||
# retaining address and leak checks. Every other remote test
|
||||
# keeps the complete Valgrind diagnostic set.
|
||||
if ! run_valgrind "$bin" --skip 'provider::iroh::' "${test_args[@]}"; then
|
||||
echo "Valgrind failed for $bin outside the Iroh provider" >&2
|
||||
exit 1
|
||||
fi
|
||||
valgrind_args=(--undef-value-errors=no)
|
||||
if ! run_valgrind "$bin" 'provider::iroh::' "${test_args[@]}"; then
|
||||
echo "Valgrind failed for $bin in the Iroh provider" >&2
|
||||
exit 1
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! run_valgrind "$bin" "${test_args[@]}"; then
|
||||
echo "Valgrind failed for $bin" >&2
|
||||
exit 1
|
||||
fi
|
||||
done < target/valgrind-test-binaries.txt
|
||||
|
||||
valgrind-leak-check:
|
||||
name: valgrind-leak-check
|
||||
if: always()
|
||||
needs: valgrind-leak-check-shard
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- name: Require every Valgrind shard
|
||||
env:
|
||||
SHARD_RESULT: ${{ needs.valgrind-leak-check-shard.result }}
|
||||
run: test "$SHARD_RESULT" = success
|
||||
|
||||
test:
|
||||
name: test (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os == 'macos' && (vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') || 'ubuntu-latest' }}
|
||||
@@ -195,6 +306,17 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22.14.0"
|
||||
cache: npm
|
||||
cache-dependency-path: cmux-tui/bindings/typescript/package-lock.json
|
||||
|
||||
- name: Install TypeScript binding dependencies
|
||||
working-directory: cmux-tui/bindings/typescript
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Init ghostty submodule
|
||||
run: git submodule update --init --depth 1 ghostty
|
||||
|
||||
@@ -226,11 +348,35 @@ jobs:
|
||||
working-directory: cmux-tui
|
||||
run: cargo build -p cmux-tui
|
||||
|
||||
- name: Resolve Zig SDK version
|
||||
id: zig-sdk-version
|
||||
shell: bash
|
||||
run: |
|
||||
version="$(
|
||||
sed -nE 's/^[[:space:]]*\.minimum_zig_version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' \
|
||||
cmux-tui/bindings/zig/build.zig.zon | head -1
|
||||
)"
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Invalid Zig SDK version: $version" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install Zig for SDK conformance
|
||||
env:
|
||||
ZIG_REQUIRED: ${{ steps.zig-sdk-version.outputs.version }}
|
||||
ZIG_FORCE_LOCAL_INSTALL: "1"
|
||||
run: ./scripts/install-zig-ci.sh
|
||||
|
||||
- name: Python conformance fixtures
|
||||
run: python3 cmux-tui/bindings/conformance/runner.py
|
||||
run: |
|
||||
test "$("$CMUX_ZIG" version)" = "${{ steps.zig-sdk-version.outputs.version }}"
|
||||
python3 cmux-tui/bindings/conformance/runner.py
|
||||
|
||||
- name: Binding e2e
|
||||
run: bash cmux-tui/bindings/conformance/e2e.sh --require python,typescript,rust,go,java
|
||||
run: |
|
||||
test "$("$CMUX_ZIG" version)" = "${{ steps.zig-sdk-version.outputs.version }}"
|
||||
bash cmux-tui/bindings/conformance/e2e.sh --require python,typescript,rust,go,java
|
||||
|
||||
windows-experimental:
|
||||
name: windows experimental (x86_64-gnu)
|
||||
|
||||
@@ -452,7 +452,7 @@ jobs:
|
||||
# The demo profile is fetched from the ASC API by name instead of a
|
||||
# repository secret, so regenerating it in the developer portal
|
||||
# needs no secret rotation. Same credentials the upload uses.
|
||||
PROFILE_BASE64="$(python3 ./ios/scripts/asc_download_profile.py --name "cmux Demo Distribution")"
|
||||
PROFILE_BASE64="$(python3 ./ios/scripts/asc_download_profile.py --name "cmux Demo Distribution Push")"
|
||||
elif [ "$IOS_BETA_PROFILE_TYPE" = "internal" ]; then
|
||||
PROFILE_BASE64="${IOS_BETA_PROVISIONING_PROFILE_INTERNAL_BASE64}"
|
||||
else
|
||||
@@ -475,9 +475,16 @@ jobs:
|
||||
echo "$IOS_BETA_PROFILE_TYPE provisioning profile targets unexpected app ID: $APP_ID (expected $IOS_BETA_EXPECTED_APP_ID)" >&2
|
||||
exit 1
|
||||
fi
|
||||
# TestFlight uses production APNs. Both capabilities must be present
|
||||
# in the installed profile or export can silently strip them.
|
||||
APS_ENVIRONMENT="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:aps-environment" "$TMP_PLIST" 2>/dev/null || echo "")"
|
||||
if [ -z "$APS_ENVIRONMENT" ] || [ "$APS_ENVIRONMENT" != "production" ]; then
|
||||
echo "$IOS_BETA_PROFILE_TYPE provisioning profile aps-environment is '$APS_ENVIRONMENT', expected 'production'" >&2
|
||||
echo "$IOS_BETA_PROFILE_TYPE provisioning profile aps-environment is '${APS_ENVIRONMENT:-<absent>}', expected 'production'" >&2
|
||||
exit 1
|
||||
fi
|
||||
TIME_SENSITIVE="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.developer.usernotifications.time-sensitive" "$TMP_PLIST" 2>/dev/null || echo "")"
|
||||
if [ "$TIME_SENSITIVE" != "true" ]; then
|
||||
echo "$IOS_BETA_PROFILE_TYPE provisioning profile com.apple.developer.usernotifications.time-sensitive is '${TIME_SENSITIVE:-<absent>}', expected 'true'" >&2
|
||||
exit 1
|
||||
fi
|
||||
PROFILE_NAME="$(/usr/libexec/PlistBuddy -c "Print :Name" "$TMP_PLIST")"
|
||||
|
||||
@@ -59,6 +59,7 @@ jobs:
|
||||
name: cmux-ghostty-cli-helper
|
||||
path: ghostty-cli-helper/ghostty
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
build-sign-notarize:
|
||||
needs: build-ghostty-cli-helper
|
||||
|
||||
@@ -246,5 +246,5 @@ jobs:
|
||||
with:
|
||||
name: reload-${{ inputs.tag }}-${{ inputs.platform }}
|
||||
path: artifact/
|
||||
retention-days: 3
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -609,12 +609,16 @@ jobs:
|
||||
echo "expected one tested crate, found ${#artifacts[@]}" >&2
|
||||
exit 1
|
||||
}
|
||||
retry_missing_project=()
|
||||
if [[ "$PACKAGE" == "cmux-sidebar" ]]; then
|
||||
retry_missing_project=(--retry-missing-project)
|
||||
fi
|
||||
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
|
||||
--registry crates \
|
||||
--package "$PACKAGE" \
|
||||
--version "$BOOTSTRAP_VERSION" \
|
||||
--artifact "${artifacts[0]}" \
|
||||
--retry-missing-project \
|
||||
"${retry_missing_project[@]}" \
|
||||
--wait-seconds 300 \
|
||||
--require-match
|
||||
sleep 1
|
||||
|
||||
@@ -167,6 +167,7 @@ jobs:
|
||||
--workflow .github/workflows/sdk-bootstrap-npm.yml \
|
||||
--workflow-ref refs/heads/main \
|
||||
--dist-tag bootstrap \
|
||||
--require-dist-tag latest \
|
||||
--publisher owner \
|
||||
--artifact "${packages[0]}"
|
||||
|
||||
@@ -258,7 +259,7 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
echo "npm lifecycle scripts are disabled in the credentialed publisher"
|
||||
npm publish "${packages[0]}" \
|
||||
npm publish "$(realpath "${packages[0]}")" \
|
||||
--ignore-scripts \
|
||||
--tag bootstrap \
|
||||
--provenance \
|
||||
@@ -297,7 +298,7 @@ jobs:
|
||||
- name: Install pinned npm
|
||||
run: npm install --global --ignore-scripts [email protected]
|
||||
|
||||
- name: Verify the prerelease did not claim latest
|
||||
- name: Verify npm-required bootstrap tags
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tags="$RUNNER_TEMP/cmux-sdk-bootstrap-tags.json"
|
||||
@@ -313,8 +314,13 @@ jobs:
|
||||
const fs = require("node:fs");
|
||||
const [path, expected] = process.argv.slice(2);
|
||||
const tags = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
if (tags.bootstrap !== expected || Object.hasOwn(tags, "latest")) {
|
||||
throw new Error(`unexpected cmux-sdk dist-tags: ${JSON.stringify(tags)}`);
|
||||
if (
|
||||
tags.bootstrap !== expected ||
|
||||
typeof tags.latest !== "string" ||
|
||||
tags.latest.length === 0
|
||||
) {
|
||||
console.error("cmux-sdk dist-tag validation failed.");
|
||||
process.exit(1);
|
||||
}
|
||||
NODE
|
||||
|
||||
@@ -336,5 +342,6 @@ jobs:
|
||||
--workflow .github/workflows/sdk-bootstrap-npm.yml \
|
||||
--workflow-ref refs/heads/main \
|
||||
--dist-tag bootstrap \
|
||||
--require-dist-tag latest \
|
||||
--publisher owner \
|
||||
--artifact "${packages[0]}"
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
fi
|
||||
# The conventions lint (free-function ban, namespace-type rule, ...)
|
||||
# covers every package, so it runs for any Packages/ change too.
|
||||
if grep -Eq '^(ios/|Packages/|Sources/Mobile/|vendor/stack-auth-swift-sdk-prerelease/|scripts/lint-ios-package-conventions\.sh$|scripts/lint-namespace-types-baseline\.txt$)' /tmp/changed-files.txt; then
|
||||
if grep -Eq '^(ios/|Packages/|Sources/Mobile/|vendor/stack-auth-swift-sdk-prerelease/|scripts/lint-ios-package-conventions\.sh$|scripts/lint-ios-package-conventions-baseline\.txt$|scripts/lint-namespace-types-baseline\.txt$)' /tmp/changed-files.txt; then
|
||||
echo "should_lint=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No package-owned files changed; skipping conventions lint."
|
||||
@@ -99,8 +99,8 @@ jobs:
|
||||
# namespace-enums) plus the repo-wide namespace-type rule (no
|
||||
# all-static "namespace" types in any package). Exits non-zero on any
|
||||
# unjustified ERROR; sanctioned exceptions carry a lint:allow /
|
||||
# TRANSITIONAL / carve-out marker, and pre-existing namespace-type
|
||||
# debt is grandfathered in scripts/lint-namespace-types-baseline.txt.
|
||||
# TRANSITIONAL / carve-out marker, and pre-existing debt is
|
||||
# grandfathered in the lint baseline files under scripts/.
|
||||
./scripts/lint-ios-package-conventions.sh
|
||||
|
||||
mobile-core-package:
|
||||
|
||||
@@ -119,7 +119,11 @@ final class CLISocketSentryTelemetry {
|
||||
processEnv["CMUX_CLI_SENTRY_DISABLED"] == "1" ||
|
||||
processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1"
|
||||
self.noiseFilter = SentryNoiseFilter()
|
||||
self.sentryPolicy = CLISocketSentryPolicy(environment: processEnv)
|
||||
self.sentryPolicy = CLISocketSentryPolicy(
|
||||
environment: processEnv,
|
||||
command: self.command,
|
||||
subcommand: self.subcommand
|
||||
)
|
||||
}
|
||||
|
||||
func breadcrumb(_ message: String, data: [String: Any] = [:]) {
|
||||
@@ -132,11 +136,13 @@ final class CLISocketSentryTelemetry {
|
||||
func captureError(stage: String, error: Error, data: [String: Any] = [:]) {
|
||||
guard shouldEmit else { return }
|
||||
let errorDescription = String(describing: error)
|
||||
let allowPolicyDenial = policyDenialContext(stage: stage, error: error)
|
||||
.map(sentryPolicy.shouldSuppressPolicyDenial) ?? false
|
||||
guard !noiseFilter.isExpectedCLISocketTransportFailure(
|
||||
stage: stage,
|
||||
message: errorDescription,
|
||||
error: error,
|
||||
dataKeys: Set(data.keys),
|
||||
allowSandboxPolicyDenial: sentryPolicy.allowsSandboxPolicyDenial
|
||||
allowSandboxPolicyDenial: allowPolicyDenial
|
||||
) else {
|
||||
return
|
||||
}
|
||||
@@ -151,9 +157,11 @@ final class CLISocketSentryTelemetry {
|
||||
for (key, value) in socketDiagnostics() {
|
||||
context[key] = value
|
||||
}
|
||||
for (key, value) in data {
|
||||
context[key] = value
|
||||
}
|
||||
context = CLISocketErrorTelemetryContext().merging(
|
||||
base: context,
|
||||
operation: data,
|
||||
error: error
|
||||
)
|
||||
let subcommand = self.subcommand
|
||||
let command = self.command
|
||||
let event = Self.makeErrorEvent(
|
||||
@@ -227,11 +235,17 @@ final class CLISocketSentryTelemetry {
|
||||
#else
|
||||
event.environment = "production-cli"
|
||||
#endif
|
||||
event.tags = [
|
||||
var tags = [
|
||||
"component": "cmux-cli",
|
||||
"cli_command": command,
|
||||
"cli_subcommand": subcommand
|
||||
]
|
||||
if let connectError = error as? CLISocketConnectError {
|
||||
event.fingerprint = connectError.sentryFingerprint
|
||||
tags["cli_socket_error_kind"] = "connect"
|
||||
tags["cli_socket_errno"] = String(connectError.errnoCode)
|
||||
}
|
||||
event.tags = tags
|
||||
event.context = ["cli_socket": context]
|
||||
if !breadcrumbs.isEmpty {
|
||||
event.breadcrumbs = breadcrumbs
|
||||
@@ -311,6 +325,18 @@ final class CLISocketSentryTelemetry {
|
||||
return context
|
||||
}
|
||||
|
||||
private func policyDenialContext(
|
||||
stage: String,
|
||||
error: Error
|
||||
) -> CLISocketPolicyDenialContext? {
|
||||
guard let connectError = error as? CLISocketConnectError else { return nil }
|
||||
|
||||
return CLISocketPolicyDenialContext(
|
||||
inspectingStage: stage,
|
||||
error: connectError
|
||||
)
|
||||
}
|
||||
|
||||
private func socketDiagnostics() -> [String: Any] {
|
||||
var context: [String: Any] = [
|
||||
"cwd": FileManager.default.currentDirectoryPath,
|
||||
|
||||
@@ -18,6 +18,24 @@ extension CMUXCLI {
|
||||
: mappedPID ?? inferredPID
|
||||
}
|
||||
|
||||
/// Gives a newly launched agent surface one event-driven opportunity to
|
||||
/// enter the app's authoritative surface index. Codex invokes SessionStart
|
||||
/// from a detached hook process, which can connect before its launching
|
||||
/// surface is published. The app-side waiter wakes on topology changes, so
|
||||
/// the CLI keeps strict identity validation without timer polling.
|
||||
func awaitAgentHookSessionStartTarget(
|
||||
subcommand: String,
|
||||
workspaceId: String,
|
||||
surfaceId: String,
|
||||
awaitPublication: (
|
||||
_ workspaceId: String,
|
||||
_ surfaceId: String
|
||||
) -> (workspaceId: String, surfaceId: String)?
|
||||
) -> (workspaceId: String, surfaceId: String)? {
|
||||
guard subcommand == "session-start" else { return nil }
|
||||
return awaitPublication(workspaceId, surfaceId)
|
||||
}
|
||||
|
||||
/// Reports a persistently throttled hook failure without serializing raw transport details.
|
||||
func reportAgentHookFailure(
|
||||
stage: AgentHookFailureStage,
|
||||
|
||||
+54
-37
@@ -3,6 +3,13 @@ import Darwin
|
||||
import Foundation
|
||||
|
||||
extension CMUXCLI {
|
||||
var restoreCommandUsageLine: String {
|
||||
String(
|
||||
localized: "cli.help.restore",
|
||||
defaultValue: "restore [--surface <id|ref>] <kind> <checkpoint-id> | restore --surface [id|ref]"
|
||||
)
|
||||
}
|
||||
|
||||
func controlAgentLaunchCommandPayload(
|
||||
_ command: AgentLaunchCommand
|
||||
) -> [String: Any] {
|
||||
@@ -193,21 +200,12 @@ extension CMUXCLI {
|
||||
)
|
||||
}
|
||||
|
||||
let resolution = AgentProcessBindingResolution.controllingTTY.rawValue
|
||||
do {
|
||||
let payload = try client.sendV2(
|
||||
method: "agent.resolve_delivery_target",
|
||||
params: [
|
||||
"pid": Int(ProcessInfo.processInfo.processIdentifier),
|
||||
"pid_resolution": resolution,
|
||||
]
|
||||
let payload = try implicitCallerIdentifyResponse(
|
||||
client: client,
|
||||
processEnvironment: processEnvironment
|
||||
)
|
||||
guard payload["source"] as? String == "pid",
|
||||
payload["pid_resolution"] as? String == resolution,
|
||||
let workspaceID = normalizedHandleValue(payload["workspace_id"] as? String),
|
||||
isUUID(workspaceID),
|
||||
let surfaceID = normalizedHandleValue(payload["surface_id"] as? String),
|
||||
isUUID(surfaceID) else {
|
||||
guard let surfaceID = identifiedCallerSurfaceID(in: payload) else {
|
||||
throw currentRestoreSurfaceUnknownError()
|
||||
}
|
||||
return surfaceID
|
||||
@@ -331,41 +329,60 @@ extension CMUXCLI {
|
||||
}
|
||||
|
||||
private func restoreSelector(_ arguments: [String]) throws -> RestoreSelector {
|
||||
if arguments.first == "--surface" {
|
||||
if arguments.count == 1 {
|
||||
return RestoreSelector(
|
||||
surface: nil,
|
||||
usesCurrentSurface: true,
|
||||
kind: nil,
|
||||
checkpointID: nil
|
||||
)
|
||||
}
|
||||
guard arguments.count == 2, !arguments[1].isEmpty else {
|
||||
if arguments == ["--surface"] {
|
||||
return RestoreSelector(
|
||||
surface: nil,
|
||||
usesCurrentSurface: true,
|
||||
kind: nil,
|
||||
checkpointID: nil
|
||||
)
|
||||
}
|
||||
|
||||
let surfaceOptionCount = arguments.filter { argument in
|
||||
argument == "--surface" || argument.hasPrefix("--surface=")
|
||||
}.count
|
||||
guard surfaceOptionCount <= 1 else {
|
||||
throw CLIError(message: String(
|
||||
localized: "cli.restore.usage.surface",
|
||||
defaultValue: "Usage: cmux restore --surface [id|ref]"
|
||||
))
|
||||
}
|
||||
let (surface, positionalArguments) = parseOption(arguments, name: "--surface")
|
||||
if surfaceOptionCount == 1 {
|
||||
guard let surface,
|
||||
!surface.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw CLIError(message: String(
|
||||
localized: "cli.restore.usage.surface",
|
||||
defaultValue: "Usage: cmux restore --surface [id|ref]"
|
||||
))
|
||||
}
|
||||
return RestoreSelector(
|
||||
surface: arguments[1],
|
||||
usesCurrentSurface: false,
|
||||
kind: nil,
|
||||
checkpointID: nil
|
||||
)
|
||||
if positionalArguments.isEmpty {
|
||||
return RestoreSelector(
|
||||
surface: surface,
|
||||
usesCurrentSurface: false,
|
||||
kind: nil,
|
||||
checkpointID: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
guard arguments.count == 2,
|
||||
!arguments[0].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
!arguments[1].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
|
||||
guard positionalArguments.count == 2,
|
||||
!positionalArguments[0].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
!positionalArguments[1].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw CLIError(message: String(
|
||||
localized: "cli.restore.usage.positional",
|
||||
defaultValue: "Usage: cmux restore <kind> <checkpoint-id>"
|
||||
defaultValue: """
|
||||
Usage: cmux restore [--surface <id|ref>] <kind> <checkpoint-id>
|
||||
cmux restore <kind> <checkpoint-id> --surface <id|ref>
|
||||
cmux restore --surface=<id|ref> <kind> <checkpoint-id>
|
||||
"""
|
||||
))
|
||||
}
|
||||
return RestoreSelector(
|
||||
surface: nil,
|
||||
usesCurrentSurface: true,
|
||||
kind: arguments[0],
|
||||
checkpointID: arguments[1]
|
||||
surface: surface,
|
||||
usesCurrentSurface: surface == nil,
|
||||
kind: positionalArguments[0],
|
||||
checkpointID: positionalArguments[1]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -301,6 +301,9 @@ extension CMUXCLI {
|
||||
let trimmedOneTimeCommand = oneTimeCommand?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let hasOneTimeCommand = trimmedOneTimeCommand?.isEmpty == false
|
||||
let authRetryPolicy = SSHForegroundAuthenticationRetryPolicy()
|
||||
let authGroupCleanupBody = authRetryPolicy.authenticationGroupDirectoryCleanupShellBody(
|
||||
terminatesPublishedGroup: hasOneTimeCommand
|
||||
)
|
||||
let backoffBuilder = SSHRetryBackoffScriptBuilder(context: .startup)
|
||||
var scriptLines: [String] = []
|
||||
if !shellFeaturesBootstrap.isEmpty {
|
||||
@@ -365,24 +368,27 @@ extension CMUXCLI {
|
||||
"cmux_ssh_auth_retry_limit=\(authRetryPolicy.maximumConsecutiveTransientFailures); cmux_ssh_auth_retry=0",
|
||||
// Initial transient foreground-auth failures are a reconnect phase, so boot-time outages share this loop.
|
||||
"cmux_ssh_reauth_required=\(hasOneTimeCommand ? 1 : 0)",
|
||||
"CMUX_SSH_CHILD_PID=; CMUX_SSH_AUTH_PID=; CMUX_SSH_PENDING_SIGNAL=; CMUX_SSH_PENDING_SIGNAL_NAME=",
|
||||
"CMUX_SSH_CHILD_PID=; CMUX_SSH_AUTH_PID=; CMUX_SSH_AUTH_GROUP_DIR=; CMUX_SSH_PENDING_SIGNAL=; CMUX_SSH_PENDING_SIGNAL_NAME=",
|
||||
"export CMUX_SSH_AUTH_GROUP_DIR",
|
||||
] + backoffBuilder.stateInitializationLines + [
|
||||
"cmux_ssh_note() { if [ -t 2 ]; then printf \"$@\" >&2 || true; fi; }",
|
||||
"cmux_ssh_register_attempt() { \(lifecycleLaunching); }",
|
||||
"cmux_ssh_begin_attempt() { CMUX_SSH_ATTEMPT_ID=$(/usr/bin/uuidgen | /usr/bin/tr '[:upper:]' '[:lower:]') || return 1; export CMUX_SSH_ATTEMPT_ID; cmux_ssh_attempt_registration_retry=0; while ! cmux_ssh_register_attempt; do cmux_ssh_attempt_registration_retry=$((cmux_ssh_attempt_registration_retry + 1)); if [ \"$cmux_ssh_attempt_registration_retry\" -ge 3 ]; then return 1; fi; /bin/sleep 0.1; done; }",
|
||||
"cmux_ssh_session_end() { if [ \"${CMUX_SSH_SESSION_ENDED:-0}\" = 1 ]; then return; fi; CMUX_SSH_SESSION_ENDED=1; cmux_ssh_cleanup_password; \(lifecycleCleanup); }",
|
||||
"cmux_ssh_retire_for_signal() { cmux_ssh_signal_status=\"$1\"; CMUX_SSH_SESSION_ENDED=1; cmux_ssh_cleanup_password; \(lifecycleRetirement); trap - EXIT HUP INT TERM; exit \"$cmux_ssh_signal_status\"; }",
|
||||
"cmux_ssh_signal_exit() { cmux_ssh_signal_status=\"$1\"; cmux_ssh_signal_name=\"$2\"; if [ -n \"${CMUX_SSH_AUTH_PID:-}\" ]; then cmux_ssh_terminate_auth_process_tree \"$CMUX_SSH_AUTH_PID\" \"$CMUX_SSH_STARTUP_PID\"; wait \"$CMUX_SSH_AUTH_PID\" 2>/dev/null || true; CMUX_SSH_AUTH_PID=; \(backoffBuilder.signalHandlerBranches) elif [ -z \"${CMUX_SSH_CHILD_PID:-}\" ]; then CMUX_SSH_PENDING_SIGNAL=\"$cmux_ssh_signal_status\"; CMUX_SSH_PENDING_SIGNAL_NAME=\"$cmux_ssh_signal_name\"; return; fi; cmux_ssh_retire_for_signal \"$cmux_ssh_signal_status\"; }",
|
||||
"cmux_ssh_remove_auth_group_dir() { \(authGroupCleanupBody) }",
|
||||
"cmux_ssh_session_end() { if [ \"${CMUX_SSH_SESSION_ENDED:-0}\" = 1 ]; then return; fi; CMUX_SSH_SESSION_ENDED=1; cmux_ssh_remove_auth_group_dir; cmux_ssh_cleanup_password; \(lifecycleCleanup); }",
|
||||
"cmux_ssh_retire_for_signal() { cmux_ssh_signal_status=\"$1\"; CMUX_SSH_SESSION_ENDED=1; cmux_ssh_remove_auth_group_dir; cmux_ssh_cleanup_password; \(lifecycleRetirement); trap - EXIT HUP INT TERM; exit \"$cmux_ssh_signal_status\"; }",
|
||||
"cmux_ssh_signal_exit() { cmux_ssh_signal_status=\"$1\"; cmux_ssh_signal_name=\"$2\"; if [ -n \"${CMUX_SSH_AUTH_PID:-}\" ]; then cmux_ssh_terminate_auth_process_tree \"$CMUX_SSH_AUTH_PID\" \"$CMUX_SSH_STARTUP_PID\"; wait \"$CMUX_SSH_AUTH_PID\" 2>/dev/null || true; CMUX_SSH_AUTH_PID=; cmux_ssh_remove_auth_group_dir; \(backoffBuilder.signalHandlerBranches) elif [ -z \"${CMUX_SSH_CHILD_PID:-}\" ]; then CMUX_SSH_PENDING_SIGNAL=\"$cmux_ssh_signal_status\"; CMUX_SSH_PENDING_SIGNAL_NAME=\"$cmux_ssh_signal_name\"; return; fi; cmux_ssh_retire_for_signal \"$cmux_ssh_signal_status\"; }",
|
||||
"trap 'cmux_ssh_session_end' EXIT",
|
||||
"trap 'cmux_ssh_signal_exit 129 HUP' HUP",
|
||||
"trap 'cmux_ssh_signal_exit 130 INT' INT",
|
||||
"trap 'cmux_ssh_signal_exit 143 TERM' TERM",
|
||||
hasOneTimeCommand ? "cmux_ssh_resume_failed_auth_group_reapers" : ":",
|
||||
"while :; do",
|
||||
" if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_retire_for_signal \"$CMUX_SSH_PENDING_SIGNAL\"; fi",
|
||||
]
|
||||
if hasOneTimeCommand {
|
||||
scriptLines.append(" if [ \"$cmux_ssh_reauth_required\" -eq 1 ]; then")
|
||||
scriptLines += [" ( cmux_ssh_foreground_auth ) <&0 &", " CMUX_SSH_AUTH_PID=$!; if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_signal_exit \"$CMUX_SSH_PENDING_SIGNAL\" \"${CMUX_SSH_PENDING_SIGNAL_NAME:-TERM}\"; fi; wait \"$CMUX_SSH_AUTH_PID\"; cmux_ssh_status=$?; CMUX_SSH_AUTH_PID=; case \"$cmux_ssh_status\" in 129|130|143) cmux_ssh_retire_for_signal \"$cmux_ssh_status\" ;; esac; if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_session_end; trap - EXIT HUP INT TERM; exit \"$CMUX_SSH_PENDING_SIGNAL\"; fi", " if [ \"$cmux_ssh_status\" -eq 0 ]; then cmux_ssh_reauth_required=0; cmux_ssh_auth_retry=0; else case \"$cmux_ssh_status\" in 254) cmux_ssh_auth_retry=$((cmux_ssh_auth_retry + 1)); if [ \"$cmux_ssh_auth_retry\" -ge \"$cmux_ssh_auth_retry_limit\" ]; then cmux_ssh_status=255; break; fi ;; \(authRetryPolicy.unclassifiedFailureExitStatus)) cmux_ssh_status=255; break ;; *) break ;; esac; fi", " fi", " if [ \"$cmux_ssh_reauth_required\" -eq 0 ]; then"]
|
||||
scriptLines += [" CMUX_SSH_AUTH_GROUP_DIR=$(umask 077; /usr/bin/mktemp -d \"${TMPDIR:-/tmp}/cmux-ssh-auth-group.XXXXXX\") || { cmux_ssh_status=255; break; }; export CMUX_SSH_AUTH_GROUP_DIR", " ( cmux_ssh_foreground_auth ) <&0 &", " CMUX_SSH_AUTH_PID=$!; if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_signal_exit \"$CMUX_SSH_PENDING_SIGNAL\" \"${CMUX_SSH_PENDING_SIGNAL_NAME:-TERM}\"; fi; wait \"$CMUX_SSH_AUTH_PID\"; cmux_ssh_status=$?; CMUX_SSH_AUTH_PID=; case \"$cmux_ssh_status\" in 129|130|143) cmux_ssh_retire_for_signal \"$cmux_ssh_status\" ;; esac; cmux_ssh_remove_auth_group_dir; if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_session_end; trap - EXIT HUP INT TERM; exit \"$CMUX_SSH_PENDING_SIGNAL\"; fi", " if [ \"$cmux_ssh_status\" -eq 0 ]; then cmux_ssh_reauth_required=0; cmux_ssh_auth_retry=0; else case \"$cmux_ssh_status\" in 254) cmux_ssh_auth_retry=$((cmux_ssh_auth_retry + 1)); if [ \"$cmux_ssh_auth_retry\" -ge \"$cmux_ssh_auth_retry_limit\" ]; then cmux_ssh_status=255; break; fi ;; \(authRetryPolicy.unclassifiedFailureExitStatus)) cmux_ssh_status=255; break ;; *) break ;; esac; fi", " fi", " if [ \"$cmux_ssh_reauth_required\" -eq 0 ]; then"]
|
||||
}
|
||||
if let trimmedControlPathPreflight, !trimmedControlPathPreflight.isEmpty,
|
||||
!hasOneTimeCommand {
|
||||
|
||||
+190
-51
@@ -1779,15 +1779,6 @@ final class SocketClient {
|
||||
let port: UInt16
|
||||
}
|
||||
|
||||
private struct SocketConnectError: Error, CustomStringConvertible {
|
||||
let path: String
|
||||
let errnoValue: Int32
|
||||
|
||||
var description: String {
|
||||
"Failed to connect to socket at \(path) (\(String(cString: strerror(errnoValue))), errno \(errnoValue))"
|
||||
}
|
||||
}
|
||||
|
||||
private struct RelayCredentials {
|
||||
let relayID: String
|
||||
let relayToken: Data
|
||||
@@ -1796,6 +1787,7 @@ final class SocketClient {
|
||||
private let path: String
|
||||
private(set) var socketFD: Int32 = -1
|
||||
private var streamReadBuffer = Data()
|
||||
private var streamLineSearchOffset = 0
|
||||
private var lastConfiguredReceiveTimeout: TimeInterval?
|
||||
private var lastOperationTelemetry: CLISocketOperationTelemetry.State?
|
||||
private static let defaultResponseTimeoutSeconds: TimeInterval = 15.0
|
||||
@@ -1961,6 +1953,7 @@ final class SocketClient {
|
||||
socketFD = -1
|
||||
}
|
||||
streamReadBuffer.removeAll(keepingCapacity: true)
|
||||
streamLineSearchOffset = 0
|
||||
lastConfiguredReceiveTimeout = nil
|
||||
}
|
||||
|
||||
@@ -2185,14 +2178,14 @@ final class SocketClient {
|
||||
|
||||
Darwin.close(socketFD)
|
||||
socketFD = -1
|
||||
throw SocketConnectError(path: path, errnoValue: connectErrno)
|
||||
throw CLISocketConnectError(path: path, errnoCode: connectErrno)
|
||||
}
|
||||
|
||||
private static func shouldRetryConnect(_ error: Error) -> Bool {
|
||||
guard let error = error as? SocketConnectError else {
|
||||
guard let error = error as? CLISocketConnectError else {
|
||||
return false
|
||||
}
|
||||
switch error.errnoValue {
|
||||
switch error.errnoCode {
|
||||
case ECONNREFUSED, EAGAIN, EWOULDBLOCK:
|
||||
return true
|
||||
default:
|
||||
@@ -2981,7 +2974,11 @@ final class SocketClient {
|
||||
try configureReceiveTimeout(45)
|
||||
}
|
||||
while true {
|
||||
if let newlineIndex = streamReadBuffer.firstIndex(of: 0x0A) {
|
||||
let searchStart = streamReadBuffer.index(
|
||||
streamReadBuffer.startIndex,
|
||||
offsetBy: min(streamLineSearchOffset, streamReadBuffer.count)
|
||||
)
|
||||
if let newlineIndex = streamReadBuffer[searchStart...].firstIndex(of: 0x0A) {
|
||||
let lineByteCount = streamReadBuffer.distance(
|
||||
from: streamReadBuffer.startIndex,
|
||||
to: newlineIndex
|
||||
@@ -2994,8 +2991,10 @@ final class SocketClient {
|
||||
throw CLIError(message: "Invalid UTF-8 event stream frame")
|
||||
}
|
||||
streamReadBuffer.removeSubrange(...newlineIndex)
|
||||
streamLineSearchOffset = 0
|
||||
return line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
streamLineSearchOffset = streamReadBuffer.count
|
||||
guard streamReadBuffer.count < maxBytes else {
|
||||
throw CLIError(message: "Event stream frame exceeded \(maxBytes) bytes")
|
||||
}
|
||||
@@ -4552,45 +4551,51 @@ struct CMUXCLI {
|
||||
params["window_id"] = targetWindow
|
||||
}
|
||||
let includeCaller = !hasFlag(commandArgs, name: "--no-caller")
|
||||
if includeCaller {
|
||||
let idWsFlag = optionValue(commandArgs, name: "--workspace")
|
||||
let idSurfaceFlag = optionValue(commandArgs, name: "--surface")
|
||||
let workspaceArg = idWsFlag ?? (effectiveWindowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
|
||||
let surfaceArg = idSurfaceFlag ?? (idWsFlag == nil && effectiveWindowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
|
||||
if workspaceArg != nil || surfaceArg != nil {
|
||||
let workspaceId = try normalizeWorkspaceHandle(
|
||||
workspaceArg,
|
||||
client: client,
|
||||
windowHandle: targetWindow,
|
||||
allowCurrent: surfaceArg != nil
|
||||
)
|
||||
var caller: [String: Any] = [:]
|
||||
if let workspaceId {
|
||||
caller["workspace_id"] = workspaceId
|
||||
}
|
||||
if surfaceArg != nil {
|
||||
guard let surfaceId = try normalizeSurfaceHandle(
|
||||
surfaceArg,
|
||||
let idWsFlag = optionValue(commandArgs, name: "--workspace")
|
||||
let idSurfaceFlag = optionValue(commandArgs, name: "--surface")
|
||||
let usesImplicitCaller = includeCaller
|
||||
&& effectiveWindowRaw == nil
|
||||
&& idWsFlag == nil
|
||||
&& idSurfaceFlag == nil
|
||||
let response: [String: Any]
|
||||
if usesImplicitCaller {
|
||||
response = try implicitCallerIdentifyResponse(
|
||||
client: client,
|
||||
processEnvironment: processEnv
|
||||
)
|
||||
} else {
|
||||
if includeCaller {
|
||||
let workspaceArg = idWsFlag ?? (effectiveWindowRaw == nil ? processEnv["CMUX_WORKSPACE_ID"] : nil)
|
||||
let surfaceArg = idSurfaceFlag ?? (idWsFlag == nil && effectiveWindowRaw == nil ? processEnv["CMUX_SURFACE_ID"] : nil)
|
||||
if workspaceArg != nil || surfaceArg != nil {
|
||||
let workspaceId = try normalizeWorkspaceHandle(
|
||||
workspaceArg,
|
||||
client: client,
|
||||
workspaceHandle: workspaceId,
|
||||
windowHandle: targetWindow
|
||||
) else {
|
||||
throw CLIError(message: "Invalid surface handle")
|
||||
windowHandle: targetWindow,
|
||||
allowCurrent: surfaceArg != nil
|
||||
)
|
||||
var caller: [String: Any] = [:]
|
||||
if let workspaceId {
|
||||
caller["workspace_id"] = workspaceId
|
||||
}
|
||||
if surfaceArg != nil {
|
||||
guard let surfaceId = try normalizeSurfaceHandle(
|
||||
surfaceArg,
|
||||
client: client,
|
||||
workspaceHandle: workspaceId,
|
||||
windowHandle: targetWindow
|
||||
) else {
|
||||
throw CLIError(message: "Invalid surface handle")
|
||||
}
|
||||
caller["surface_id"] = surfaceId
|
||||
}
|
||||
if !caller.isEmpty {
|
||||
params["caller"] = caller
|
||||
}
|
||||
caller["surface_id"] = surfaceId
|
||||
}
|
||||
if !caller.isEmpty {
|
||||
params["caller"] = caller
|
||||
}
|
||||
}
|
||||
if effectiveWindowRaw == nil,
|
||||
idWsFlag == nil,
|
||||
idSurfaceFlag == nil,
|
||||
let callerTTY = resolveCallerDescriptorTTYName() {
|
||||
params["caller_tty"] = callerTTY
|
||||
}
|
||||
response = try client.sendV2(method: "system.identify", params: params)
|
||||
}
|
||||
let response = try client.sendV2(method: "system.identify", params: params)
|
||||
print(jsonString(formatIDs(response, mode: idFormat)))
|
||||
|
||||
case "list-windows":
|
||||
@@ -15825,11 +15830,13 @@ struct CMUXCLI {
|
||||
"""
|
||||
case "restore":
|
||||
return String(localized: "cli.restore.help", defaultValue: """
|
||||
Usage: cmux restore <kind> <checkpoint-id>
|
||||
Usage: cmux restore [--surface <id|ref>] <kind> <checkpoint-id>
|
||||
cmux restore <kind> <checkpoint-id> --surface <id|ref>
|
||||
cmux restore --surface=<id|ref> <kind> <checkpoint-id>
|
||||
cmux restore --surface [id|ref]
|
||||
|
||||
Replace this CLI process with the persisted surface process. New
|
||||
records preserve argv, environment, and cwd as structured values;
|
||||
records preserve launch arguments and cwd as structured values;
|
||||
command-only records from older builds use a compatibility shell.
|
||||
With no id or ref, --surface uses the calling cmux surface.
|
||||
""")
|
||||
@@ -26262,6 +26269,97 @@ struct CMUXCLI {
|
||||
return resolveCallerDescriptorTTYName()
|
||||
}
|
||||
|
||||
func implicitCallerIdentifyResponse(
|
||||
client: SocketClient,
|
||||
processEnvironment: [String: String]
|
||||
) throws -> [String: Any] {
|
||||
let callerTTY = resolveCallerDescriptorTTYName()
|
||||
?? resolveCallerTTYName(includeAmbientTTY: false)
|
||||
if let callerTTY {
|
||||
let ttyResponse = try client.sendV2(
|
||||
method: "system.identify",
|
||||
params: ["caller_tty": callerTTY]
|
||||
)
|
||||
if identifiedCallerSurfaceID(in: ttyResponse) != nil
|
||||
|| identifyResponseHasMalformedCallerSurface(ttyResponse) {
|
||||
return ttyResponse
|
||||
}
|
||||
if let environmentParams = try implicitCallerEnvironmentIdentifyParams(
|
||||
client: client,
|
||||
processEnvironment: processEnvironment
|
||||
) {
|
||||
return try client.sendV2(
|
||||
method: "system.identify",
|
||||
params: environmentParams
|
||||
)
|
||||
}
|
||||
return ttyResponse
|
||||
}
|
||||
|
||||
if let environmentParams = try implicitCallerEnvironmentIdentifyParams(
|
||||
client: client,
|
||||
processEnvironment: processEnvironment
|
||||
) {
|
||||
return try client.sendV2(
|
||||
method: "system.identify",
|
||||
params: environmentParams
|
||||
)
|
||||
}
|
||||
return try client.sendV2(method: "system.identify")
|
||||
}
|
||||
|
||||
func identifiedCallerSurfaceID(in response: [String: Any]) -> String? {
|
||||
guard let caller = response["caller"] as? [String: Any],
|
||||
let surfaceID = normalizedHandleValue(caller["surface_id"] as? String),
|
||||
isUUID(surfaceID) else {
|
||||
return nil
|
||||
}
|
||||
return surfaceID
|
||||
}
|
||||
|
||||
private func implicitCallerEnvironmentIdentifyParams(
|
||||
client: SocketClient,
|
||||
processEnvironment: [String: String]
|
||||
) throws -> [String: Any]? {
|
||||
let workspaceArg = normalizedHandleValue(processEnvironment["CMUX_WORKSPACE_ID"])
|
||||
let surfaceArg = normalizedHandleValue(processEnvironment["CMUX_SURFACE_ID"])
|
||||
guard workspaceArg != nil || surfaceArg != nil else { return nil }
|
||||
|
||||
let workspaceID = try normalizeWorkspaceHandle(
|
||||
workspaceArg,
|
||||
client: client,
|
||||
allowCurrent: surfaceArg != nil
|
||||
)
|
||||
var caller: [String: Any] = [:]
|
||||
if let workspaceID {
|
||||
caller["workspace_id"] = workspaceID
|
||||
}
|
||||
if let surfaceArg {
|
||||
guard let surfaceID = try normalizeSurfaceHandle(
|
||||
surfaceArg,
|
||||
client: client,
|
||||
workspaceHandle: workspaceID,
|
||||
windowHandle: nil
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
caller["surface_id"] = surfaceID
|
||||
}
|
||||
guard !caller.isEmpty else { return nil }
|
||||
return ["caller": caller]
|
||||
}
|
||||
|
||||
private func identifyResponseHasMalformedCallerSurface(
|
||||
_ response: [String: Any]
|
||||
) -> Bool {
|
||||
guard let caller = response["caller"] as? [String: Any],
|
||||
let surface = caller["surface_id"],
|
||||
!(surface is NSNull) else {
|
||||
return false
|
||||
}
|
||||
return identifiedCallerSurfaceID(in: response) == nil
|
||||
}
|
||||
|
||||
func resolveCallerDescriptorTTYName() -> String? {
|
||||
for fileDescriptor in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] {
|
||||
if let rawTTYName = ttyname(fileDescriptor),
|
||||
@@ -31152,6 +31250,46 @@ export default CMUXSessionRestore;
|
||||
telemetry: telemetry
|
||||
)
|
||||
}
|
||||
func retryAmbientSessionStartTarget() -> (workspaceId: String, surfaceId: String)? {
|
||||
guard hookWsFlag == nil,
|
||||
explicitSurfaceFlag == nil,
|
||||
let directWorkspaceArg = nonEmptyClaudeHookIdentifier(directWorkspaceArg),
|
||||
let directSurfaceArg = nonEmptyClaudeHookIdentifier(directSurfaceArg) else {
|
||||
return nil
|
||||
}
|
||||
telemetry.breadcrumb("\(def.name)-hook.session-start.target-await")
|
||||
let target = awaitAgentHookSessionStartTarget(
|
||||
subcommand: subcommand,
|
||||
workspaceId: directWorkspaceArg,
|
||||
surfaceId: directSurfaceArg
|
||||
) { workspaceId, surfaceId in
|
||||
guard let payload = try? client.sendV2(
|
||||
method: "agent.wait_for_delivery_target",
|
||||
params: [
|
||||
"workspace_id": workspaceId,
|
||||
"surface_id": surfaceId,
|
||||
],
|
||||
responseTimeout: 1.25
|
||||
),
|
||||
payload["source"] as? String == "surface",
|
||||
let resolvedWorkspaceId = normalizedHandleValue(
|
||||
payload["workspace_id"] as? String
|
||||
),
|
||||
isUUID(resolvedWorkspaceId),
|
||||
let resolvedSurfaceId = normalizedHandleValue(
|
||||
payload["surface_id"] as? String
|
||||
),
|
||||
isUUID(resolvedSurfaceId),
|
||||
resolvedSurfaceId.caseInsensitiveCompare(surfaceId) == .orderedSame else {
|
||||
return nil
|
||||
}
|
||||
return (resolvedWorkspaceId, resolvedSurfaceId)
|
||||
}
|
||||
if target != nil {
|
||||
telemetry.breadcrumb("\(def.name)-hook.session-start.target-await-resolved")
|
||||
}
|
||||
return target
|
||||
}
|
||||
func resolveAgentHookTarget(mapped: ClaudeHookSessionRecord?) -> (workspaceId: String, surfaceId: String)? {
|
||||
guard !hasUnusableDirectBinding else {
|
||||
#if DEBUG
|
||||
@@ -31283,7 +31421,8 @@ export default CMUXSessionRestore;
|
||||
switch action {
|
||||
case .sessionStart:
|
||||
let mapped = sessionId.isEmpty ? nil : (try? store.lookup(sessionId: sessionId))
|
||||
guard let target = resolveAgentHookTarget(mapped: mapped) else {
|
||||
guard let target = resolveAgentHookTarget(mapped: mapped)
|
||||
?? retryAmbientSessionStartTarget() else {
|
||||
reportTargetResolutionFailure()
|
||||
didSendFeedTelemetry = true
|
||||
print("{}")
|
||||
@@ -36061,7 +36200,7 @@ export default CMUXSessionRestore;
|
||||
shortcuts
|
||||
disable-browser | enable-browser | browser-status
|
||||
agent-hibernation <on|off>
|
||||
restore <kind> <checkpoint-id> | restore --surface [id|ref]
|
||||
\(restoreCommandUsageLine)
|
||||
restore-session
|
||||
open <path-or-url>... [--workspace <id|ref|index>] [--surface <id|ref|index>] [--pane <id|ref|index>] [--window <id|ref|index>] [--focus <true|false>] [--no-focus]
|
||||
diff [patch-file|-] [--source <unstaged|staged|branch|last-turn>] [--unstaged|--staged|--branch|--last-turn] [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] [--cwd <path>] [--base <ref>] [--focus <true|false>] [--no-focus] [--title <text>] [--layout <split|unified>] [--font-size <points>]
|
||||
|
||||
+1
@@ -155,6 +155,7 @@ extension AuthCoordinator {
|
||||
sessionCache.setHasTokens(true)
|
||||
currentUser = fixtureUser
|
||||
isAuthenticated = true
|
||||
publishAuthenticatedSessionIdentity()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+84
@@ -429,3 +429,87 @@ public struct AuthenticatedSessionSnapshot: Sendable, Equatable,
|
||||
|
||||
public var debugDescription: String { description }
|
||||
}
|
||||
|
||||
/// Credential-free identity for synchronously binding queued work to the
|
||||
/// current authenticated session.
|
||||
public struct AuthenticatedSessionIdentity: Sendable, Equatable,
|
||||
CustomStringConvertible, CustomDebugStringConvertible {
|
||||
public let generation: UInt64
|
||||
public let accountID: String
|
||||
|
||||
public init(generation: UInt64, accountID: String) {
|
||||
self.generation = generation
|
||||
self.accountID = accountID
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
"AuthenticatedSessionIdentity(generation: \(generation), accountID: <redacted>)"
|
||||
}
|
||||
|
||||
public var debugDescription: String { description }
|
||||
}
|
||||
|
||||
public extension AuthCoordinator {
|
||||
/// The current account plus session generation without either credential.
|
||||
var authenticatedSessionIdentity: AuthenticatedSessionIdentity? {
|
||||
guard isAuthenticated,
|
||||
!sessionTokenTransitionIsActive,
|
||||
let accountID = currentUser?.id,
|
||||
!accountID.isEmpty else { return nil }
|
||||
return AuthenticatedSessionIdentity(
|
||||
generation: authSessionGeneration,
|
||||
accountID: accountID
|
||||
)
|
||||
}
|
||||
|
||||
/// A credential-free lifecycle stream for consumers that must cancel work
|
||||
/// at the exact auth transition instead of discovering stale authority on
|
||||
/// their next request. The first element is always the current state.
|
||||
func authenticatedSessionIdentities()
|
||||
-> AsyncStream<AuthenticatedSessionIdentity?> {
|
||||
let continuationID = UUID()
|
||||
return AsyncStream(bufferingPolicy: .bufferingNewest(1)) {
|
||||
continuation in
|
||||
authenticatedSessionIdentityContinuations[continuationID] =
|
||||
continuation
|
||||
continuation.yield(publishedAuthenticatedSessionIdentity)
|
||||
continuation.onTermination = { @Sendable [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.authenticatedSessionIdentityContinuations[
|
||||
continuationID
|
||||
] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a credential-free identity still names the published session.
|
||||
/// This stays stable through same-account revalidation but flips false at
|
||||
/// the synchronous start of sign-out.
|
||||
func isAuthenticatedSessionIdentityCurrent(
|
||||
_ identity: AuthenticatedSessionIdentity
|
||||
) -> Bool {
|
||||
publishedAuthenticatedSessionIdentity == identity
|
||||
}
|
||||
}
|
||||
|
||||
extension AuthCoordinator {
|
||||
private var publishedAuthenticatedSessionIdentity:
|
||||
AuthenticatedSessionIdentity? {
|
||||
guard isAuthenticated,
|
||||
!isCapturingSignOutCredentials,
|
||||
let accountID = currentUser?.id,
|
||||
!accountID.isEmpty else { return nil }
|
||||
return AuthenticatedSessionIdentity(
|
||||
generation: authSessionGeneration,
|
||||
accountID: accountID
|
||||
)
|
||||
}
|
||||
|
||||
func publishAuthenticatedSessionIdentity() {
|
||||
let identity = publishedAuthenticatedSessionIdentity
|
||||
for continuation in authenticatedSessionIdentityContinuations.values {
|
||||
continuation.yield(identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -106,6 +106,9 @@ public final class AuthCoordinator {
|
||||
@ObservationIgnored var signOutEpoch: UInt64 = 0
|
||||
/// Monotonic sign-in attempt count, allocating each flow's attempt id.
|
||||
@ObservationIgnored var signInAttemptCounter: UInt64 = 0
|
||||
@ObservationIgnored var authenticatedSessionIdentityContinuations: [
|
||||
UUID: AsyncStream<AuthenticatedSessionIdentity?>.Continuation
|
||||
] = [:]
|
||||
/// Sign-in attempts that currently own a possible write to the token store.
|
||||
///
|
||||
/// This ownership spans the whole flow, not just the credential-exchange
|
||||
@@ -155,6 +158,7 @@ public final class AuthCoordinator {
|
||||
|
||||
private func finishSignInFlow(_ flow: SignInFlowContext) {
|
||||
activeSignInFlows[flow.attempt] = nil
|
||||
publishAuthenticatedSessionIdentity()
|
||||
}
|
||||
|
||||
/// Creates an auth coordinator.
|
||||
@@ -505,6 +509,7 @@ public final class AuthCoordinator {
|
||||
// the local clear below).
|
||||
advanceSessionGeneration()
|
||||
signOutEpoch &+= 1
|
||||
publishAuthenticatedSessionIdentity()
|
||||
await phaseTimeoutRegistry.clear([.sendCode, .verifyCode, .passwordSignIn, .oauth, .validateSession])
|
||||
|
||||
// Capture the teardown credentials with raw stored reads (no refresh,
|
||||
@@ -626,6 +631,7 @@ public final class AuthCoordinator {
|
||||
currentUser = user
|
||||
isAuthenticated = true
|
||||
isRestoringSession = false
|
||||
publishAuthenticatedSessionIdentity()
|
||||
saveCachedUser(user)
|
||||
sessionCache.setHasTokens(true)
|
||||
await refreshTeams(generation: generation)
|
||||
@@ -726,6 +732,7 @@ public final class AuthCoordinator {
|
||||
currentUser = cachedUser
|
||||
isAuthenticated = cachedUser != nil
|
||||
isRestoringSession = false
|
||||
publishAuthenticatedSessionIdentity()
|
||||
}
|
||||
|
||||
func clearPersistedAuthForUITest() async {
|
||||
@@ -757,6 +764,7 @@ public final class AuthCoordinator {
|
||||
currentUser = state.currentUser
|
||||
isAuthenticated = state.isAuthenticated
|
||||
isRestoringSession = state.isRestoringSession
|
||||
publishAuthenticatedSessionIdentity()
|
||||
}
|
||||
|
||||
func loadCachedUser() -> CMUXAuthUser? {
|
||||
|
||||
+17
-1
@@ -8,6 +8,13 @@ import Foundation
|
||||
/// that talk to the web API (e.g. ``PushRegistrationService``) so they never
|
||||
/// reach for an auth singleton.
|
||||
public protocol TokenProviding: Sendable {
|
||||
/// Coherent account id + token pair pinned to one auth-session generation.
|
||||
func authenticatedSessionSnapshot() async throws
|
||||
-> AuthenticatedSessionSnapshot
|
||||
/// Whether a previously captured snapshot still names the live session.
|
||||
func isAuthenticatedSessionCurrent(
|
||||
_ snapshot: AuthenticatedSessionSnapshot
|
||||
) async -> Bool
|
||||
/// The current access token, throwing when there is no valid session.
|
||||
func accessToken() async throws -> String
|
||||
/// The currently stored access token, without refresh or auth-state mutation.
|
||||
@@ -28,4 +35,13 @@ public protocol TokenProviding: Sendable {
|
||||
func forceRefreshAccessToken() async throws -> String
|
||||
}
|
||||
|
||||
extension AuthCoordinator: TokenProviding {}
|
||||
extension AuthCoordinator: TokenProviding {
|
||||
public func isAuthenticatedSessionCurrent(
|
||||
_ snapshot: AuthenticatedSessionSnapshot
|
||||
) async -> Bool {
|
||||
isAuthenticated
|
||||
&& !sessionTokenTransitionIsActive
|
||||
&& authSessionGeneration == snapshot.generation
|
||||
&& currentUser?.id == snapshot.accountID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@ public protocol PushRegistering: Sendable {
|
||||
/// Whether the user has opted into phone notifications.
|
||||
var isEnabled: Bool { get async }
|
||||
|
||||
/// The furthest locally and remotely confirmed registration stage.
|
||||
var snapshot: PushRegistrationSnapshot { get async }
|
||||
|
||||
/// A stream that immediately yields the current snapshot and every change.
|
||||
func snapshots() async -> AsyncStream<PushRegistrationSnapshot>
|
||||
|
||||
/// Persist the opt-in flag, re-uploading any cached token on enable and
|
||||
/// removing it server-side on disable.
|
||||
func setEnabled(_ enabled: Bool) async
|
||||
@@ -18,6 +24,10 @@ public protocol PushRegistering: Sendable {
|
||||
/// Cache and (when opted in) upload a freshly registered APNs device token.
|
||||
func register(deviceToken: Data) async
|
||||
|
||||
/// Records a terminal APNs token-registration callback failure without
|
||||
/// retaining or exposing the system error description.
|
||||
func deviceTokenRegistrationFailed() async
|
||||
|
||||
/// Re-upload the cached token (e.g. after sign-in). No-op unless opted in.
|
||||
func syncTokenIfPossible() async
|
||||
|
||||
@@ -32,4 +42,11 @@ public protocol PushRegistering: Sendable {
|
||||
/// live provider could authenticate as a NEXT account whose sign-in raced
|
||||
/// the bounded teardown.
|
||||
func unregisterFromServer(accessToken: String?, refreshToken: String?) async
|
||||
|
||||
/// Sign-out variant carrying the account id captured before local clear.
|
||||
func unregisterFromServer(
|
||||
accountID: String?,
|
||||
accessToken: String?,
|
||||
refreshToken: String?
|
||||
) async
|
||||
}
|
||||
|
||||
+759
-31
@@ -11,8 +11,10 @@ private let pushLog = Logger(subsystem: "ai.manaflow.cmux", category: "push")
|
||||
/// URL, bundle id, `UserDefaults(suiteName:)`, and `URLSession`, then inject it
|
||||
/// as `any PushRegistering`.
|
||||
///
|
||||
/// Privacy: notifications are **off by default**. Nothing (not even a device
|
||||
/// token) is uploaded until the user enables them via ``setEnabled(_:)``.
|
||||
/// Privacy: nothing (not even a device token) is uploaded until the app's
|
||||
/// workspace-list permission flow is accepted or the user explicitly enables
|
||||
/// notifications and the coordinator calls ``setEnabled(_:)``. An explicit
|
||||
/// app opt-out remains persisted and authoritative.
|
||||
public actor PushRegistrationService: PushRegistering {
|
||||
private let tokenProvider: any TokenProviding
|
||||
private let apiBaseURL: String
|
||||
@@ -20,9 +22,24 @@ public actor PushRegistrationService: PushRegistering {
|
||||
private let apnsEnvironment: String
|
||||
private let defaults: UserDefaults
|
||||
private let session: URLSession
|
||||
private let retryDelays: [Duration]
|
||||
private let retryJitter: @Sendable (ClosedRange<Double>) -> Double
|
||||
private let retrySleep: @Sendable (Duration) async throws -> Void
|
||||
private var retryTask: Task<Void, Never>?
|
||||
private var unregisterDrainTask: Task<Void, Never>?
|
||||
private var operationGeneration = UUID()
|
||||
private var snapshotValue: PushRegistrationSnapshot
|
||||
private var snapshotContinuations:
|
||||
[UUID: AsyncStream<PushRegistrationSnapshot>.Continuation] = [:]
|
||||
|
||||
private static let enabledKey = "cmux.notifications.pushEnabled"
|
||||
private static let cachedTokenKey = "cmux.notifications.deviceTokenHex"
|
||||
private static let registeredAccountIDKey = "cmux.notifications.registeredAccountID"
|
||||
private static let pendingUnregisterTokenKey = "cmux.notifications.pendingUnregisterToken"
|
||||
private static let pendingUnregisterAccountIDKey = "cmux.notifications.pendingUnregisterAccountID"
|
||||
private static let pendingUnregisterQueueKey =
|
||||
"cmux.notifications.pendingUnregisters.v2"
|
||||
private static let pendingUnregisterAttemptBudget = 4
|
||||
|
||||
/// Creates a push registration service.
|
||||
///
|
||||
@@ -43,7 +60,19 @@ public actor PushRegistrationService: PushRegistering {
|
||||
bundleID: String,
|
||||
apnsEnvironment: String,
|
||||
suiteName: String? = nil,
|
||||
session: sending URLSession = .shared
|
||||
session: sending URLSession = .shared,
|
||||
retryDelays: [Duration] = [
|
||||
.seconds(1),
|
||||
.seconds(4),
|
||||
.seconds(15),
|
||||
.seconds(60),
|
||||
],
|
||||
retryJitter: @escaping @Sendable (ClosedRange<Double>) -> Double = {
|
||||
Double.random(in: $0)
|
||||
},
|
||||
retrySleep: @escaping @Sendable (Duration) async throws -> Void = {
|
||||
try await ContinuousClock().sleep(for: $0)
|
||||
}
|
||||
) {
|
||||
self.tokenProvider = tokenProvider
|
||||
self.apiBaseURL = apiBaseURL
|
||||
@@ -54,35 +83,126 @@ public actor PushRegistrationService: PushRegistering {
|
||||
} else {
|
||||
self.defaults = .standard
|
||||
}
|
||||
Self.migrateLegacyPendingUnregisters(in: self.defaults)
|
||||
self.session = session
|
||||
self.retryDelays = retryDelays
|
||||
self.retryJitter = retryJitter
|
||||
self.retrySleep = retrySleep
|
||||
let enabled = self.defaults.bool(forKey: Self.enabledKey)
|
||||
let hasToken = self.defaults.string(forKey: Self.cachedTokenKey)?.isEmpty == false
|
||||
self.snapshotValue = PushRegistrationSnapshot(
|
||||
isEnabled: enabled,
|
||||
hasDeviceToken: hasToken,
|
||||
backendState: enabled
|
||||
? (hasToken ? .registrationRequired : .awaitingDeviceToken)
|
||||
: .awaitingDeviceToken
|
||||
)
|
||||
}
|
||||
|
||||
public var isEnabled: Bool { defaults.bool(forKey: Self.enabledKey) }
|
||||
public var snapshot: PushRegistrationSnapshot { snapshotValue }
|
||||
|
||||
public func snapshots() -> AsyncStream<PushRegistrationSnapshot> {
|
||||
let id = UUID()
|
||||
return AsyncStream { continuation in
|
||||
snapshotContinuations[id] = continuation
|
||||
continuation.yield(snapshotValue)
|
||||
continuation.onTermination = { [weak self] _ in
|
||||
Task { await self?.removeSnapshotContinuation(id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func setEnabled(_ enabled: Bool) async {
|
||||
let wasEnabled = isEnabled
|
||||
cancelRetry()
|
||||
defaults.set(enabled, forKey: Self.enabledKey)
|
||||
if enabled {
|
||||
await syncTokenIfPossible()
|
||||
} else {
|
||||
await unregisterFromServer()
|
||||
publish(.disabled)
|
||||
if wasEnabled {
|
||||
await unregisterFromServer()
|
||||
} else {
|
||||
await retryPendingUnregisterIfPossible()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func register(deviceToken: Data) async {
|
||||
let hex = deviceToken.map { String(format: "%02x", $0) }.joined()
|
||||
let previousToken = cachedTokenHex
|
||||
if let previousToken,
|
||||
previousToken != hex,
|
||||
let previousOwner = defaults.string(
|
||||
forKey: Self.registeredAccountIDKey
|
||||
),
|
||||
!previousOwner.isEmpty {
|
||||
// Rotation does not prove the old row disappeared. Preserve its
|
||||
// cleanup before replacing the cache, then make the new token
|
||||
// ready before attempting the old-token DELETE.
|
||||
persistPendingUnregister(
|
||||
tokenHex: previousToken,
|
||||
accountID: previousOwner
|
||||
)
|
||||
defaults.removeObject(forKey: Self.registeredAccountIDKey)
|
||||
}
|
||||
defaults.set(hex, forKey: Self.cachedTokenKey)
|
||||
guard isEnabled else { return }
|
||||
guard isEnabled else {
|
||||
publish(.disabled)
|
||||
return
|
||||
}
|
||||
cancelRetry()
|
||||
await upload(tokenHex: hex)
|
||||
if snapshotValue.backendState == .registered {
|
||||
await retryPendingUnregisterIfPossible()
|
||||
}
|
||||
}
|
||||
|
||||
public func syncTokenIfPossible() async {
|
||||
guard isEnabled, let hex = cachedTokenHex else { return }
|
||||
guard isEnabled else {
|
||||
await retryPendingUnregisterIfPossible()
|
||||
publish(.disabled)
|
||||
return
|
||||
}
|
||||
guard let hex = cachedTokenHex else {
|
||||
publish(PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: false,
|
||||
backendState: .awaitingDeviceToken
|
||||
))
|
||||
// There is no current registration to prioritize, so an
|
||||
// owner-matching privacy cleanup can proceed immediately.
|
||||
await retryPendingUnregisterIfPossible()
|
||||
return
|
||||
}
|
||||
cancelRetry()
|
||||
await upload(tokenHex: hex)
|
||||
// Current-account registration is the readiness-critical operation.
|
||||
// Historical cleanup follows it, with its own bounded attempt budget.
|
||||
if snapshotValue.backendState == .registered {
|
||||
await retryPendingUnregisterIfPossible()
|
||||
}
|
||||
}
|
||||
|
||||
public func unregisterFromServer() async {
|
||||
cancelRetry()
|
||||
guard let hex = cachedTokenHex else { return }
|
||||
await sendDelete(tokenHex: hex)
|
||||
let session = try? await tokenProvider.authenticatedSessionSnapshot()
|
||||
let ownerID = defaults.string(
|
||||
forKey: Self.registeredAccountIDKey
|
||||
) ?? session?.accountID
|
||||
guard let ownerID, !ownerID.isEmpty else { return }
|
||||
// Persist before requiring live auth. This is the privacy guarantee for
|
||||
// an offline or signed-out opt-out.
|
||||
persistPendingUnregister(tokenHex: hex, accountID: ownerID)
|
||||
// A token acknowledged for account A must never be deleted using
|
||||
// account B credentials. Its tombstone waits for A to return.
|
||||
guard let session, session.accountID == ownerID else { return }
|
||||
if await sendDelete(tokenHex: hex, sessionSnapshot: session) {
|
||||
clearPendingUnregister(tokenHex: hex, accountID: ownerID)
|
||||
clearRegisteredOwner(accountID: ownerID, tokenHex: hex)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the device token from the server at sign-out, authenticating
|
||||
@@ -93,7 +213,39 @@ public actor PushRegistrationService: PushRegistering {
|
||||
/// - accessToken: The captured (or teardown-minted) access token.
|
||||
/// - refreshToken: The captured refresh token.
|
||||
public func unregisterFromServer(accessToken: String?, refreshToken: String?) async {
|
||||
await unregisterFromServer(
|
||||
accountID: nil,
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken
|
||||
)
|
||||
}
|
||||
|
||||
/// Sign-out variant with the account id captured before local auth clear.
|
||||
public func unregisterFromServer(
|
||||
accountID capturedAccountID: String?,
|
||||
accessToken: String?,
|
||||
refreshToken: String?
|
||||
) async {
|
||||
cancelRetry()
|
||||
guard let hex = cachedTokenHex else { return }
|
||||
let registeredOwnerID = defaults.string(
|
||||
forKey: Self.registeredAccountIDKey
|
||||
)
|
||||
let ownerID = registeredOwnerID ?? capturedAccountID
|
||||
if let ownerID, !ownerID.isEmpty {
|
||||
// Persist the recovery record before validating credentials.
|
||||
// Offline sign-out commonly has only the refresh token, but a
|
||||
// later sign-in to this same account can safely finish the DELETE.
|
||||
persistPendingUnregister(tokenHex: hex, accountID: ownerID)
|
||||
}
|
||||
if let registeredOwnerID,
|
||||
capturedAccountID != registeredOwnerID {
|
||||
// The legacy overload has no account identity, and a caller
|
||||
// explicitly carrying B must never apply B's credentials to A's
|
||||
// acknowledged token. Keep A's tombstone until A returns.
|
||||
pushLog.info("Skipping push-token unregister: captured account does not prove registered ownership")
|
||||
return
|
||||
}
|
||||
// Sign-out path: never fall back to the live token provider. The
|
||||
// local-first sign-out cleared it, and a sign-in racing the bounded
|
||||
// teardown can repopulate it with the NEXT account's tokens; the
|
||||
@@ -104,7 +256,21 @@ public actor PushRegistrationService: PushRegistering {
|
||||
pushLog.info("Skipping push-token unregister at sign-out: captured credentials incomplete")
|
||||
return
|
||||
}
|
||||
await sendDelete(tokenHex: hex, capturedAccessToken: accessToken, capturedRefreshToken: refreshToken)
|
||||
if await sendDelete(
|
||||
tokenHex: hex,
|
||||
capturedAccessToken: accessToken,
|
||||
capturedRefreshToken: refreshToken
|
||||
), let ownerID {
|
||||
clearPendingUnregister(tokenHex: hex, accountID: ownerID)
|
||||
clearRegisteredOwner(accountID: ownerID, tokenHex: hex)
|
||||
}
|
||||
if isEnabled {
|
||||
publish(PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .registrationRequired
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private var cachedTokenHex: String? {
|
||||
@@ -113,7 +279,28 @@ public actor PushRegistrationService: PushRegistering {
|
||||
}
|
||||
|
||||
private func upload(tokenHex: String) async {
|
||||
guard let request = await makeRequest(
|
||||
operationGeneration = UUID()
|
||||
let generation = operationGeneration
|
||||
await attemptUpload(
|
||||
tokenHex: tokenHex,
|
||||
generation: generation,
|
||||
remainingDelays: retryDelays
|
||||
)
|
||||
}
|
||||
|
||||
private func attemptUpload(
|
||||
tokenHex: String,
|
||||
generation: UUID,
|
||||
remainingDelays: [Duration]
|
||||
) async {
|
||||
guard isEnabled, generation == operationGeneration,
|
||||
cachedTokenHex == tokenHex else { return }
|
||||
publish(PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .registering
|
||||
))
|
||||
let request = await makeRequest(
|
||||
method: "POST",
|
||||
path: "/api/device-tokens",
|
||||
body: [
|
||||
@@ -122,23 +309,199 @@ public actor PushRegistrationService: PushRegistering {
|
||||
"environment": apnsEnvironment,
|
||||
"platform": "ios",
|
||||
]
|
||||
) else { return }
|
||||
await perform(request, label: "register")
|
||||
)
|
||||
let result: RegistrationResult
|
||||
let requestSession: AuthenticatedSessionSnapshot?
|
||||
switch request {
|
||||
case let .success(context):
|
||||
requestSession = context.session
|
||||
result = await performRegistration(context.request)
|
||||
case let .failure(failure):
|
||||
requestSession = nil
|
||||
result = .failure(failure, retryAfter: nil)
|
||||
}
|
||||
let operationIsCurrent = isEnabled
|
||||
&& generation == operationGeneration
|
||||
&& cachedTokenHex == tokenHex
|
||||
let sessionIsCurrent: Bool
|
||||
if let requestSession {
|
||||
sessionIsCurrent = await tokenProvider
|
||||
.isAuthenticatedSessionCurrent(requestSession)
|
||||
} else {
|
||||
sessionIsCurrent = false
|
||||
}
|
||||
if case .success = result,
|
||||
let requestSession,
|
||||
(!operationIsCurrent || !sessionIsCurrent) {
|
||||
await reconcileStaleSuccessfulRegistration(
|
||||
tokenHex: tokenHex,
|
||||
staleSession: requestSession
|
||||
)
|
||||
return
|
||||
}
|
||||
guard operationIsCurrent else { return }
|
||||
if requestSession != nil, !sessionIsCurrent {
|
||||
publish(PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .failed(.authenticationRequired)
|
||||
))
|
||||
return
|
||||
}
|
||||
switch result {
|
||||
case let .success(pushServiceConfigured):
|
||||
if let requestSession {
|
||||
defaults.set(
|
||||
requestSession.accountID,
|
||||
forKey: Self.registeredAccountIDKey
|
||||
)
|
||||
}
|
||||
// The token is globally unique. A successful upsert onto the
|
||||
// current account also removes any old-account association, so a
|
||||
// pending tombstone for this token is fulfilled without applying
|
||||
// old credentials.
|
||||
for pending in pendingUnregisters where pending.tokenHex == tokenHex {
|
||||
clearPendingUnregister(
|
||||
tokenHex: pending.tokenHex,
|
||||
accountID: pending.accountID
|
||||
)
|
||||
}
|
||||
if pushServiceConfigured {
|
||||
publish(PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .registered
|
||||
))
|
||||
} else {
|
||||
// The API committed ownership before reporting its provider
|
||||
// readiness. Retain that cleanup identity while failing the
|
||||
// user-facing readiness check closed and retrying recovery.
|
||||
let failure = PushRegistrationFailure.serviceUnavailable
|
||||
publish(PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .failed(failure)
|
||||
))
|
||||
scheduleUploadRetry(
|
||||
failure: failure,
|
||||
retryAfter: nil,
|
||||
tokenHex: tokenHex,
|
||||
generation: generation,
|
||||
remainingDelays: remainingDelays
|
||||
)
|
||||
}
|
||||
case let .failure(failure, retryAfter):
|
||||
publish(PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .failed(failure)
|
||||
))
|
||||
scheduleUploadRetry(
|
||||
failure: failure,
|
||||
retryAfter: retryAfter,
|
||||
tokenHex: tokenHex,
|
||||
generation: generation,
|
||||
remainingDelays: remainingDelays
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleUploadRetry(
|
||||
failure: PushRegistrationFailure,
|
||||
retryAfter: Duration?,
|
||||
tokenHex: String,
|
||||
generation: UUID,
|
||||
remainingDelays: [Duration]
|
||||
) {
|
||||
guard failure.isRecoverable, !remainingDelays.isEmpty else { return }
|
||||
let fallbackDelay = remainingDelays[0]
|
||||
let delay = retryAfter ?? Self.jittered(
|
||||
fallbackDelay,
|
||||
multiplier: retryJitter(0.8...1.2)
|
||||
)
|
||||
let laterDelays = Array(remainingDelays.dropFirst())
|
||||
retryTask = Task { [weak self, retrySleep] in
|
||||
do {
|
||||
try await retrySleep(delay)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
await self?.attemptUpload(
|
||||
tokenHex: tokenHex,
|
||||
generation: generation,
|
||||
remainingDelays: laterDelays
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Repairs the backend after an invalidated POST still succeeds.
|
||||
///
|
||||
/// URLSession cancellation cannot prove that the server did not commit the
|
||||
/// request. Delete with the exact stale account credentials after its
|
||||
/// acknowledgement, then re-upsert the token for whichever account is
|
||||
/// current now. This orders A POST, A DELETE, B POST and therefore makes B
|
||||
/// the final owner even when A's response arrives last.
|
||||
private func reconcileStaleSuccessfulRegistration(
|
||||
tokenHex: String,
|
||||
staleSession: AuthenticatedSessionSnapshot
|
||||
) async {
|
||||
let currentSession = try? await tokenProvider
|
||||
.authenticatedSessionSnapshot()
|
||||
if isEnabled,
|
||||
cachedTokenHex == tokenHex,
|
||||
currentSession?.accountID == staleSession.accountID {
|
||||
// A newer operation for the same account and token already
|
||||
// represents the same backend ownership. Do not disturb it.
|
||||
return
|
||||
}
|
||||
|
||||
persistPendingUnregister(
|
||||
tokenHex: tokenHex,
|
||||
accountID: staleSession.accountID
|
||||
)
|
||||
if await sendDelete(
|
||||
tokenHex: tokenHex,
|
||||
capturedAccessToken: staleSession.accessToken,
|
||||
capturedRefreshToken: staleSession.refreshToken
|
||||
) {
|
||||
clearPendingUnregister(
|
||||
tokenHex: tokenHex,
|
||||
accountID: staleSession.accountID
|
||||
)
|
||||
clearRegisteredOwner(
|
||||
accountID: staleSession.accountID,
|
||||
tokenHex: tokenHex
|
||||
)
|
||||
}
|
||||
|
||||
guard isEnabled, let currentToken = cachedTokenHex,
|
||||
let currentSession = try? await tokenProvider
|
||||
.authenticatedSessionSnapshot(),
|
||||
await tokenProvider.isAuthenticatedSessionCurrent(currentSession)
|
||||
else { return }
|
||||
await upload(tokenHex: currentToken)
|
||||
}
|
||||
|
||||
private func sendDelete(
|
||||
tokenHex: String,
|
||||
capturedAccessToken: String? = nil,
|
||||
capturedRefreshToken: String? = nil
|
||||
) async {
|
||||
guard let request = await makeRequest(
|
||||
capturedRefreshToken: String? = nil,
|
||||
sessionSnapshot: AuthenticatedSessionSnapshot? = nil
|
||||
) async -> Bool {
|
||||
guard case let .success(context) = await makeRequest(
|
||||
method: "DELETE",
|
||||
path: "/api/device-tokens",
|
||||
body: ["deviceToken": tokenHex],
|
||||
capturedAccessToken: capturedAccessToken,
|
||||
capturedRefreshToken: capturedRefreshToken
|
||||
) else { return }
|
||||
await perform(request, label: "unregister")
|
||||
capturedRefreshToken: capturedRefreshToken,
|
||||
sessionSnapshot: sessionSnapshot
|
||||
) else { return false }
|
||||
guard await performDelete(context.request) else { return false }
|
||||
if let session = context.session {
|
||||
return await tokenProvider.isAuthenticatedSessionCurrent(session)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func makeRequest(
|
||||
@@ -146,42 +509,407 @@ public actor PushRegistrationService: PushRegistering {
|
||||
path: String,
|
||||
body: [String: String],
|
||||
capturedAccessToken: String? = nil,
|
||||
capturedRefreshToken: String? = nil
|
||||
) async -> URLRequest? {
|
||||
capturedRefreshToken: String? = nil,
|
||||
sessionSnapshot: AuthenticatedSessionSnapshot? = nil
|
||||
) async -> Result<PushRequest, PushRegistrationFailure> {
|
||||
let accessToken: String
|
||||
let refreshToken: String
|
||||
if let capturedAccessToken, let capturedRefreshToken {
|
||||
let authenticatedSession: AuthenticatedSessionSnapshot?
|
||||
if let sessionSnapshot {
|
||||
accessToken = sessionSnapshot.accessToken
|
||||
refreshToken = sessionSnapshot.refreshToken
|
||||
authenticatedSession = sessionSnapshot
|
||||
} else if let capturedAccessToken, let capturedRefreshToken {
|
||||
// Sign-out path: the live provider is already cleared by the
|
||||
// local-first sign-out; the captured pair is the only credential.
|
||||
accessToken = capturedAccessToken
|
||||
refreshToken = capturedRefreshToken
|
||||
authenticatedSession = nil
|
||||
} else {
|
||||
do {
|
||||
accessToken = try await tokenProvider.accessToken()
|
||||
let session = try await tokenProvider
|
||||
.authenticatedSessionSnapshot()
|
||||
accessToken = session.accessToken
|
||||
refreshToken = session.refreshToken
|
||||
authenticatedSession = session
|
||||
} catch {
|
||||
return nil
|
||||
return .failure(.authenticationRequired)
|
||||
}
|
||||
guard let liveRefreshToken = await tokenProvider.refreshToken() else { return nil }
|
||||
refreshToken = liveRefreshToken
|
||||
}
|
||||
guard let url = URL(string: apiBaseURL + path) else { return nil }
|
||||
guard let url = URL(string: apiBaseURL + path) else {
|
||||
return .failure(.invalidConfiguration)
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue(refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||
return request
|
||||
request.timeoutInterval = 15
|
||||
return .success(PushRequest(
|
||||
request: request,
|
||||
session: authenticatedSession
|
||||
))
|
||||
}
|
||||
|
||||
private func perform(_ request: URLRequest, label: String) async {
|
||||
private func performRegistration(_ request: URLRequest) async -> RegistrationResult {
|
||||
let redirectDelegate = RedirectMethodPreservingDelegate()
|
||||
do {
|
||||
let (_, response) = try await session.data(for: request)
|
||||
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
||||
pushLog.error("\(label, privacy: .public) failed status=\(http.statusCode, privacy: .public)")
|
||||
let (data, response) = try await session.data(
|
||||
for: request,
|
||||
delegate: redirectDelegate
|
||||
)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
return .failure(.invalidServerResponse, retryAfter: nil)
|
||||
}
|
||||
guard (200...299).contains(http.statusCode) else {
|
||||
return Self.failureResult(statusCode: http.statusCode, response: http, data: data)
|
||||
}
|
||||
guard let acknowledgement = try? JSONDecoder().decode(
|
||||
RegistrationAcknowledgement.self,
|
||||
from: data
|
||||
), acknowledgement.ok else {
|
||||
return .failure(.invalidServerResponse, retryAfter: nil)
|
||||
}
|
||||
return .success(
|
||||
pushServiceConfigured:
|
||||
acknowledgement.pushServiceConfigured != false
|
||||
)
|
||||
} catch {
|
||||
pushLog.error("\(label, privacy: .public) error=\(error.localizedDescription, privacy: .private)")
|
||||
if redirectDelegate.refusedRedirect {
|
||||
return .failure(.invalidServerResponse, retryAfter: nil)
|
||||
}
|
||||
pushLog.error("register transport failure")
|
||||
return .failure(.networkUnavailable, retryAfter: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func performDelete(_ request: URLRequest) async -> Bool {
|
||||
let redirectDelegate = RedirectMethodPreservingDelegate()
|
||||
do {
|
||||
let (data, response) = try await session.data(
|
||||
for: request,
|
||||
delegate: redirectDelegate
|
||||
)
|
||||
if let http = response as? HTTPURLResponse,
|
||||
!(200...299).contains(http.statusCode) {
|
||||
pushLog.error(
|
||||
"unregister failed status=\(http.statusCode, privacy: .public)"
|
||||
)
|
||||
return false
|
||||
}
|
||||
guard response is HTTPURLResponse,
|
||||
let acknowledgement = try? JSONDecoder().decode(
|
||||
RegistrationAcknowledgement.self,
|
||||
from: data
|
||||
),
|
||||
acknowledgement.ok
|
||||
else {
|
||||
pushLog.error("unregister acknowledgement invalid")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
pushLog.error("unregister transport failure")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func retryPendingUnregisterIfPossible() async {
|
||||
guard let session = try? await tokenProvider
|
||||
.authenticatedSessionSnapshot() else { return }
|
||||
let currentAccountID = session.accountID
|
||||
let matching = pendingUnregisters.filter {
|
||||
$0.accountID == currentAccountID
|
||||
}
|
||||
let batch = Array(
|
||||
matching.prefix(Self.pendingUnregisterAttemptBudget)
|
||||
)
|
||||
let results = await withTaskGroup(
|
||||
of: (PendingUnregister, Bool).self,
|
||||
returning: [(PendingUnregister, Bool)].self
|
||||
) { group in
|
||||
for pending in batch {
|
||||
group.addTask { [self] in
|
||||
(
|
||||
pending,
|
||||
await sendDelete(
|
||||
tokenHex: pending.tokenHex,
|
||||
sessionSnapshot: session
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
var results: [(PendingUnregister, Bool)] = []
|
||||
for await result in group {
|
||||
results.append(result)
|
||||
}
|
||||
return results
|
||||
}
|
||||
for (pending, succeeded) in results where succeeded {
|
||||
clearPendingUnregister(
|
||||
tokenHex: pending.tokenHex,
|
||||
accountID: pending.accountID
|
||||
)
|
||||
clearRegisteredOwner(
|
||||
accountID: pending.accountID,
|
||||
tokenHex: pending.tokenHex
|
||||
)
|
||||
}
|
||||
if matching.count > batch.count,
|
||||
results.contains(where: { $0.1 }) {
|
||||
schedulePendingUnregisterContinuation()
|
||||
}
|
||||
}
|
||||
|
||||
private func persistPendingUnregister(tokenHex: String, accountID: String) {
|
||||
let entry = PendingUnregister(tokenHex: tokenHex, accountID: accountID)
|
||||
var queue = pendingUnregisters
|
||||
if !queue.contains(entry) {
|
||||
queue.append(entry)
|
||||
}
|
||||
// Never evict a privacy cleanup obligation merely to enforce a local
|
||||
// storage cap. The set is deduplicated by (account, token), and drains
|
||||
// in bounded network batches so size cannot stall current readiness.
|
||||
storePendingUnregisters(queue)
|
||||
}
|
||||
|
||||
private func schedulePendingUnregisterContinuation() {
|
||||
guard unregisterDrainTask == nil else { return }
|
||||
unregisterDrainTask = Task { [weak self] in
|
||||
await Task.yield()
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
await self.runPendingUnregisterContinuation()
|
||||
}
|
||||
}
|
||||
|
||||
private func runPendingUnregisterContinuation() async {
|
||||
unregisterDrainTask = nil
|
||||
await retryPendingUnregisterIfPossible()
|
||||
}
|
||||
|
||||
private func clearPendingUnregister(
|
||||
tokenHex: String,
|
||||
accountID: String
|
||||
) {
|
||||
let filtered = pendingUnregisters.filter { entry in
|
||||
entry.tokenHex != tokenHex || entry.accountID != accountID
|
||||
}
|
||||
storePendingUnregisters(filtered)
|
||||
}
|
||||
|
||||
private var pendingUnregisters: [PendingUnregister] {
|
||||
let entries: [PendingUnregister]
|
||||
if let data = defaults.data(forKey: Self.pendingUnregisterQueueKey),
|
||||
let decoded = try? JSONDecoder().decode(
|
||||
[PendingUnregister].self,
|
||||
from: data
|
||||
) {
|
||||
entries = decoded
|
||||
} else {
|
||||
entries = []
|
||||
}
|
||||
var seen = Set<PendingUnregister>()
|
||||
return entries.filter { seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
private static func migrateLegacyPendingUnregisters(
|
||||
in defaults: UserDefaults
|
||||
) {
|
||||
guard let tokenHex = defaults.string(
|
||||
forKey: pendingUnregisterTokenKey
|
||||
), let accountID = defaults.string(
|
||||
forKey: pendingUnregisterAccountIDKey
|
||||
), !tokenHex.isEmpty, !accountID.isEmpty else { return }
|
||||
var entries = (defaults.data(forKey: pendingUnregisterQueueKey)
|
||||
.flatMap { try? JSONDecoder().decode(
|
||||
[PendingUnregister].self,
|
||||
from: $0
|
||||
) }) ?? []
|
||||
let legacy = PendingUnregister(
|
||||
tokenHex: tokenHex,
|
||||
accountID: accountID
|
||||
)
|
||||
if !entries.contains(legacy) { entries.append(legacy) }
|
||||
if let data = try? JSONEncoder().encode(entries) {
|
||||
defaults.set(data, forKey: pendingUnregisterQueueKey)
|
||||
}
|
||||
defaults.removeObject(forKey: pendingUnregisterTokenKey)
|
||||
defaults.removeObject(forKey: pendingUnregisterAccountIDKey)
|
||||
}
|
||||
|
||||
private func storePendingUnregisters(_ entries: [PendingUnregister]) {
|
||||
if entries.isEmpty {
|
||||
defaults.removeObject(forKey: Self.pendingUnregisterQueueKey)
|
||||
defaults.removeObject(forKey: Self.pendingUnregisterTokenKey)
|
||||
defaults.removeObject(forKey: Self.pendingUnregisterAccountIDKey)
|
||||
return
|
||||
}
|
||||
if let data = try? JSONEncoder().encode(entries) {
|
||||
defaults.set(data, forKey: Self.pendingUnregisterQueueKey)
|
||||
}
|
||||
defaults.removeObject(forKey: Self.pendingUnregisterTokenKey)
|
||||
defaults.removeObject(forKey: Self.pendingUnregisterAccountIDKey)
|
||||
}
|
||||
|
||||
private func clearRegisteredOwner(
|
||||
accountID: String,
|
||||
tokenHex: String
|
||||
) {
|
||||
guard cachedTokenHex == tokenHex,
|
||||
defaults.string(
|
||||
forKey: Self.registeredAccountIDKey
|
||||
) == accountID else {
|
||||
return
|
||||
}
|
||||
defaults.removeObject(forKey: Self.registeredAccountIDKey)
|
||||
}
|
||||
|
||||
public func deviceTokenRegistrationFailed() {
|
||||
cancelRetry()
|
||||
guard isEnabled else {
|
||||
publish(.disabled)
|
||||
return
|
||||
}
|
||||
publish(PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: cachedTokenHex != nil,
|
||||
backendState: .deviceTokenRegistrationFailed
|
||||
))
|
||||
}
|
||||
|
||||
private func cancelRetry() {
|
||||
operationGeneration = UUID()
|
||||
retryTask?.cancel()
|
||||
retryTask = nil
|
||||
}
|
||||
|
||||
private func publish(_ snapshot: PushRegistrationSnapshot) {
|
||||
guard snapshotValue != snapshot else { return }
|
||||
snapshotValue = snapshot
|
||||
for continuation in snapshotContinuations.values {
|
||||
continuation.yield(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeSnapshotContinuation(_ id: UUID) {
|
||||
snapshotContinuations.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
private static func failureResult(
|
||||
statusCode: Int,
|
||||
response: HTTPURLResponse,
|
||||
data: Data
|
||||
) -> RegistrationResult {
|
||||
switch statusCode {
|
||||
case 300...399:
|
||||
return .failure(.invalidServerResponse, retryAfter: nil)
|
||||
case 408, 425:
|
||||
let seconds = retryAfterSeconds(response: response, body: data)
|
||||
return .failure(
|
||||
.serviceUnavailable,
|
||||
retryAfter: seconds.map(Duration.seconds)
|
||||
)
|
||||
case 401:
|
||||
return .failure(.authenticationRequired, retryAfter: nil)
|
||||
case 409:
|
||||
let body = try? JSONDecoder().decode(
|
||||
RegistrationErrorResponse.self,
|
||||
from: data
|
||||
)
|
||||
if body?.error == "push_delivery_in_progress" {
|
||||
let seconds = retryAfterSeconds(
|
||||
response: response,
|
||||
body: data
|
||||
)
|
||||
return .failure(
|
||||
.serviceUnavailable,
|
||||
retryAfter: seconds.map(Duration.seconds)
|
||||
)
|
||||
}
|
||||
return .failure(.accountDeletionInProgress, retryAfter: nil)
|
||||
case 429:
|
||||
let body = try? JSONDecoder().decode(
|
||||
RegistrationErrorResponse.self,
|
||||
from: data
|
||||
)
|
||||
if body?.error == "too_many_devices" {
|
||||
return .failure(
|
||||
.deviceLimitReached(limit: max(1, body?.limit ?? 200)),
|
||||
retryAfter: nil
|
||||
)
|
||||
}
|
||||
let seconds = retryAfterSeconds(
|
||||
response: response,
|
||||
body: data
|
||||
)
|
||||
return .failure(
|
||||
.rateLimited(retryAfterSeconds: seconds),
|
||||
retryAfter: seconds.map(Duration.seconds)
|
||||
)
|
||||
case 500...599:
|
||||
return .failure(.serviceUnavailable, retryAfter: nil)
|
||||
default:
|
||||
return .failure(.rejected(statusCode: statusCode), retryAfter: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private static func retryAfterSeconds(
|
||||
response: HTTPURLResponse,
|
||||
body: Data
|
||||
) -> Int? {
|
||||
let headerDelay = response.value(forHTTPHeaderField: "Retry-After")
|
||||
.flatMap(Int.init)
|
||||
let bodyDelay = try? JSONDecoder().decode(
|
||||
RegistrationErrorResponse.self,
|
||||
from: body
|
||||
).retryAfterSeconds
|
||||
guard let raw = headerDelay ?? bodyDelay else { return nil }
|
||||
return min(max(raw, 0), 600)
|
||||
}
|
||||
|
||||
private static func jittered(_ duration: Duration, multiplier: Double) -> Duration {
|
||||
let components = duration.components
|
||||
let seconds = Double(components.seconds)
|
||||
+ Double(components.attoseconds) / 1_000_000_000_000_000_000
|
||||
let nanoseconds = seconds * multiplier * 1_000_000_000
|
||||
guard nanoseconds.isFinite else {
|
||||
return .nanoseconds(nanoseconds.sign == .minus
|
||||
? Int64.min
|
||||
: Int64.max)
|
||||
}
|
||||
if nanoseconds >= Double(Int64.max) {
|
||||
return .nanoseconds(Int64.max)
|
||||
}
|
||||
if nanoseconds <= Double(Int64.min) {
|
||||
return .nanoseconds(Int64.min)
|
||||
}
|
||||
return .nanoseconds(Int64(nanoseconds))
|
||||
}
|
||||
}
|
||||
|
||||
private enum RegistrationResult {
|
||||
case success(pushServiceConfigured: Bool)
|
||||
case failure(PushRegistrationFailure, retryAfter: Duration?)
|
||||
}
|
||||
|
||||
private struct PushRequest {
|
||||
let request: URLRequest
|
||||
let session: AuthenticatedSessionSnapshot?
|
||||
}
|
||||
|
||||
private struct RegistrationAcknowledgement: Decodable {
|
||||
let ok: Bool
|
||||
let pushServiceConfigured: Bool?
|
||||
}
|
||||
|
||||
private struct RegistrationErrorResponse: Decodable {
|
||||
let error: String?
|
||||
let retryAfterSeconds: Int?
|
||||
let limit: Int?
|
||||
}
|
||||
|
||||
private struct PendingUnregister: Codable, Hashable {
|
||||
let tokenHex: String
|
||||
let accountID: String
|
||||
}
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/// A backend device-token registration failure safe to expose in UI and logs.
|
||||
public enum PushRegistrationFailure: Error, Sendable, Equatable {
|
||||
/// The native session could not supply a valid access/refresh token pair.
|
||||
case authenticationRequired
|
||||
/// Account deletion currently blocks user-scoped mutations.
|
||||
case accountDeletionInProgress
|
||||
/// The server asked the client to wait before retrying.
|
||||
case rateLimited(retryAfterSeconds: Int?)
|
||||
/// The account already has the maximum number of unpruned device tokens.
|
||||
case deviceLimitReached(limit: Int)
|
||||
/// The request could not reach the API.
|
||||
case networkUnavailable
|
||||
/// The API or APNs relay is temporarily unavailable.
|
||||
case serviceUnavailable
|
||||
/// The configured API base URL is invalid.
|
||||
case invalidConfiguration
|
||||
/// A successful HTTP response did not contain the registration acknowledgement.
|
||||
case invalidServerResponse
|
||||
/// The API permanently rejected the registration request.
|
||||
case rejected(statusCode: Int)
|
||||
|
||||
/// Whether repeating the same registration later can reasonably succeed.
|
||||
public var isRecoverable: Bool {
|
||||
switch self {
|
||||
case .rateLimited, .networkUnavailable, .serviceUnavailable:
|
||||
true
|
||||
case .authenticationRequired, .accountDeletionInProgress, .deviceLimitReached,
|
||||
.invalidConfiguration, .invalidServerResponse, .rejected:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The furthest backend stage a push-enabled iOS installation has confirmed.
|
||||
public enum PushRegistrationBackendState: Sendable, Equatable {
|
||||
/// The OS has not supplied an APNs device token yet.
|
||||
case awaitingDeviceToken
|
||||
/// A cached APNs token exists but has not been acknowledged this launch.
|
||||
case registrationRequired
|
||||
/// iOS failed to acquire a current APNs token. A user-triggered retry can
|
||||
/// call `registerForRemoteNotifications()` again.
|
||||
case deviceTokenRegistrationFailed
|
||||
/// A device-token request is currently in flight.
|
||||
case registering
|
||||
/// The API acknowledged the current APNs token.
|
||||
case registered
|
||||
/// Registration stopped at a typed, user-visible failure.
|
||||
case failed(PushRegistrationFailure)
|
||||
|
||||
/// Whether the current state can recover automatically.
|
||||
public var isRecoverable: Bool {
|
||||
if case let .failed(failure) = self {
|
||||
return failure.isRecoverable
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Truthful local and backend push-registration readiness.
|
||||
public struct PushRegistrationSnapshot: Sendable, Equatable {
|
||||
/// Whether the user explicitly opted into phone notifications.
|
||||
public let isEnabled: Bool
|
||||
/// Whether this install has acquired an APNs device token.
|
||||
public let hasDeviceToken: Bool
|
||||
/// The backend acknowledgement stage for the current token.
|
||||
public let backendState: PushRegistrationBackendState
|
||||
|
||||
/// Creates a push-registration snapshot.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - isEnabled: Whether the user explicitly opted in.
|
||||
/// - hasDeviceToken: Whether APNs supplied a token.
|
||||
/// - backendState: The furthest confirmed backend stage.
|
||||
public init(
|
||||
isEnabled: Bool,
|
||||
hasDeviceToken: Bool,
|
||||
backendState: PushRegistrationBackendState
|
||||
) {
|
||||
self.isEnabled = isEnabled
|
||||
self.hasDeviceToken = hasDeviceToken
|
||||
self.backendState = backendState
|
||||
}
|
||||
|
||||
/// The canonical disabled snapshot.
|
||||
public static let disabled = PushRegistrationSnapshot(
|
||||
isEnabled: false,
|
||||
hasDeviceToken: false,
|
||||
backendState: .awaitingDeviceToken
|
||||
)
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
public import Foundation
|
||||
import OSLog
|
||||
|
||||
nonisolated private let pushRedirectLog = Logger(
|
||||
subsystem: "ai.manaflow.cmux",
|
||||
category: "push.redirect"
|
||||
)
|
||||
|
||||
/// Preserves mutating push requests across safe redirects and rejects unsafe hops.
|
||||
///
|
||||
/// Foundation normally rewrites `POST` and `DELETE` to a body-less `GET` for
|
||||
/// 301/302 responses. That turns a successful redirect target into a false
|
||||
/// registration/send acknowledgement. This delegate restores the original
|
||||
/// method, body, and headers for same-origin 301/302 responses. Same-origin
|
||||
/// 307/308 requests already preserve the method, but their credential headers
|
||||
/// are re-applied defensively.
|
||||
///
|
||||
/// Every cross-origin redirect is refused before credentials or notification
|
||||
/// data reach the target. A 303 from a mutating request is refused because its
|
||||
/// body-less GET cannot acknowledge that the original mutation completed.
|
||||
public final class RedirectMethodPreservingDelegate:
|
||||
NSObject,
|
||||
URLSessionTaskDelegate,
|
||||
@unchecked Sendable
|
||||
{
|
||||
// URLSession's redirect delegate callback and the public refusal read are
|
||||
// both synchronous on different executors, so an actor cannot own this
|
||||
// single-bit handoff without changing either API to async.
|
||||
private let refusalLock = NSLock()
|
||||
private var refusedRedirectValue = false
|
||||
|
||||
/// Creates a stateless per-owner redirect delegate.
|
||||
public override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// Whether this request was stopped by the redirect policy.
|
||||
public var refusedRedirect: Bool {
|
||||
refusalLock.withLock { refusedRedirectValue }
|
||||
}
|
||||
|
||||
/// Applies the redirect policy to one URL loading task.
|
||||
public func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest proposedRequest: URLRequest,
|
||||
completionHandler: @escaping (URLRequest?) -> Void
|
||||
) {
|
||||
guard let original = task.originalRequest,
|
||||
Self.sameOrigin(original.url, proposedRequest.url) else {
|
||||
markRefused()
|
||||
pushRedirectLog.error(
|
||||
"Refused cross-origin push redirect status=\(response.statusCode, privacy: .public)"
|
||||
)
|
||||
completionHandler(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let originalMethod = original.httpMethod?.uppercased() ?? "GET"
|
||||
let isMutation = Self.mutatingMethods.contains(originalMethod)
|
||||
if response.statusCode == 303, isMutation {
|
||||
markRefused()
|
||||
pushRedirectLog.error("Refused mutating push 303 redirect")
|
||||
completionHandler(nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard isMutation, (301...302).contains(response.statusCode)
|
||||
|| (307...308).contains(response.statusCode) else {
|
||||
completionHandler(proposedRequest)
|
||||
return
|
||||
}
|
||||
guard original.httpBodyStream == nil else {
|
||||
markRefused()
|
||||
pushRedirectLog.error("Refused non-replayable push redirect body")
|
||||
completionHandler(nil)
|
||||
return
|
||||
}
|
||||
|
||||
var preserved = proposedRequest
|
||||
preserved.httpMethod = originalMethod
|
||||
preserved.httpBody = original.httpBody
|
||||
for (field, value) in original.allHTTPHeaderFields ?? [:] {
|
||||
preserved.setValue(value, forHTTPHeaderField: field)
|
||||
}
|
||||
pushRedirectLog.info(
|
||||
"Preserved push \(originalMethod, privacy: .public) across status=\(response.statusCode, privacy: .public)"
|
||||
)
|
||||
completionHandler(preserved)
|
||||
}
|
||||
|
||||
private func markRefused() {
|
||||
refusalLock.withLock {
|
||||
refusedRedirectValue = true
|
||||
}
|
||||
}
|
||||
|
||||
private static let mutatingMethods = Set(["POST", "PUT", "PATCH", "DELETE"])
|
||||
static func sameOrigin(_ lhs: URL?, _ rhs: URL?) -> Bool {
|
||||
guard let lhs, let rhs else { return false }
|
||||
guard let lhsScheme = lhs.scheme?.lowercased(),
|
||||
let rhsScheme = rhs.scheme?.lowercased(),
|
||||
["http", "https"].contains(lhsScheme),
|
||||
["http", "https"].contains(rhsScheme),
|
||||
let lhsHost = lhs.host?.lowercased(),
|
||||
let rhsHost = rhs.host?.lowercased(),
|
||||
!lhsHost.isEmpty,
|
||||
!rhsHost.isEmpty
|
||||
else { return false }
|
||||
return lhsScheme == rhsScheme
|
||||
&& lhsHost == rhsHost
|
||||
&& effectivePort(lhs) == effectivePort(rhs)
|
||||
}
|
||||
|
||||
private static func effectivePort(_ url: URL) -> Int? {
|
||||
if let port = url.port { return port }
|
||||
switch url.scheme?.lowercased() {
|
||||
case "https", "wss":
|
||||
return 443
|
||||
case "http", "ws":
|
||||
return 80
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-9
@@ -206,17 +206,16 @@ import Testing
|
||||
}
|
||||
await client.storedAccessDidPark()
|
||||
|
||||
// The late exchange's write is what sign-out's credential capture has to
|
||||
// race, so wait for the store to hold exchange 2's tokens (or to have
|
||||
// been cleared) before releasing the capture. The client resumes this
|
||||
// from the write, so the wait does not compete with it for CPU.
|
||||
await client.releaseParkedCredential()
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: .seconds(2))
|
||||
while true {
|
||||
let refresh = await client.refreshToken()
|
||||
if refresh == "refresh-2" || refresh == nil { break }
|
||||
if clock.now >= deadline {
|
||||
preconditionFailure("Timed out waiting for late exchange cleanup to reach the token store")
|
||||
}
|
||||
await Task.yield()
|
||||
let cleanupWatchdog = failAfterDeadline(.seconds(30)) {
|
||||
"Timed out waiting for late exchange cleanup to reach the token store"
|
||||
}
|
||||
await client.tokensDidSettle(afterExchange: 2)
|
||||
cleanupWatchdog.cancel()
|
||||
|
||||
await client.releaseParkedStoredAccess()
|
||||
await signOut.value
|
||||
|
||||
@@ -36,6 +36,36 @@ import Testing
|
||||
#expect(coordinator.currentUser == nil)
|
||||
}
|
||||
|
||||
@Test(.timeLimit(.minutes(1)))
|
||||
func sessionIdentityStreamPublishesSignInAndImmediateSignOut() async throws {
|
||||
let user = CMUXAuthUser(
|
||||
id: "u1",
|
||||
primaryEmail: "[email protected]",
|
||||
displayName: "A"
|
||||
)
|
||||
let (coordinator, _) = makeCoordinator(
|
||||
client: FakeAuthClient(user: user)
|
||||
)
|
||||
var identities = coordinator.authenticatedSessionIdentities()
|
||||
.makeAsyncIterator()
|
||||
|
||||
let initial = await identities.next()
|
||||
#expect(initial != nil)
|
||||
#expect(initial! == nil)
|
||||
|
||||
try await coordinator.signInWithPassword(
|
||||
email: "[email protected]",
|
||||
password: "pw"
|
||||
)
|
||||
let signedIn = try #require(await identities.next())
|
||||
#expect(signedIn?.accountID == user.id)
|
||||
|
||||
await coordinator.signOut()
|
||||
let signedOut = await identities.next()
|
||||
#expect(signedOut != nil)
|
||||
#expect(signedOut! == nil)
|
||||
}
|
||||
|
||||
@Test func passwordSignInAuthenticatesAndCaches() async throws {
|
||||
let user = CMUXAuthUser(id: "u1", primaryEmail: "[email protected]", displayName: "A")
|
||||
let client = FakeAuthClient(user: user)
|
||||
@@ -50,6 +80,31 @@ import Testing
|
||||
#expect(recorded?.email == "[email protected]")
|
||||
}
|
||||
|
||||
@Test func emptyAccountIDNeverPublishesAnAuthenticatedIdentity() async throws {
|
||||
let user = CMUXAuthUser(
|
||||
id: "",
|
||||
primaryEmail: "[email protected]",
|
||||
displayName: "A"
|
||||
)
|
||||
let (coordinator, _) = makeCoordinator(
|
||||
client: FakeAuthClient(user: user)
|
||||
)
|
||||
|
||||
try await coordinator.signInWithPassword(
|
||||
email: "[email protected]",
|
||||
password: "pw"
|
||||
)
|
||||
|
||||
#expect(coordinator.isAuthenticated)
|
||||
#expect(coordinator.authenticatedSessionIdentity == nil)
|
||||
#expect(!coordinator.isAuthenticatedSessionIdentityCurrent(
|
||||
AuthenticatedSessionIdentity(
|
||||
generation: coordinator.authSessionGeneration,
|
||||
accountID: ""
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
@Test func everyAuthSessionTransitionClosesBeforeTheNextSessionPublishes() async throws {
|
||||
let first = CMUXAuthUser(id: "u1", primaryEmail: "[email protected]", displayName: "A")
|
||||
let second = CMUXAuthUser(id: "u2", primaryEmail: "[email protected]", displayName: "B")
|
||||
|
||||
+29
@@ -28,6 +28,8 @@ actor GateableValidationAuthClient: AuthClient {
|
||||
/// so tests can tell WHICH exchange's write the store currently holds:
|
||||
/// exchange N stores `"access-N"` / `"refresh-N"` in write order.
|
||||
private var exchangeCounter = 0
|
||||
/// Tests awaiting a settled token store after a late exchange.
|
||||
private var tokenWaiters: [(count: Int, continuation: CheckedContinuation<Void, Never>)] = []
|
||||
private var currentUserStartCount = 0
|
||||
private let validationGate = Gate()
|
||||
private let teamsGate = Gate()
|
||||
@@ -63,6 +65,29 @@ actor GateableValidationAuthClient: AuthClient {
|
||||
gate.parked.removeFirst().resume()
|
||||
}
|
||||
|
||||
// MARK: - Token-store settling
|
||||
|
||||
/// Suspends until exchange `count` has written its tokens, or until a clear
|
||||
/// emptied the store. Those are the two outcomes a late exchange racing
|
||||
/// sign-out can produce, and both are written inside this actor, so the
|
||||
/// waiter is resumed by the write instead of polling for it.
|
||||
func tokensDidSettle(afterExchange count: Int) async {
|
||||
if tokensSettled(afterExchange: count) { return }
|
||||
await withCheckedContinuation { tokenWaiters.append((count, $0)) }
|
||||
}
|
||||
|
||||
private func tokensSettled(afterExchange count: Int) -> Bool {
|
||||
exchangeCounter >= count || refresh == nil
|
||||
}
|
||||
|
||||
private func resumeSettledTokenWaiters() {
|
||||
tokenWaiters.removeAll { waiter in
|
||||
guard tokensSettled(afterExchange: waiter.count) else { return false }
|
||||
waiter.continuation.resume()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func parkIfArmed(_ gate: Gate) async {
|
||||
guard gate.armed else { return }
|
||||
gate.armed = false
|
||||
@@ -146,6 +171,7 @@ actor GateableValidationAuthClient: AuthClient {
|
||||
exchangeCounter += 1
|
||||
access = "access-\(exchangeCounter)"
|
||||
refresh = "refresh-\(exchangeCounter)"
|
||||
resumeSettledTokenWaiters()
|
||||
}
|
||||
|
||||
func accessToken() async -> String? { access }
|
||||
@@ -159,6 +185,7 @@ actor GateableValidationAuthClient: AuthClient {
|
||||
exchangeCounter += 1
|
||||
access = "access-\(exchangeCounter)"
|
||||
refresh = "refresh-\(exchangeCounter)"
|
||||
resumeSettledTokenWaiters()
|
||||
}
|
||||
func signInWithOAuth(provider: String, anchor: any AuthPresentationAnchoring) async throws {}
|
||||
|
||||
@@ -171,6 +198,7 @@ actor GateableValidationAuthClient: AuthClient {
|
||||
await parkIfArmed(clearGate)
|
||||
access = nil
|
||||
refresh = nil
|
||||
resumeSettledTokenWaiters()
|
||||
}
|
||||
|
||||
func clearLocalSession(ifRefreshTokenMatches refreshToken: String) async {
|
||||
@@ -181,6 +209,7 @@ actor GateableValidationAuthClient: AuthClient {
|
||||
guard refresh == refreshToken else { return }
|
||||
access = nil
|
||||
refresh = nil
|
||||
resumeSettledTokenWaiters()
|
||||
}
|
||||
|
||||
func revokeSession(accessToken: String?, refreshToken: String?) async throws {}
|
||||
|
||||
+32
-1
@@ -17,6 +17,7 @@ actor FlowFakeAuthClient: AuthClient {
|
||||
private var currentUserError: (any Error)?
|
||||
private var userGateClosed = false
|
||||
private var userGateWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
private var pendingUserRequestWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
private var storedAccessGateArmed = false
|
||||
private var storedAccessParked: [CheckedContinuation<Void, Never>] = []
|
||||
private var storedAccessParkWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
@@ -38,6 +39,12 @@ actor FlowFakeAuthClient: AuthClient {
|
||||
for waiter in waiters { waiter.resume() }
|
||||
}
|
||||
|
||||
/// Suspends until a `currentUser` read is parked on the closed user gate.
|
||||
func pendingUserRequestDidPark() async {
|
||||
if pendingUserRequests > 0 { return }
|
||||
await withCheckedContinuation { pendingUserRequestWaiters.append($0) }
|
||||
}
|
||||
|
||||
func armStoredAccessTokenGate() { storedAccessGateArmed = true }
|
||||
|
||||
/// Suspends until a `storedAccessToken` read is parked on the armed gate.
|
||||
@@ -59,7 +66,12 @@ actor FlowFakeAuthClient: AuthClient {
|
||||
func currentUser(throwOnMissing: Bool) async throws -> CMUXAuthUser? {
|
||||
if userGateClosed {
|
||||
pendingUserRequests += 1
|
||||
await withCheckedContinuation { userGateWaiters.append($0) }
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
userGateWaiters.append(continuation)
|
||||
let waiters = pendingUserRequestWaiters
|
||||
pendingUserRequestWaiters = []
|
||||
for waiter in waiters { waiter.resume() }
|
||||
}
|
||||
pendingUserRequests -= 1
|
||||
}
|
||||
if let currentUserError {
|
||||
@@ -140,6 +152,14 @@ actor FlowInMemoryTokenStore: StackAuthTokenStoreProtocol {
|
||||
final class FakeBrowserAuthSessionFactory: HostBrowserAuthSessionFactory {
|
||||
private(set) var sessions: [FakeBrowserAuthSession] = []
|
||||
var nextStartResult = true
|
||||
private var sessionWaiters: [(count: Int, continuation: CheckedContinuation<Void, Never>)] = []
|
||||
|
||||
/// Suspends until the attempt has created at least `count` sessions, so a
|
||||
/// test acts on a session that exists rather than polling for one.
|
||||
func sessionsDidReach(_ count: Int) async {
|
||||
if sessions.count >= count { return }
|
||||
await withCheckedContinuation { sessionWaiters.append((count, $0)) }
|
||||
}
|
||||
|
||||
func makeSession(
|
||||
signInURL: URL,
|
||||
@@ -153,8 +173,19 @@ final class FakeBrowserAuthSessionFactory: HostBrowserAuthSessionFactory {
|
||||
)
|
||||
sessions.append(session)
|
||||
nextStartResult = true
|
||||
for waiter in takeSatisfiedSessionWaiters() { waiter.resume() }
|
||||
return session
|
||||
}
|
||||
|
||||
private func takeSatisfiedSessionWaiters() -> [CheckedContinuation<Void, Never>] {
|
||||
var satisfied: [CheckedContinuation<Void, Never>] = []
|
||||
sessionWaiters.removeAll { waiter in
|
||||
guard sessions.count >= waiter.count else { return false }
|
||||
satisfied.append(waiter.continuation)
|
||||
return true
|
||||
}
|
||||
return satisfied
|
||||
}
|
||||
}
|
||||
|
||||
/// Delivers its completion exactly once, mirroring `ASWebAuthenticationSession`.
|
||||
|
||||
+36
-27
@@ -1,5 +1,6 @@
|
||||
import CMUXAuthCore
|
||||
import Foundation
|
||||
import Observation
|
||||
@testable import CmuxAuthRuntime
|
||||
|
||||
@MainActor
|
||||
@@ -94,40 +95,48 @@ struct HostBrowserSignInFlowHarness {
|
||||
.value ?? ""
|
||||
}
|
||||
|
||||
func waitForSession(count: Int = 1, timeout: Duration = .seconds(2)) async {
|
||||
// The attempt task runs on the same main actor; yielding lets it reach
|
||||
// the browser-session continuation deterministically.
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: timeout)
|
||||
while factory.sessions.count < count {
|
||||
if clock.now >= deadline {
|
||||
preconditionFailure(
|
||||
"Timed out waiting for \(count) host-browser session(s); got \(factory.sessions.count)"
|
||||
)
|
||||
}
|
||||
await Task.yield()
|
||||
/// Waits for the attempt to create `count` browser sessions. The factory
|
||||
/// resumes this from the session it creates, so the wait costs no CPU while
|
||||
/// the attempt runs.
|
||||
func waitForSession(count: Int = 1, timeout: Duration = .seconds(30)) async {
|
||||
let watchdog = failAfterDeadline(timeout) { [factory] in
|
||||
"Timed out waiting for \(count) host-browser session(s); got \(factory.sessions.count)"
|
||||
}
|
||||
await factory.sessionsDidReach(count)
|
||||
watchdog.cancel()
|
||||
}
|
||||
|
||||
func waitForCondition(timeout: Duration = .seconds(2), until condition: @MainActor () -> Bool) async {
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: timeout)
|
||||
/// Waits until `condition` holds.
|
||||
///
|
||||
/// `condition` has to read observable state on the flow or the coordinator
|
||||
/// (both are `@Observable` and main-actor isolated). The wait registers with
|
||||
/// the observation system and suspends until one of the properties the
|
||||
/// condition read is written, then re-checks; a condition over unobserved
|
||||
/// state would never be woken and would hit the deadline below.
|
||||
func waitForCondition(timeout: Duration = .seconds(30), until condition: @MainActor () -> Bool) async {
|
||||
let watchdog = failAfterDeadline(timeout) {
|
||||
"Timed out waiting for host-browser condition; it must read observable flow or coordinator state"
|
||||
}
|
||||
while !condition() {
|
||||
if clock.now >= deadline {
|
||||
preconditionFailure("Timed out waiting for host-browser condition")
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
withObservationTracking {
|
||||
_ = condition()
|
||||
} onChange: {
|
||||
// Fires from the write itself, before the new value lands.
|
||||
// Resuming here queues the re-check as a separate main-actor
|
||||
// job, which cannot run until the write has finished.
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
await Task.yield()
|
||||
}
|
||||
watchdog.cancel()
|
||||
}
|
||||
|
||||
func waitForPendingUserRequest(timeout: Duration = .seconds(2)) async {
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: timeout)
|
||||
while await client.pendingUserRequests == 0 {
|
||||
if clock.now >= deadline {
|
||||
preconditionFailure("Timed out waiting for a pending user request")
|
||||
}
|
||||
await Task.yield()
|
||||
}
|
||||
/// Waits for a `currentUser` read to park on the closed user gate. The fake
|
||||
/// client resumes this as it parks.
|
||||
func waitForPendingUserRequest(timeout: Duration = .seconds(30)) async {
|
||||
let watchdog = failAfterDeadline(timeout) { "Timed out waiting for a pending user request" }
|
||||
await client.pendingUserRequestDidPark()
|
||||
watchdog.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
|
||||
/// End-to-end URL loading probe for mutating redirect behavior.
|
||||
final class PushRedirectURLProtocol: URLProtocol, @unchecked Sendable {
|
||||
enum Scenario: Sendable {
|
||||
case sameOrigin301
|
||||
case sameOrigin302
|
||||
case sameOrigin303
|
||||
case sameOrigin307
|
||||
case sameOrigin308
|
||||
case schemeDowngrade307
|
||||
case crossHost308
|
||||
case portChange302
|
||||
|
||||
var statusCode: Int {
|
||||
switch self {
|
||||
case .sameOrigin301:
|
||||
301
|
||||
case .sameOrigin302, .portChange302:
|
||||
302
|
||||
case .sameOrigin303:
|
||||
303
|
||||
case .sameOrigin307, .schemeDowngrade307:
|
||||
307
|
||||
case .sameOrigin308, .crossHost308:
|
||||
308
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static let state = PushRedirectState()
|
||||
static let startHost = "push-start.test"
|
||||
static let targetHost = "push-target.test"
|
||||
static let startPath = "/api/device-tokens"
|
||||
static let targetPath = "/canonical/device-tokens"
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
|
||||
override func startLoading() {
|
||||
Task {
|
||||
guard let url = request.url else {
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
return
|
||||
}
|
||||
let scenario = await Self.state.scenario
|
||||
if url.path == Self.startPath {
|
||||
let target: URL
|
||||
switch scenario {
|
||||
case .sameOrigin301, .sameOrigin302, .sameOrigin303,
|
||||
.sameOrigin307, .sameOrigin308:
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
|
||||
components.path = Self.targetPath
|
||||
target = components.url!
|
||||
case .schemeDowngrade307:
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
|
||||
components.scheme = "http"
|
||||
components.path = Self.targetPath
|
||||
target = components.url!
|
||||
case .crossHost308:
|
||||
target = URL(string: "https://\(Self.targetHost)\(Self.targetPath)")!
|
||||
case .portChange302:
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
|
||||
components.port = 444
|
||||
components.path = Self.targetPath
|
||||
target = components.url!
|
||||
}
|
||||
let status = scenario.statusCode
|
||||
let response = HTTPURLResponse(
|
||||
url: url,
|
||||
statusCode: status,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: ["Location": target.absoluteString]
|
||||
)!
|
||||
var proposed = URLRequest(url: target)
|
||||
proposed.httpMethod = [307, 308].contains(status) ? request.httpMethod : "GET"
|
||||
client?.urlProtocol(self, wasRedirectedTo: proposed, redirectResponse: response)
|
||||
return
|
||||
}
|
||||
|
||||
await Self.state.recordTarget(request)
|
||||
let response = HTTPURLResponse(
|
||||
url: url,
|
||||
statusCode: 200,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: nil
|
||||
)!
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: Data(#"{"ok":true}"#.utf8))
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
}
|
||||
|
||||
actor PushRedirectState {
|
||||
private(set) var scenario: PushRedirectURLProtocol.Scenario = .sameOrigin301
|
||||
private(set) var targetRequests: [URLRequest] = []
|
||||
|
||||
func reset(_ scenario: PushRedirectURLProtocol.Scenario) {
|
||||
self.scenario = scenario
|
||||
targetRequests = []
|
||||
}
|
||||
|
||||
func recordTarget(_ request: URLRequest) {
|
||||
targetRequests.append(request)
|
||||
}
|
||||
}
|
||||
+1296
-12
File diff suppressed because it is too large
Load Diff
+211
@@ -0,0 +1,211 @@
|
||||
import Foundation
|
||||
|
||||
private struct PushRegistrationLoadingContext: @unchecked Sendable {
|
||||
let loadingProtocol: PushRegistrationURLProtocol
|
||||
}
|
||||
|
||||
/// Scripted transport for push-registration lifecycle tests.
|
||||
///
|
||||
/// `URLProtocol` is configured by type, so one actor-backed script is shared by
|
||||
/// this serialized suite. The actor owns both the response queue and request
|
||||
/// capture, keeping test mutation out of process-global unsafe variables.
|
||||
final class PushRegistrationURLProtocol: URLProtocol, @unchecked Sendable {
|
||||
struct Stub: Sendable {
|
||||
let statusCode: Int?
|
||||
let headers: [String: String]
|
||||
let body: Data
|
||||
let error: URLError?
|
||||
let started: TestPhaseSignal?
|
||||
let blocker: TestContinuationBlocker?
|
||||
|
||||
static func response(
|
||||
_ statusCode: Int,
|
||||
headers: [String: String] = [:],
|
||||
json: String = #"{"ok":true}"#
|
||||
) -> Stub {
|
||||
Stub(
|
||||
statusCode: statusCode,
|
||||
headers: headers,
|
||||
body: Data(json.utf8),
|
||||
error: nil,
|
||||
started: nil,
|
||||
blocker: nil
|
||||
)
|
||||
}
|
||||
|
||||
static func gatedResponse(
|
||||
_ statusCode: Int,
|
||||
started: TestPhaseSignal,
|
||||
blocker: TestContinuationBlocker,
|
||||
headers: [String: String] = [:],
|
||||
json: String = #"{"ok":true}"#
|
||||
) -> Stub {
|
||||
Stub(
|
||||
statusCode: statusCode,
|
||||
headers: headers,
|
||||
body: Data(json.utf8),
|
||||
error: nil,
|
||||
started: started,
|
||||
blocker: blocker
|
||||
)
|
||||
}
|
||||
|
||||
static func failure(_ code: URLError.Code) -> Stub {
|
||||
Stub(
|
||||
statusCode: nil,
|
||||
headers: [:],
|
||||
body: Data(),
|
||||
error: URLError(code),
|
||||
started: nil,
|
||||
blocker: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static let script = PushRegistrationURLScript()
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
|
||||
override func startLoading() {
|
||||
let capturedRequest = request
|
||||
let capturedBody = Self.bodyData(from: capturedRequest)
|
||||
let stub = Self.script.take(capturedRequest, body: capturedBody)
|
||||
let context = PushRegistrationLoadingContext(
|
||||
loadingProtocol: self
|
||||
)
|
||||
if stub.error != nil {
|
||||
Task.detached { [capturedRequest, context] in
|
||||
await Task.yield()
|
||||
context.complete(stub, request: capturedRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard stub.started != nil || stub.blocker != nil else {
|
||||
context.complete(stub, request: capturedRequest)
|
||||
return
|
||||
}
|
||||
Task.detached { [capturedRequest, context] in
|
||||
await stub.started?.markStarted()
|
||||
await stub.blocker?.wait()
|
||||
context.complete(stub, request: capturedRequest)
|
||||
}
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
|
||||
private static func bodyData(from request: URLRequest) -> Data? {
|
||||
if let body = request.httpBody {
|
||||
return body
|
||||
}
|
||||
guard let stream = request.httpBodyStream else { return nil }
|
||||
stream.open()
|
||||
defer { stream.close() }
|
||||
var data = Data()
|
||||
let bufferSize = 1_024
|
||||
let buffer = UnsafeMutablePointer<UInt8>.allocate(
|
||||
capacity: bufferSize
|
||||
)
|
||||
defer { buffer.deallocate() }
|
||||
while stream.hasBytesAvailable {
|
||||
let count = stream.read(buffer, maxLength: bufferSize)
|
||||
if count <= 0 { break }
|
||||
data.append(buffer, count: count)
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
private extension PushRegistrationLoadingContext {
|
||||
func complete(
|
||||
_ stub: PushRegistrationURLProtocol.Stub,
|
||||
request: URLRequest
|
||||
) {
|
||||
if let error = stub.error {
|
||||
loadingProtocol.client?.urlProtocol(
|
||||
loadingProtocol,
|
||||
didFailWithError: error
|
||||
)
|
||||
return
|
||||
}
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: stub.statusCode ?? 500,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: stub.headers
|
||||
)!
|
||||
loadingProtocol.client?.urlProtocol(
|
||||
loadingProtocol,
|
||||
didReceive: response,
|
||||
cacheStoragePolicy: .notAllowed
|
||||
)
|
||||
if !stub.body.isEmpty {
|
||||
loadingProtocol.client?.urlProtocol(
|
||||
loadingProtocol,
|
||||
didLoad: stub.body
|
||||
)
|
||||
}
|
||||
loadingProtocol.client?.urlProtocolDidFinishLoading(
|
||||
loadingProtocol
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class PushRegistrationURLScript: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var stubs: [PushRegistrationURLProtocol.Stub] = []
|
||||
private var capturedRequests: [URLRequest] = []
|
||||
private var capturedBodies: [Data?] = []
|
||||
|
||||
var requests: [URLRequest] {
|
||||
get async {
|
||||
lock.withLock { capturedRequests }
|
||||
}
|
||||
}
|
||||
|
||||
var requestBodies: [Data?] {
|
||||
get async {
|
||||
lock.withLock { capturedBodies }
|
||||
}
|
||||
}
|
||||
|
||||
func waitForRequestCount(
|
||||
_ expectedCount: Int,
|
||||
timeout: Duration = .seconds(1)
|
||||
) async -> Bool {
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: timeout)
|
||||
while lock.withLock({ capturedRequests.count }) < expectedCount {
|
||||
guard clock.now < deadline else { return false }
|
||||
try? await clock.sleep(for: .milliseconds(1))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func reset(
|
||||
_ nextStubs: [PushRegistrationURLProtocol.Stub]
|
||||
) async {
|
||||
lock.withLock {
|
||||
stubs = nextStubs
|
||||
capturedRequests = []
|
||||
capturedBodies = []
|
||||
}
|
||||
}
|
||||
|
||||
func take(
|
||||
_ request: URLRequest,
|
||||
body: Data?
|
||||
) -> PushRegistrationURLProtocol.Stub {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
capturedRequests.append(request)
|
||||
capturedBodies.append(body)
|
||||
guard !stubs.isEmpty else {
|
||||
return .response(
|
||||
500,
|
||||
json: #"{"error":"unscripted_request"}"#
|
||||
)
|
||||
}
|
||||
return stubs.removeFirst()
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CmuxAuthRuntime
|
||||
|
||||
struct RedirectMethodPreservingDelegateTests {
|
||||
@Test(arguments: [
|
||||
("relative/path", "other/path"),
|
||||
("opaque:first", "opaque:second"),
|
||||
("file:///tmp/source", "file:///tmp/target"),
|
||||
("ws://example.test/source", "ws://example.test/target"),
|
||||
("wss://example.test/source", "wss://example.test/target"),
|
||||
("https://example.test/source", "https://other.test/target"),
|
||||
("https://example.test/source", "http://example.test/target"),
|
||||
("https://example.test/source", "https://example.test:8443/target"),
|
||||
("https://example.test/source", "https://sub.example.test/target"),
|
||||
])
|
||||
func nonHTTPOriginsFailClosed(
|
||||
source: String,
|
||||
target: String
|
||||
) throws {
|
||||
let sourceURL = try #require(URL(string: source))
|
||||
let targetURL = try #require(URL(string: target))
|
||||
|
||||
#expect(
|
||||
!RedirectMethodPreservingDelegate.sameOrigin(
|
||||
sourceURL,
|
||||
targetURL
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
("https://example.test/source", "https://example.test/target"),
|
||||
("https://example.test:443/source", "https://example.test/target"),
|
||||
("http://example.test:80/source", "http://EXAMPLE.test/target"),
|
||||
])
|
||||
func canonicalHTTPOriginsMatch(
|
||||
source: String,
|
||||
target: String
|
||||
) throws {
|
||||
let sourceURL = try #require(URL(string: source))
|
||||
let targetURL = try #require(URL(string: target))
|
||||
|
||||
#expect(
|
||||
RedirectMethodPreservingDelegate.sameOrigin(
|
||||
sourceURL,
|
||||
targetURL
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
/// Aborts the run with `message` unless the caller cancels the returned task
|
||||
/// first.
|
||||
///
|
||||
/// The waits this guards are event-driven: they suspend until the fake they
|
||||
/// wait on resumes them, so a run that reaches this deadline is one where the
|
||||
/// awaited edge never arrived. Reporting that by name beats leaving the run
|
||||
/// suspended forever. The deadline is generous on purpose — it never bounds a
|
||||
/// passing run, so machine load cannot push a healthy wait past it.
|
||||
@MainActor
|
||||
func failAfterDeadline(
|
||||
_ timeout: Duration,
|
||||
_ message: @escaping @MainActor () -> String
|
||||
) -> Task<Void, Never> {
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: timeout)
|
||||
guard !Task.isCancelled else { return }
|
||||
preconditionFailure(message())
|
||||
}
|
||||
}
|
||||
+43
-31
@@ -40,10 +40,49 @@ extension CmxIrohClientRuntime {
|
||||
throw CmxIrohClientRuntimeError.relayFleetMismatch
|
||||
}
|
||||
let revision = lifecycleRevision
|
||||
try await connectivityEngine.replaceRelayProfile(
|
||||
profile,
|
||||
expectedIdentity: binding.endpointID
|
||||
)
|
||||
|
||||
await relayCoordinator?.deactivate()
|
||||
relayCoordinator = nil
|
||||
if profile.source == .managed, !profile.allowedRelayURLs.isEmpty {
|
||||
let refreshSchedule = CmxIrohRelayRefreshSchedule(
|
||||
role: .client,
|
||||
endpointIdentity: binding.endpointID
|
||||
)
|
||||
let coordinator = CmxIrohRelayCredentialCoordinator(
|
||||
supervisor: connectivityEngine,
|
||||
broker: broker,
|
||||
managedRelayURLs: replacementManagedURLs,
|
||||
selectedRelayURLs: profile.allowedRelayURLs,
|
||||
jitter: { now, refreshAfter in
|
||||
refreshSchedule.deadline(now: now, refreshAfter: refreshAfter)
|
||||
},
|
||||
retrySchedule: .foregroundClient,
|
||||
automaticRefreshEnabled: automaticRelayCredentialRefreshEnabled,
|
||||
credentialDidInstall: { [handleRelayCredential] response in
|
||||
await handleRelayCredential(response, binding)
|
||||
}
|
||||
)
|
||||
relayCoordinator = coordinator
|
||||
do {
|
||||
try await coordinator.activateManagedPolicy(
|
||||
bindingID: binding.bindingID,
|
||||
endpointIdentity: binding.endpointID,
|
||||
profile: profile,
|
||||
bootstrap: relayBootstrap
|
||||
)
|
||||
} catch {
|
||||
await coordinator.deactivate()
|
||||
if relayCoordinator === coordinator {
|
||||
relayCoordinator = nil
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
try await connectivityEngine.replaceRelayProfile(
|
||||
profile,
|
||||
expectedIdentity: binding.endpointID
|
||||
)
|
||||
}
|
||||
try requireCurrent(revision)
|
||||
|
||||
managedRelayURLs = replacementManagedURLs
|
||||
@@ -98,32 +137,5 @@ extension CmxIrohClientRuntime {
|
||||
}
|
||||
await contextRouter.install(provider)
|
||||
try requireCurrent(revision)
|
||||
|
||||
await relayCoordinator?.deactivate()
|
||||
relayCoordinator = nil
|
||||
guard profile.source == .managed,
|
||||
!profile.allowedRelayURLs.isEmpty else { return }
|
||||
let coordinator = CmxIrohRelayCredentialCoordinator(
|
||||
supervisor: connectivityEngine,
|
||||
broker: broker,
|
||||
managedRelayURLs: replacementManagedURLs,
|
||||
selectedRelayURLs: profile.allowedRelayURLs,
|
||||
retrySchedule: .foregroundClient,
|
||||
automaticRefreshEnabled: automaticRelayCredentialRefreshEnabled,
|
||||
credentialDidInstall: { [handleRelayCredential] response in
|
||||
await handleRelayCredential(response, binding)
|
||||
}
|
||||
)
|
||||
relayCoordinator = coordinator
|
||||
do {
|
||||
try await coordinator.activate(
|
||||
bindingID: binding.bindingID,
|
||||
endpointIdentity: binding.endpointID,
|
||||
bootstrap: relayBootstrap
|
||||
)
|
||||
} catch {
|
||||
// The verified allowlist is already live; direct paths remain usable
|
||||
// while the coordinator retries a managed credential refresh.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+43
-31
@@ -42,42 +42,54 @@ extension CmxIrohHostRuntime {
|
||||
throw CmxIrohHostRuntimeError.relayFleetMismatch
|
||||
}
|
||||
let revision = lifecycleRevision
|
||||
try await connectivityEngine.replaceRelayProfile(
|
||||
profile,
|
||||
expectedIdentity: binding.endpointID
|
||||
)
|
||||
|
||||
relayActivationTask?.cancel()
|
||||
relayActivationTask = nil
|
||||
await relayCoordinator?.deactivate()
|
||||
relayCoordinator = nil
|
||||
if profile.source == .managed, !profile.allowedRelayURLs.isEmpty {
|
||||
let refreshSchedule = CmxIrohRelayRefreshSchedule(
|
||||
role: .host,
|
||||
endpointIdentity: binding.endpointID
|
||||
)
|
||||
let coordinator = CmxIrohRelayCredentialCoordinator(
|
||||
supervisor: connectivityEngine,
|
||||
broker: broker,
|
||||
managedRelayURLs: replacementManagedURLs,
|
||||
selectedRelayURLs: profile.allowedRelayURLs,
|
||||
jitter: { now, refreshAfter in
|
||||
refreshSchedule.deadline(now: now, refreshAfter: refreshAfter)
|
||||
},
|
||||
credentialDidInstall: { [handleRelayCredential] response in
|
||||
await handleRelayCredential(response, binding)
|
||||
}
|
||||
)
|
||||
relayCoordinator = coordinator
|
||||
do {
|
||||
try await coordinator.activateManagedPolicy(
|
||||
bindingID: binding.bindingID,
|
||||
endpointIdentity: binding.endpointID,
|
||||
profile: profile,
|
||||
bootstrap: relayBootstrap
|
||||
)
|
||||
} catch {
|
||||
await coordinator.deactivate()
|
||||
if relayCoordinator === coordinator {
|
||||
relayCoordinator = nil
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
try await connectivityEngine.replaceRelayProfile(
|
||||
profile,
|
||||
expectedIdentity: binding.endpointID
|
||||
)
|
||||
}
|
||||
try requireCurrent(revision)
|
||||
|
||||
managedRelayURLs = replacementManagedURLs
|
||||
currentEndpointRelayProfile = profile
|
||||
await admissionController?.updateManagedRelayURLs(replacementManagedURLs)
|
||||
try requireCurrent(revision)
|
||||
|
||||
relayActivationTask?.cancel()
|
||||
relayActivationTask = nil
|
||||
await relayCoordinator?.deactivate()
|
||||
relayCoordinator = nil
|
||||
guard profile.source == .managed,
|
||||
!profile.allowedRelayURLs.isEmpty else { return }
|
||||
let coordinator = CmxIrohRelayCredentialCoordinator(
|
||||
supervisor: connectivityEngine,
|
||||
broker: broker,
|
||||
managedRelayURLs: replacementManagedURLs,
|
||||
selectedRelayURLs: profile.allowedRelayURLs,
|
||||
credentialDidInstall: { [handleRelayCredential] response in
|
||||
await handleRelayCredential(response, binding)
|
||||
}
|
||||
)
|
||||
relayCoordinator = coordinator
|
||||
do {
|
||||
try await coordinator.activate(
|
||||
bindingID: binding.bindingID,
|
||||
endpointIdentity: binding.endpointID,
|
||||
bootstrap: relayBootstrap
|
||||
)
|
||||
} catch {
|
||||
// The verified allowlist is already live; direct paths remain usable
|
||||
// while the coordinator retries a managed credential refresh.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+144
-23
@@ -92,14 +92,10 @@ public actor CmxIrohRelayCredentialCoordinator {
|
||||
bootstrap: CmxIrohRelayTokenResponse? = nil,
|
||||
waitForInitialCredential: Bool = false
|
||||
) async throws {
|
||||
lifecycleRevision &+= 1
|
||||
let revision = lifecycleRevision
|
||||
refreshTask?.cancel()
|
||||
inFlightRefresh?.task.cancel()
|
||||
inFlightRefresh = nil
|
||||
let expectedBinding = Binding(id: bindingID, endpointIdentity: endpointIdentity)
|
||||
binding = expectedBinding
|
||||
installedCredential = nil
|
||||
let (expectedBinding, revision) = beginActivation(
|
||||
bindingID: bindingID,
|
||||
endpointIdentity: endpointIdentity
|
||||
)
|
||||
|
||||
if let bootstrap {
|
||||
do {
|
||||
@@ -167,6 +163,111 @@ public actor CmxIrohRelayCredentialCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces one live managed relay policy and starts credential refresh.
|
||||
///
|
||||
/// The coordinator owns the endpoint mutation so a policy bootstrap is
|
||||
/// installed exactly once. This preserves active QUIC sessions while the
|
||||
/// endpoint's relay client adopts the replacement credentials.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - bindingID: The broker binding that owns the endpoint.
|
||||
/// - endpointIdentity: The pinned endpoint identity being updated.
|
||||
/// - profile: The complete managed relay profile to install.
|
||||
/// - bootstrap: Credentials already represented by `profile`, when available.
|
||||
/// - Throws: A policy mismatch, endpoint mutation failure, or cancellation.
|
||||
public func activateManagedPolicy(
|
||||
bindingID: String,
|
||||
endpointIdentity: CmxIrohPeerIdentity,
|
||||
profile: CmxIrohEndpointRelayProfile,
|
||||
bootstrap: CmxIrohRelayTokenResponse?
|
||||
) async throws {
|
||||
guard profile.source == .managed,
|
||||
!selectedRelayURLs.isEmpty,
|
||||
selectedRelayURLs.isSubset(of: managedRelayURLs),
|
||||
profile.allowedRelayURLs == selectedRelayURLs else {
|
||||
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
|
||||
}
|
||||
|
||||
let bootstrapInstallation: (
|
||||
response: CmxIrohRelayTokenResponse,
|
||||
configurations: [CmxIrohRelayConfiguration]
|
||||
)? = try bootstrap.map { response in
|
||||
let selectedConfigurations = try validatedSelectedConfigurations(response)
|
||||
guard profile.managedRelays.count == selectedConfigurations.count,
|
||||
profile.managedRelays.allSatisfy(selectedConfigurations.contains) else {
|
||||
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
|
||||
}
|
||||
return (response, selectedConfigurations)
|
||||
}
|
||||
|
||||
let (expectedBinding, revision) = beginActivation(
|
||||
bindingID: bindingID,
|
||||
endpointIdentity: endpointIdentity
|
||||
)
|
||||
try await supervisor.replaceRelayProfile(
|
||||
profile,
|
||||
expectedIdentity: endpointIdentity
|
||||
)
|
||||
try Task.checkCancellation()
|
||||
guard isCurrent(revision), binding == expectedBinding else {
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
if let bootstrapInstallation {
|
||||
let installed = try recordInstallation(
|
||||
bootstrapInstallation.response,
|
||||
selectedConfigurations: bootstrapInstallation.configurations,
|
||||
binding: expectedBinding,
|
||||
revision: revision
|
||||
)
|
||||
startLoopIfEnabled(revision: revision, firstRefresh: installed.refreshAfter)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await broker.issueRelayToken(
|
||||
bindingID: bindingID,
|
||||
endpointID: endpointIdentity
|
||||
)
|
||||
let installed = try await install(
|
||||
response,
|
||||
binding: expectedBinding,
|
||||
revision: revision
|
||||
)
|
||||
startLoopIfEnabled(revision: revision, firstRefresh: installed.refreshAfter)
|
||||
} catch {
|
||||
guard isCurrent(revision), !Task.isCancelled else {
|
||||
throw CancellationError()
|
||||
}
|
||||
let delay = retryDelay(failureCount: 0, error: error)
|
||||
startLoopIfEnabled(
|
||||
revision: revision,
|
||||
firstRefresh: retryDeadline(
|
||||
now: clock.now(),
|
||||
backoff: delay,
|
||||
honorsServerFloor: (error as? any CmxRetryAfterProviding)?
|
||||
.retryAfterSeconds != nil
|
||||
),
|
||||
initialFailureCount: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func beginActivation(
|
||||
bindingID: String,
|
||||
endpointIdentity: CmxIrohPeerIdentity
|
||||
) -> (Binding, UInt64) {
|
||||
lifecycleRevision &+= 1
|
||||
let revision = lifecycleRevision
|
||||
refreshTask?.cancel()
|
||||
inFlightRefresh?.task.cancel()
|
||||
inFlightRefresh = nil
|
||||
let expectedBinding = Binding(id: bindingID, endpointIdentity: endpointIdentity)
|
||||
binding = expectedBinding
|
||||
installedCredential = nil
|
||||
return (expectedBinding, revision)
|
||||
}
|
||||
|
||||
private func installInitialCredentialAfterRetry(
|
||||
binding: Binding,
|
||||
revision: UInt64,
|
||||
@@ -415,27 +516,14 @@ public actor CmxIrohRelayCredentialCoordinator {
|
||||
guard isCurrent(revision), binding == expectedBinding else {
|
||||
throw CancellationError()
|
||||
}
|
||||
guard response.relayFleet.count == managedRelayURLs.count,
|
||||
Set(response.relayFleet) == managedRelayURLs else {
|
||||
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
|
||||
}
|
||||
let now = clock.now()
|
||||
let configurations = try response.relayConfigurations(now: now)
|
||||
let selectedConfigurations = configurations.filter {
|
||||
selectedRelayURLs.contains($0.url)
|
||||
}
|
||||
guard !selectedRelayURLs.isEmpty,
|
||||
selectedConfigurations.count == selectedRelayURLs.count,
|
||||
selectedRelayURLs.isSubset(of: managedRelayURLs) else {
|
||||
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
|
||||
}
|
||||
let selectedConfigurations = try validatedSelectedConfigurations(response)
|
||||
try Task.checkCancellation()
|
||||
guard isCurrent(revision), binding == expectedBinding else {
|
||||
throw CancellationError()
|
||||
}
|
||||
if selectedRelayURLs == managedRelayURLs {
|
||||
try await supervisor.replaceRelays(
|
||||
configurations,
|
||||
selectedConfigurations,
|
||||
expectedIdentity: expectedBinding.endpointIdentity
|
||||
)
|
||||
} else {
|
||||
@@ -448,6 +536,39 @@ public actor CmxIrohRelayCredentialCoordinator {
|
||||
expectedIdentity: expectedBinding.endpointIdentity
|
||||
)
|
||||
}
|
||||
return try recordInstallation(
|
||||
response,
|
||||
selectedConfigurations: selectedConfigurations,
|
||||
binding: expectedBinding,
|
||||
revision: revision
|
||||
)
|
||||
}
|
||||
|
||||
private func validatedSelectedConfigurations(
|
||||
_ response: CmxIrohRelayTokenResponse
|
||||
) throws -> [CmxIrohRelayConfiguration] {
|
||||
guard response.relayFleet.count == managedRelayURLs.count,
|
||||
Set(response.relayFleet) == managedRelayURLs else {
|
||||
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
|
||||
}
|
||||
let configurations = try response.relayConfigurations(now: clock.now())
|
||||
let selectedConfigurations = configurations.filter {
|
||||
selectedRelayURLs.contains($0.url)
|
||||
}
|
||||
guard !selectedRelayURLs.isEmpty,
|
||||
selectedConfigurations.count == selectedRelayURLs.count,
|
||||
selectedRelayURLs.isSubset(of: managedRelayURLs) else {
|
||||
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
|
||||
}
|
||||
return selectedConfigurations
|
||||
}
|
||||
|
||||
private func recordInstallation(
|
||||
_ response: CmxIrohRelayTokenResponse,
|
||||
selectedConfigurations: [CmxIrohRelayConfiguration],
|
||||
binding expectedBinding: Binding,
|
||||
revision: UInt64
|
||||
) throws -> InstalledCredential {
|
||||
try Task.checkCancellation()
|
||||
guard isCurrent(revision), binding == expectedBinding,
|
||||
let refreshAfter = selectedConfigurations.map(\.refreshAfter).min(),
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import CMUXMobileCore
|
||||
import Foundation
|
||||
|
||||
/// Assigns endpoint-stable, non-overlapping relay credential refresh slots.
|
||||
struct CmxIrohRelayRefreshSchedule: Sendable {
|
||||
enum Role: Sendable {
|
||||
case host
|
||||
case client
|
||||
|
||||
fileprivate var phaseStart: Int {
|
||||
switch self {
|
||||
case .host: 0
|
||||
case .client: 30
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let phaseWidth = 15
|
||||
private static let minuteDuration: TimeInterval = 60
|
||||
private static let fnvOffsetBasis: UInt64 = 14_695_981_039_346_656_037
|
||||
private static let fnvPrime: UInt64 = 1_099_511_628_211
|
||||
|
||||
private let secondWithinMinute: Int
|
||||
|
||||
init(role: Role, endpointIdentity: CmxIrohPeerIdentity) {
|
||||
var hash = Self.fnvOffsetBasis
|
||||
for byte in endpointIdentity.endpointID.utf8 {
|
||||
hash ^= UInt64(byte)
|
||||
hash &*= Self.fnvPrime
|
||||
}
|
||||
secondWithinMinute = role.phaseStart + Int(hash % UInt64(Self.phaseWidth))
|
||||
}
|
||||
|
||||
func deadline(now: Date, refreshAfter: Date) -> Date {
|
||||
let refreshEpoch = refreshAfter.timeIntervalSince1970
|
||||
let minuteStart = floor(refreshEpoch / Self.minuteDuration)
|
||||
* Self.minuteDuration
|
||||
var candidateEpoch = minuteStart + TimeInterval(secondWithinMinute)
|
||||
if candidateEpoch > refreshEpoch {
|
||||
candidateEpoch -= Self.minuteDuration
|
||||
}
|
||||
return min(
|
||||
refreshAfter,
|
||||
max(now, Date(timeIntervalSince1970: candidateEpoch))
|
||||
)
|
||||
}
|
||||
}
|
||||
+180
@@ -1,8 +1,147 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CmuxIrohTransport
|
||||
|
||||
@Suite
|
||||
struct CmxIrohCustomRelayRuntimeTests {
|
||||
@Test
|
||||
func clientManagedPolicyRefreshMutatesEndpointExactlyOnce() async throws {
|
||||
let fixture = try ClientRuntimeTestFixture()
|
||||
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
|
||||
let runtime = try CmxIrohClientRuntime(
|
||||
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
|
||||
broker: TestIrohClientBroker(
|
||||
binding: fixture.binding,
|
||||
discovery: fixture.discovery,
|
||||
relay: fixture.relayResponse()
|
||||
),
|
||||
configuration: fixture.configuration,
|
||||
pendingRevocations: fixture.pendingRevocations(),
|
||||
now: { fixture.now }
|
||||
)
|
||||
try await runtime.start()
|
||||
try await Self.waitForRelayMutation(endpoint)
|
||||
let initialCredentialUpdates = await endpoint.observedRelayUpdates().count
|
||||
let initialProfileUpdates = await endpoint.observedRelayProfileUpdates().count
|
||||
|
||||
try await runtime.replaceRelayPolicy(try Self.managedPolicy(
|
||||
response: fixture.relayResponse(),
|
||||
relayURLs: Set(ClientRuntimeTestFixture.relayURLs),
|
||||
now: fixture.now
|
||||
))
|
||||
|
||||
let credentialUpdates = await endpoint.observedRelayUpdates().count
|
||||
- initialCredentialUpdates
|
||||
let profileUpdates = await endpoint.observedRelayProfileUpdates().count
|
||||
- initialProfileUpdates
|
||||
#expect(credentialUpdates + profileUpdates == 1)
|
||||
#expect(await endpoint.observedCloseCallCount() == 0)
|
||||
#expect(await runtime.snapshot().endpointID == fixture.endpointID)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func hostManagedPolicyRefreshMutatesEndpointExactlyOnce() async throws {
|
||||
let fixture = try HostRuntimeFixture()
|
||||
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
|
||||
let runtime = CmxIrohHostRuntime(
|
||||
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
|
||||
broker: TestIrohHostBroker(
|
||||
registrationBinding: fixture.binding,
|
||||
discovery: fixture.discovery
|
||||
),
|
||||
configuration: fixture.configuration,
|
||||
pendingRevocations: fixture.pendingRevocations(),
|
||||
now: { Date(timeIntervalSince1970: 1_800_000_000) },
|
||||
handleTransport: { session, _ in await session.close() }
|
||||
)
|
||||
try await runtime.start()
|
||||
try await Self.waitForRelayMutation(endpoint)
|
||||
let initialCredentialUpdates = await endpoint.observedRelayUpdates().count
|
||||
let initialProfileUpdates = await endpoint.observedRelayProfileUpdates().count
|
||||
let response = try ClientRuntimeTestFixture().relayResponse()
|
||||
|
||||
try await runtime.replaceRelayPolicy(try Self.managedPolicy(
|
||||
response: response,
|
||||
relayURLs: fixture.managedRelays,
|
||||
now: Date(timeIntervalSince1970: 1_800_000_000)
|
||||
))
|
||||
|
||||
let credentialUpdates = await endpoint.observedRelayUpdates().count
|
||||
- initialCredentialUpdates
|
||||
let profileUpdates = await endpoint.observedRelayProfileUpdates().count
|
||||
- initialProfileUpdates
|
||||
#expect(credentialUpdates + profileUpdates == 1)
|
||||
#expect(await endpoint.observedCloseCallCount() == 0)
|
||||
#expect(await runtime.snapshot().endpointID == fixture.endpointID)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func clientManagedPolicyFailureDeactivatesUncommittedCoordinator() async throws {
|
||||
let fixture = try ClientRuntimeTestFixture()
|
||||
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
|
||||
let runtime = try CmxIrohClientRuntime(
|
||||
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
|
||||
broker: TestIrohClientBroker(
|
||||
binding: fixture.binding,
|
||||
discovery: fixture.discovery,
|
||||
relay: fixture.relayResponse()
|
||||
),
|
||||
configuration: fixture.configuration,
|
||||
pendingRevocations: fixture.pendingRevocations(),
|
||||
now: { fixture.now }
|
||||
)
|
||||
try await runtime.start()
|
||||
try await Self.waitForRelayMutation(endpoint)
|
||||
await endpoint.setRelayUpdateShouldFail(true)
|
||||
|
||||
await #expect(throws: TestIrohTransportError.relayUpdateFailed) {
|
||||
try await runtime.replaceRelayPolicy(try Self.managedPolicy(
|
||||
response: fixture.relayResponse(),
|
||||
relayURLs: Set(ClientRuntimeTestFixture.relayURLs),
|
||||
now: fixture.now
|
||||
))
|
||||
}
|
||||
|
||||
#expect(await runtime.relayCoordinator == nil)
|
||||
#expect(await endpoint.observedCloseCallCount() == 0)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func hostManagedPolicyFailureDeactivatesUncommittedCoordinator() async throws {
|
||||
let fixture = try HostRuntimeFixture()
|
||||
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
|
||||
let runtime = CmxIrohHostRuntime(
|
||||
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
|
||||
broker: TestIrohHostBroker(
|
||||
registrationBinding: fixture.binding,
|
||||
discovery: fixture.discovery
|
||||
),
|
||||
configuration: fixture.configuration,
|
||||
pendingRevocations: fixture.pendingRevocations(),
|
||||
now: { Date(timeIntervalSince1970: 1_800_000_000) },
|
||||
handleTransport: { session, _ in await session.close() }
|
||||
)
|
||||
try await runtime.start()
|
||||
try await Self.waitForRelayMutation(endpoint)
|
||||
await endpoint.setRelayUpdateShouldFail(true)
|
||||
let response = try ClientRuntimeTestFixture().relayResponse()
|
||||
|
||||
await #expect(throws: TestIrohTransportError.relayUpdateFailed) {
|
||||
try await runtime.replaceRelayPolicy(try Self.managedPolicy(
|
||||
response: response,
|
||||
relayURLs: fixture.managedRelays,
|
||||
now: Date(timeIntervalSince1970: 1_800_000_000)
|
||||
))
|
||||
}
|
||||
|
||||
#expect(await runtime.relayCoordinator == nil)
|
||||
#expect(await endpoint.observedCloseCallCount() == 0)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func clientOverrideSkipsManagedTokenIssuance() async throws {
|
||||
let fixture = try ClientRuntimeTestFixture()
|
||||
@@ -153,4 +292,45 @@ struct CmxIrohCustomRelayRuntimeTests {
|
||||
#expect(await runtime.snapshot().endpointID == fixture.endpointID)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
private static func managedPolicy(
|
||||
response: CmxIrohRelayTokenResponse,
|
||||
relayURLs: Set<String>,
|
||||
now: Date
|
||||
) throws -> CmxIrohEffectiveRelayPolicy {
|
||||
let profile = try CmxIrohEndpointRelayProfile(
|
||||
managedRelayURLs: relayURLs,
|
||||
relays: response.relayConfigurations(now: now)
|
||||
)
|
||||
return CmxIrohEffectiveRelayPolicy(
|
||||
endpointRelayProfile: profile,
|
||||
managedSnapshot: nil,
|
||||
managedPolicy: nil,
|
||||
requestedConfiguration: nil,
|
||||
effectivePreference: .automatic,
|
||||
source: .managed,
|
||||
usedCachedPolicy: false,
|
||||
preferenceRevision: nil,
|
||||
relayBootstrap: response
|
||||
)
|
||||
}
|
||||
|
||||
private static func waitForRelayMutation(_ endpoint: TestIrohEndpoint) async throws {
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: .seconds(1))
|
||||
while clock.now < deadline {
|
||||
let credentialUpdates = await endpoint.observedRelayUpdates().count
|
||||
let profileUpdates = await endpoint.observedRelayProfileUpdates().count
|
||||
if credentialUpdates + profileUpdates > 0 { return }
|
||||
await Task.yield()
|
||||
}
|
||||
let credentialUpdates = await endpoint.observedRelayUpdates().count
|
||||
let profileUpdates = await endpoint.observedRelayProfileUpdates().count
|
||||
let counts = "credential updates: \(credentialUpdates), "
|
||||
+ "profile updates: \(profileUpdates)"
|
||||
Issue.record("Timed out waiting for relay mutation (\(counts))")
|
||||
throw RelayMutationTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
private struct RelayMutationTimeout: Error {}
|
||||
|
||||
+78
@@ -5,6 +5,84 @@ import Testing
|
||||
|
||||
@Suite
|
||||
struct CmxIrohRelayCredentialCoordinatorTests {
|
||||
@Test
|
||||
func hostAndClientRefreshSlotsStaySeparatedAcrossCredentialCycles() throws {
|
||||
let hostIdentity = try CmxIrohPeerIdentity(
|
||||
endpointID: String(repeating: "1a", count: 32)
|
||||
)
|
||||
let clientIdentity = try CmxIrohPeerIdentity(
|
||||
endpointID: String(repeating: "b7", count: 32)
|
||||
)
|
||||
let hostSchedule = CmxIrohRelayRefreshSchedule(
|
||||
role: .host,
|
||||
endpointIdentity: hostIdentity
|
||||
)
|
||||
let clientSchedule = CmxIrohRelayRefreshSchedule(
|
||||
role: .client,
|
||||
endpointIdentity: clientIdentity
|
||||
)
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
var hostSeconds: [Int] = []
|
||||
var clientSeconds: [Int] = []
|
||||
|
||||
for cycle in 1 ... 8 {
|
||||
let refreshAfter = now.addingTimeInterval(TimeInterval(cycle * 240))
|
||||
let hostDeadline = hostSchedule.deadline(
|
||||
now: now,
|
||||
refreshAfter: refreshAfter
|
||||
)
|
||||
let clientDeadline = clientSchedule.deadline(
|
||||
now: now,
|
||||
refreshAfter: refreshAfter
|
||||
)
|
||||
let hostSecond = Int(hostDeadline.timeIntervalSince1970) % 60
|
||||
let clientSecond = Int(clientDeadline.timeIntervalSince1970) % 60
|
||||
hostSeconds.append(hostSecond)
|
||||
clientSeconds.append(clientSecond)
|
||||
|
||||
#expect((0 ... 14).contains(hostSecond))
|
||||
#expect((30 ... 44).contains(clientSecond))
|
||||
#expect(hostDeadline >= now)
|
||||
#expect(clientDeadline >= now)
|
||||
#expect(hostDeadline <= refreshAfter)
|
||||
#expect(clientDeadline <= refreshAfter)
|
||||
}
|
||||
|
||||
#expect(Set(hostSeconds).count == 1)
|
||||
#expect(Set(clientSeconds).count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func refreshSlotsSpreadEndpointsWithinEachRole() throws {
|
||||
let refreshAfter = Date(timeIntervalSince1970: 1_700_000_240)
|
||||
let now = refreshAfter.addingTimeInterval(-240)
|
||||
let hostSlots = try (0 ..< 16).map { index in
|
||||
let identity = try CmxIrohPeerIdentity(
|
||||
endpointID: String(format: "%064x", index + 1)
|
||||
)
|
||||
return Int(
|
||||
CmxIrohRelayRefreshSchedule(role: .host, endpointIdentity: identity)
|
||||
.deadline(now: now, refreshAfter: refreshAfter)
|
||||
.timeIntervalSince1970
|
||||
) % 60
|
||||
}
|
||||
let clientSlots = try (0 ..< 16).map { index in
|
||||
let identity = try CmxIrohPeerIdentity(
|
||||
endpointID: String(format: "%064x", index + 1)
|
||||
)
|
||||
return Int(
|
||||
CmxIrohRelayRefreshSchedule(role: .client, endpointIdentity: identity)
|
||||
.deadline(now: now, refreshAfter: refreshAfter)
|
||||
.timeIntervalSince1970
|
||||
) % 60
|
||||
}
|
||||
|
||||
#expect(Set(hostSlots).count > 1)
|
||||
#expect(hostSlots.allSatisfy { (0 ... 14).contains($0) })
|
||||
#expect(Set(clientSlots).count > 1)
|
||||
#expect(clientSlots.allSatisfy { (30 ... 44).contains($0) })
|
||||
}
|
||||
|
||||
@Test
|
||||
func bootstrapInstallsCompleteFleetBeforeSleepingUntilRefresh() async throws {
|
||||
let fixture = try RelayCoordinatorFixture()
|
||||
|
||||
+11
-48
@@ -66,10 +66,10 @@ public struct ScrubberDenylists: Sendable {
|
||||
"bearer",
|
||||
]
|
||||
|
||||
/// Short or marker-free credential key aliases matched WHOLE (not as
|
||||
/// substrings), so they don't redact innocuous keys that merely contain them
|
||||
/// (e.g. `sid` must not match `inside`/`aside`; `su` must not match
|
||||
/// `issue`/`consumer`).
|
||||
/// Short or marker-free credential key aliases matched as a whole key or a
|
||||
/// punctuation-delimited key component (not as substrings), so they don't
|
||||
/// redact innocuous keys that merely contain them (e.g. `sid` must not match
|
||||
/// `inside`/`aside`; `su` must not match `issue`/`consumer`).
|
||||
///
|
||||
/// These are stored **already normalized** the same way
|
||||
/// `SentryScrubber.isSensitiveKey(_:)` normalizes (lowercase, strip
|
||||
@@ -122,12 +122,13 @@ public struct ScrubberDenylists: Sendable {
|
||||
/// prefix in the output so the redacted field stays legible; patterns with no
|
||||
/// capture group replace the whole match with the secret placeholder.
|
||||
///
|
||||
/// The first seven entries are cmux-original and partly exceed relay (relay
|
||||
/// has no provider-prefix, JWT, or AWS-access-key rule); they are kept
|
||||
/// verbatim, including their exact `options:` (JWT and AWS are
|
||||
/// case-sensitive on purpose). The trailing entries are the relay `@common`
|
||||
/// value rules cmux was missing, ported with no capture group so cmux's
|
||||
/// "group 1 is a prefix to keep" loop redacts the whole match.
|
||||
/// The first five entries are cmux-original and partly exceed relay (relay
|
||||
/// has no provider-prefix, JWT, or AWS-access-key rule); JWT and AWS remain
|
||||
/// case-sensitive on purpose. Sensitive `key=value` assignments are not
|
||||
/// regexes here: ``SentryScrubber`` parses them with a single-pass scanner
|
||||
/// so marker-rich HTTP queries cannot trigger regex backtracking. The
|
||||
/// trailing entries are relay `@common` value rules, ported with no capture
|
||||
/// group so cmux redacts the whole match.
|
||||
///
|
||||
/// Upstream for the relay-ported entries:
|
||||
/// getsentry/relay @ 99c91d92845fe436713b51018a7f8d2b7b469be5,
|
||||
@@ -146,44 +147,6 @@ public struct ScrubberDenylists: Sendable {
|
||||
SentryRegexPattern(#"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"#),
|
||||
// Authorization: <scheme> <token> (Basic / Digest / token / etc.)
|
||||
SentryRegexPattern(#"(Authorization:\s*\w+\s+)\S+"#),
|
||||
// `<sensitive-key> = value` in raw query strings, env-style assignments,
|
||||
// or JSON ("key":"value"). The marker set is kept in sync with the
|
||||
// key-aware dictionary path (``SentryScrubber/isSensitiveKey(_:)``) so a
|
||||
// credential like `auth=…`, `session_id=…`, or `cookie=…` is redacted
|
||||
// whether it arrives as a dictionary entry or as raw text. The marker may
|
||||
// be embedded in a longer identifier (e.g. AWS_SECRET_ACCESS_KEY,
|
||||
// MY_API_KEY), so optional identifier characters are allowed around it.
|
||||
// Conceptually the relay PASSWORD_KEY_REGEX / TOKEN_KEY_REGEX analogue
|
||||
// (relay-pii/src/regexes.rs:341-345), hand-tuned for cmux's free text.
|
||||
//
|
||||
// Split into a QUOTED-value rule and an UNQUOTED-value rule because the
|
||||
// value terminator is quote-context-dependent: an unquoted query value
|
||||
// MUST stop at `&`/`,`/`}` (`?token=X&page=2` has to keep `&page=2`),
|
||||
// but those same characters can legitimately appear INSIDE a quoted JSON
|
||||
// value (`"password":"a&b"`), where stopping early would leak the
|
||||
// suffix. The quoted rule captures the opening value quote in its prefix
|
||||
// group and consumes `[^"']*` (everything up to the closing quote); the
|
||||
// unquoted rule keeps the delimiter-bounded `[^\s"'&,}]+`. It does not
|
||||
// attempt to model escaped quotes inside a quoted value (a regex JSON
|
||||
// parser); the structured dict-key layer plus the wholesale Data/user/
|
||||
// cookie drops are the real boundary, this free-text pass is best-effort
|
||||
// defense-in-depth for raw event messages / stack lines / breadcrumbs.
|
||||
// Quoted-value form: redact through the closing quote.
|
||||
SentryRegexPattern(
|
||||
#"([A-Za-z0-9.\-]*(?:access[_\-]?token|api[_\-]?key|access[_\-]?key|private[_\-]?key|session[_\-]?id|session|secret|token|password|passwd|pwd|credentials?|cookie|bearer|auth)[A-Za-z0-9.\-]*["']?\s*[:=]\s*["'])[^"']*"#
|
||||
),
|
||||
// The bare `sid` session alias (`?sid=…`, `&sid=…`, `sid:…`) carries a
|
||||
// session credential but is too short to embed in the marker set above
|
||||
// without matching innocuous substrings (`inside=`, `aside=`). A `\b`
|
||||
// word boundary anchors it so only a standalone `sid` key is redacted.
|
||||
// Quoted-value form (same quote-context split as the marker rule above).
|
||||
SentryRegexPattern(#"(\bsid["']?\s*[:=]\s*["'])[^"']*"#),
|
||||
// Unquoted-value form of the marker rule: delimiter-bounded value.
|
||||
SentryRegexPattern(
|
||||
#"([A-Za-z0-9.\-]*(?:access[_\-]?token|api[_\-]?key|access[_\-]?key|private[_\-]?key|session[_\-]?id|session|secret|token|password|passwd|pwd|credentials?|cookie|bearer|auth)[A-Za-z0-9.\-]*["']?\s*[:=]\s*)[^\s"'&,}]+"#
|
||||
),
|
||||
// Unquoted-value form of the bare `sid` alias.
|
||||
SentryRegexPattern(#"(\bsid["']?\s*[:=]\s*)[^\s"'&,}]+"#),
|
||||
// Provider-style keys: sk-..., pk-..., ghp_..., xoxb-..., and similar
|
||||
// prefixes. No relay equivalent — cmux-specific add for dev secrets.
|
||||
SentryRegexPattern(#"\b(?:sk|pk|rk|ghp|gho|ghu|ghs|ghr|xox[baprs])[_\-][A-Za-z0-9_\-]{16,}"#),
|
||||
|
||||
+229
-8
@@ -289,8 +289,8 @@ public struct SentryScrubber: Sendable {
|
||||
/// strings: adding a denylist key now covers query params automatically, with
|
||||
/// no parallel free-text marker list to drift out of sync. The denylist's
|
||||
/// EXACT-match aliases (`csrf`, `_csrf`, `xsrf`, `_vercel_jwt`, `su`,
|
||||
/// `sentrysid`, `phpsessid`, `sid`, …) are therefore caught here even though
|
||||
/// they are too short to embed safely in the free-text assignment regex.
|
||||
/// `sentrysid`, `phpsessid`, `sid`, …) are therefore caught here and by the
|
||||
/// free-text assignment scanner without ambiguous regex matching.
|
||||
///
|
||||
/// The key is URL-decoded before the sensitivity check (so `%5Fcsrf` matches
|
||||
/// `_csrf`) but the original, still-encoded key text is emitted unchanged.
|
||||
@@ -336,15 +336,21 @@ public struct SentryScrubber: Sendable {
|
||||
/// - Parameter key: The dictionary or header key.
|
||||
/// - Returns: `true` when the key's value should be redacted wholesale.
|
||||
static func isSensitiveKey(_ key: String) -> Bool {
|
||||
let normalized = key.lowercased().replacingOccurrences(of: "-", with: "")
|
||||
.replacingOccurrences(of: "_", with: "")
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
let normalized = normalizedSensitiveKey(key)
|
||||
if sensitiveKeyExactMarkers.contains(normalized) {
|
||||
return true
|
||||
}
|
||||
for marker in sensitiveKeyMarkers where normalized.contains(marker) {
|
||||
return true
|
||||
}
|
||||
// Exact aliases such as `sid` and `su` may appear as a leaf after a
|
||||
// punctuation boundary (`x-sid`, `vendor.sid`). Match whole components
|
||||
// without regressing to substring behavior that would redact `inside`.
|
||||
for component in key.split(whereSeparator: { !$0.isLetter && !$0.isNumber }) {
|
||||
if sensitiveKeyExactMarkers.contains(normalizedSensitiveKey(String(component))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -359,7 +365,7 @@ public struct SentryScrubber: Sendable {
|
||||
/// Short or marker-free credential key aliases matched WHOLE (not as
|
||||
/// substrings), so they don't redact innocuous keys that merely contain them
|
||||
/// (e.g. `sid` must not match `inside`/`aside`). The free-text scrubber
|
||||
/// covers their `key=value` form via a `\b`-anchored pattern.
|
||||
/// covers their `key=value` form through the shared key check.
|
||||
///
|
||||
/// Sourced from ``ScrubberDenylists/sensitiveKeyExactMarkers`` (ported from
|
||||
/// sentry-python `DEFAULT_DENYLIST` + `DEFAULT_PII_DENYLIST` and relay's
|
||||
@@ -406,7 +412,8 @@ public struct SentryScrubber: Sendable {
|
||||
|
||||
/// Replaces email addresses with ``redactedEmail``.
|
||||
private func redactEmails(in text: String) -> String {
|
||||
Self.email.replace(in: text) { _ in Self.redactedEmail }
|
||||
guard text.contains("@") else { return text }
|
||||
return Self.email.replace(in: text) { _ in Self.redactedEmail }
|
||||
}
|
||||
|
||||
// MARK: - Secrets
|
||||
@@ -424,6 +431,220 @@ public struct SentryScrubber: Sendable {
|
||||
return Self.redactedSecret
|
||||
}
|
||||
}
|
||||
return result
|
||||
return redactSensitiveAssignments(in: result)
|
||||
}
|
||||
|
||||
/// Redacts sensitive `key=value` and `key: value` assignments in one pass.
|
||||
///
|
||||
/// The previous regex family placed unbounded key quantifiers around a
|
||||
/// sensitive-marker alternation. Marker-rich HTTP queries without a later
|
||||
/// assignment delimiter made ICU reconsider the same suffix repeatedly,
|
||||
/// blocking the thread that added a Sentry breadcrumb. This scanner visits
|
||||
/// the input in linear time, reuses the structured key denylist, and
|
||||
/// understands escaped quotes in JSON-style values.
|
||||
private func redactSensitiveAssignments(in text: String) -> String {
|
||||
let utf8 = text.utf8
|
||||
guard utf8.contains(0x3A) || utf8.contains(0x3D) else { return text }
|
||||
|
||||
if let result = utf8.withContiguousStorageIfAvailable({ bytes in
|
||||
redactSensitiveAssignments(in: text, bytes: bytes)
|
||||
}) {
|
||||
return result
|
||||
}
|
||||
|
||||
return redactSensitiveAssignments(in: text, bytes: Array(utf8))
|
||||
}
|
||||
|
||||
private func redactSensitiveAssignments<Bytes: RandomAccessCollection>(
|
||||
in text: String,
|
||||
bytes: Bytes
|
||||
) -> String where Bytes.Element == UInt8, Bytes.Index == Int {
|
||||
var replacementRanges: [Range<Int>] = []
|
||||
var index = 0
|
||||
while index < bytes.count {
|
||||
guard bytes[index] == 0x3A || bytes[index] == 0x3D else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
guard let range = sensitiveAssignmentValueRange(
|
||||
delimiterIndex: index,
|
||||
bytes: bytes
|
||||
) else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
replacementRanges.append(range)
|
||||
index = max(index + 1, range.upperBound)
|
||||
}
|
||||
|
||||
guard !replacementRanges.isEmpty else { return text }
|
||||
let replacement = Array(Self.redactedSecret.utf8)
|
||||
var output: [UInt8] = []
|
||||
output.reserveCapacity(bytes.count)
|
||||
var copiedThrough = 0
|
||||
for range in replacementRanges {
|
||||
output.append(contentsOf: bytes[copiedThrough ..< range.lowerBound])
|
||||
output.append(contentsOf: replacement)
|
||||
copiedThrough = range.upperBound
|
||||
}
|
||||
output.append(contentsOf: bytes[copiedThrough...])
|
||||
return String(decoding: output, as: UTF8.self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the value bytes belonging to a sensitive assignment delimiter.
|
||||
private func sensitiveAssignmentValueRange<Bytes: RandomAccessCollection>(
|
||||
delimiterIndex: Int,
|
||||
bytes: Bytes
|
||||
) -> Range<Int>? where Bytes.Element == UInt8, Bytes.Index == Int {
|
||||
guard delimiterIndex > 0 else { return nil }
|
||||
|
||||
var keyCursor = delimiterIndex - 1
|
||||
while keyCursor >= 0, isASCIIWhitespace(bytes[keyCursor]) {
|
||||
keyCursor -= 1
|
||||
}
|
||||
if keyCursor >= 0, isQuote(bytes[keyCursor]) {
|
||||
keyCursor -= 1
|
||||
while keyCursor >= 0, isASCIIWhitespace(bytes[keyCursor]) {
|
||||
keyCursor -= 1
|
||||
}
|
||||
}
|
||||
guard keyCursor >= 0 else { return nil }
|
||||
|
||||
let keyEnd = keyCursor + 1
|
||||
while keyCursor >= 0, isAssignmentKeyByte(bytes[keyCursor]) {
|
||||
keyCursor -= 1
|
||||
}
|
||||
let keyStart = keyCursor + 1
|
||||
guard keyStart < keyEnd else { return nil }
|
||||
let key = String(decoding: bytes[keyStart ..< keyEnd], as: UTF8.self)
|
||||
guard SentryScrubber.isSensitiveKey(key) else { return nil }
|
||||
|
||||
var valueStart = delimiterIndex + 1
|
||||
while valueStart < bytes.count, isASCIIWhitespace(bytes[valueStart]) {
|
||||
valueStart += 1
|
||||
}
|
||||
guard valueStart < bytes.count else { return nil }
|
||||
|
||||
if isQuote(bytes[valueStart]) {
|
||||
let quote = bytes[valueStart]
|
||||
valueStart += 1
|
||||
var valueEnd = valueStart
|
||||
while valueEnd < bytes.count {
|
||||
if bytes[valueEnd] == quote {
|
||||
break
|
||||
}
|
||||
if bytes[valueEnd] == 0x5C, valueEnd + 1 < bytes.count {
|
||||
valueEnd += 2
|
||||
} else {
|
||||
valueEnd += 1
|
||||
}
|
||||
}
|
||||
guard valueStart < valueEnd else { return nil }
|
||||
return valueStart ..< valueEnd
|
||||
}
|
||||
|
||||
if isAuthorizationAssignmentKey(key) {
|
||||
let firstTokenStart = valueStart
|
||||
while valueStart < bytes.count,
|
||||
!isUnquotedValueTerminator(at: valueStart, in: bytes)
|
||||
{
|
||||
valueStart += 1
|
||||
}
|
||||
var secondTokenStart = valueStart
|
||||
while secondTokenStart < bytes.count, isASCIIWhitespace(bytes[secondTokenStart]) {
|
||||
secondTokenStart += 1
|
||||
}
|
||||
if secondTokenStart < bytes.count,
|
||||
!isUnquotedValueTerminator(at: secondTokenStart, in: bytes)
|
||||
{
|
||||
valueStart = secondTokenStart
|
||||
} else {
|
||||
valueStart = firstTokenStart
|
||||
}
|
||||
}
|
||||
|
||||
var valueEnd = valueStart
|
||||
while valueEnd < bytes.count,
|
||||
!isUnquotedValueTerminator(at: valueEnd, in: bytes)
|
||||
{
|
||||
valueEnd += 1
|
||||
}
|
||||
guard valueStart < valueEnd else { return nil }
|
||||
return valueStart ..< valueEnd
|
||||
}
|
||||
|
||||
private func normalizedSensitiveKey(_ key: String) -> String {
|
||||
key.lowercased().replacingOccurrences(of: "-", with: "")
|
||||
.replacingOccurrences(of: "_", with: "")
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
}
|
||||
|
||||
private func isAuthorizationAssignmentKey(_ key: String) -> Bool {
|
||||
let normalized = normalizedSensitiveKey(key)
|
||||
return normalized == "authorization" || normalized == "proxyauthorization"
|
||||
}
|
||||
|
||||
private func isAssignmentKeyByte(_ byte: UInt8) -> Bool {
|
||||
(byte >= 0x30 && byte <= 0x39)
|
||||
|| (byte >= 0x41 && byte <= 0x5A)
|
||||
|| (byte >= 0x61 && byte <= 0x7A)
|
||||
|| byte == 0x2D
|
||||
|| byte == 0x2E
|
||||
|| byte == 0x5F
|
||||
}
|
||||
|
||||
private func isASCIIWhitespace(_ byte: UInt8) -> Bool {
|
||||
byte == 0x20 || byte == 0x09 || byte == 0x0A || byte == 0x0D
|
||||
}
|
||||
|
||||
private func isQuote(_ byte: UInt8) -> Bool {
|
||||
byte == 0x22 || byte == 0x27
|
||||
}
|
||||
|
||||
private func isUnquotedValueTerminator<Bytes: RandomAccessCollection>(
|
||||
at index: Int,
|
||||
in bytes: Bytes
|
||||
) -> Bool where Bytes.Element == UInt8, Bytes.Index == Int {
|
||||
let byte = bytes[index]
|
||||
if isASCIIWhitespace(byte) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Query and environment separators are also valid credential bytes. Only
|
||||
// preserve one when the suffix proves that a new assignment starts there;
|
||||
// otherwise redact through it so no secret suffix can escape.
|
||||
if byte == 0x26 || byte == 0x2C || byte == 0x3B {
|
||||
return startsAssignment(after: index, in: bytes)
|
||||
}
|
||||
|
||||
// Quotes and closing delimiters are structural only at a visible boundary.
|
||||
// Embedded punctuation such as `token=abc)def` remains part of the value.
|
||||
if isQuote(byte) || byte == 0x29 || byte == 0x5D || byte == 0x7D {
|
||||
let nextIndex = index + 1
|
||||
return nextIndex == bytes.count
|
||||
|| isASCIIWhitespace(bytes[nextIndex])
|
||||
|| startsAssignment(after: index, in: bytes)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func startsAssignment<Bytes: RandomAccessCollection>(
|
||||
after separatorIndex: Int,
|
||||
in bytes: Bytes
|
||||
) -> Bool where Bytes.Element == UInt8, Bytes.Index == Int {
|
||||
var cursor = separatorIndex + 1
|
||||
while cursor < bytes.count, isASCIIWhitespace(bytes[cursor]) {
|
||||
cursor += 1
|
||||
}
|
||||
let keyStart = cursor
|
||||
while cursor < bytes.count, isAssignmentKeyByte(bytes[cursor]) {
|
||||
cursor += 1
|
||||
}
|
||||
guard cursor > keyStart else { return false }
|
||||
while cursor < bytes.count, isASCIIWhitespace(bytes[cursor]) {
|
||||
cursor += 1
|
||||
}
|
||||
return cursor < bytes.count && (bytes[cursor] == 0x3A || bytes[cursor] == 0x3D)
|
||||
}
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ import Testing
|
||||
("creds AKIAIOSFODNN7EXAMPLE rejected", "creds <redacted-secret> rejected"),
|
||||
// Quoted JSON values whose secret contains a delimiter (`&`, `,`, `}`)
|
||||
// must redact through the CLOSING quote, not stop at the delimiter and
|
||||
// leak the tail (the quote-context split). These are the exact shapes a
|
||||
// leak the tail. These are the exact shapes a
|
||||
// raw event message / breadcrumb / NSError description arrives as.
|
||||
(#"{"password":"abc&def"}"#, #"{"password":"<redacted-secret>"}"#),
|
||||
(#"{"token":"a,b,c"}"#, #"{"token":"<redacted-secret>"}"#),
|
||||
|
||||
+77
-3
@@ -67,6 +67,21 @@ import Testing
|
||||
)
|
||||
}
|
||||
|
||||
@Test func redactsAuthorizationAssignmentsWithAndWithoutSchemes() {
|
||||
#expect(
|
||||
scrubber.scrub("Authorization: Basic dXNlcjpwYXNz")
|
||||
== "Authorization: Basic <redacted-secret>"
|
||||
)
|
||||
#expect(
|
||||
scrubber.scrub("Authorization: opaquetoken")
|
||||
== "Authorization: <redacted-secret>"
|
||||
)
|
||||
#expect(
|
||||
scrubber.scrub("Proxy-Authorization=Digest opaquetoken")
|
||||
== "Proxy-Authorization=Digest <redacted-secret>"
|
||||
)
|
||||
}
|
||||
|
||||
@Test func redactsTokenQueryParameterButKeepsKey() {
|
||||
#expect(
|
||||
scrubber.scrub("GET https://api.example.com/v1?token=supersecretvalue123&page=2")
|
||||
@@ -81,6 +96,22 @@ import Testing
|
||||
)
|
||||
}
|
||||
|
||||
@Test func redactsEscapedQuoteInsideQuotedSecretAssignment() {
|
||||
#expect(
|
||||
scrubber.scrub(#"{"password":"abc\"def"}"#)
|
||||
== #"{"password":"<redacted-secret>"}"#
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func markerRichTextWithoutAnAssignmentDoesNotWedgeScrubbing() {
|
||||
// Sentry HTTP-query breadcrumbs can contain long branch names with
|
||||
// "author" fragments. The free-text scanner must inspect this once,
|
||||
// not repeatedly backtrack around the embedded "auth" substring.
|
||||
let input = "state=all&head=" + String(repeating: "author-", count: 160) + "branch"
|
||||
#expect(scrubber.scrub(input) == input)
|
||||
}
|
||||
|
||||
@Test func redactsProviderApiKey() {
|
||||
#expect(
|
||||
scrubber.scrub("using sk-proj-abcdef0123456789ABCDEF to call")
|
||||
@@ -108,8 +139,8 @@ import Testing
|
||||
}
|
||||
|
||||
@Test func redactsBroaderCredentialMarkersInRawQueryStrings() {
|
||||
// The free-text assignment markers stay in sync with the dictionary
|
||||
// sensitive-key set, so auth/session/cookie params are caught as raw text.
|
||||
// The free-text assignment scanner shares the dictionary sensitive-key
|
||||
// set, so auth/session/cookie params are caught as raw text.
|
||||
#expect(
|
||||
scrubber.scrub("GET /x?auth=opaquesessionvalue&page=1")
|
||||
== "GET /x?auth=<redacted-secret>&page=1"
|
||||
@@ -124,6 +155,27 @@ import Testing
|
||||
)
|
||||
}
|
||||
|
||||
@Test func redactsPunctuationInsideUnquotedCredentialValues() {
|
||||
for input in [
|
||||
"token=abc;def",
|
||||
"token=abc)def",
|
||||
"token=abc]def",
|
||||
"token=abc,def",
|
||||
"token=abc}def",
|
||||
] {
|
||||
#expect(scrubber.scrub(input) == "token=<redacted-secret>")
|
||||
}
|
||||
|
||||
#expect(
|
||||
scrubber.scrub("token=abc;page=2")
|
||||
== "token=<redacted-secret>;page=2"
|
||||
)
|
||||
#expect(
|
||||
scrubber.scrub("env TOKEN=abc,KEEP=2")
|
||||
== "env TOKEN=<redacted-secret>,KEEP=2"
|
||||
)
|
||||
}
|
||||
|
||||
@Test func redactsEnvStyleSecretAssignmentWithLongerKeyName() {
|
||||
// The sensitive marker is embedded in a longer env identifier.
|
||||
#expect(
|
||||
@@ -157,6 +209,28 @@ import Testing
|
||||
#expect(scrubber.scrub("aside=note") == "aside=note")
|
||||
}
|
||||
|
||||
@Test func redactsSensitiveExactAliasesAtPunctuationBoundaries() {
|
||||
#expect(
|
||||
scrubber.scrub(#"{"connect.sid":"session-secret"}"#)
|
||||
== #"{"connect.sid":"<redacted-secret>"}"#
|
||||
)
|
||||
#expect(
|
||||
scrubber.scrub("x-sid=session-secret")
|
||||
== "x-sid=<redacted-secret>"
|
||||
)
|
||||
#expect(
|
||||
scrubber.scrubQueryString("x-sid=session-secret&inside=hallway")
|
||||
== "x-sid=<redacted-secret>&inside=hallway"
|
||||
)
|
||||
let structured = scrubber.scrub(dictionary: [
|
||||
"x-sid": "session-secret",
|
||||
"x-inside": "hallway",
|
||||
])
|
||||
#expect(structured["x-sid"] as? String == "<redacted-secret>")
|
||||
#expect(structured["x-inside"] as? String == "hallway")
|
||||
#expect(scrubber.scrub("x-inside=hallway") == "x-inside=hallway")
|
||||
}
|
||||
|
||||
@Test func sessionAndSidDictionaryKeysAreSensitiveWithoutOvermatching() {
|
||||
let input: [String: Any] = [
|
||||
"session": "abc",
|
||||
@@ -248,7 +322,7 @@ import Testing
|
||||
@Test func scrubsSensitiveQueryParamsByKeyKeepingNonSensitive() {
|
||||
// The maintained denylist's EXACT aliases (csrf/_csrf, _vercel_jwt, su,
|
||||
// phpsessid, sid) are redacted by key even though they are too short to
|
||||
// live in the free-text assignment regex. Keys stay; non-sensitive params
|
||||
// need ambiguous free-text regexes. Keys stay; non-sensitive params
|
||||
// (page) are untouched.
|
||||
#expect(
|
||||
scrubber.scrubQueryString("_csrf=abc123&_vercel_jwt=xyz789&page=2")
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
|
||||
/// The authenticated Mac-side gate for forwarding notifications to this phone.
|
||||
///
|
||||
/// The Mac includes this only after the caller proves same-account ownership.
|
||||
/// Missing or unknown values must therefore be treated as unavailable, never as
|
||||
/// ready.
|
||||
public struct MobileHostPhonePushStatus: Decodable, Equatable, Sendable {
|
||||
/// When the Mac forwards otherwise-qualifying notifications.
|
||||
public enum Mode: String, Decodable, Equatable, Sendable {
|
||||
/// Forward only while the Mac is locked, asleep, or idle.
|
||||
case onlyWhenAway
|
||||
/// Forward regardless of Mac presence.
|
||||
case always
|
||||
}
|
||||
|
||||
/// What the authenticated status exchange proves about account ownership.
|
||||
public enum AccountScope: String, Decodable, Equatable, Sendable {
|
||||
/// The Mac verified the phone's Stack token against its own account.
|
||||
case verifiedSameAccount = "verified_same_account"
|
||||
}
|
||||
|
||||
/// The Mac's sanitized current decision for a would-be notification.
|
||||
public enum Admission: String, Decodable, Equatable, Sendable {
|
||||
case allowed
|
||||
case forwardingDisabled = "forwarding_disabled"
|
||||
case suppressedMacActive = "suppressed_mac_active"
|
||||
case unknown
|
||||
}
|
||||
|
||||
/// Durability of the Mac's bounded retry queue. Failures degrade restart
|
||||
/// reliability without claiming that the live APNs request path is down.
|
||||
public enum QueuePersistence: String, Decodable, Equatable, Sendable {
|
||||
case unknown
|
||||
case healthy
|
||||
case loadFailed = "load_failed"
|
||||
case saveFailed = "save_failed"
|
||||
case clearFailed = "clear_failed"
|
||||
}
|
||||
|
||||
/// Whether the Mac's independent forwarding privacy gate is enabled.
|
||||
public let forwardingEnabled: Bool
|
||||
/// The Mac's live forwarding mode.
|
||||
public let mode: Mode
|
||||
/// Whether the current mode and presence admit a forward right now.
|
||||
public let admission: Admission
|
||||
/// Sanitized persistence health for queued Mac-to-phone events.
|
||||
public let queuePersistence: QueuePersistence
|
||||
/// Whether terminal title/body content is redacted before upload.
|
||||
public let hideContent: Bool
|
||||
/// The API base URL the Mac will send the notification through.
|
||||
public let apiOrigin: String
|
||||
/// The account relationship proven by the authenticated RPC.
|
||||
public let accountScope: AccountScope
|
||||
|
||||
/// Creates an authenticated Mac push-status value.
|
||||
public init(
|
||||
forwardingEnabled: Bool,
|
||||
mode: Mode,
|
||||
admission: Admission = .unknown,
|
||||
queuePersistence: QueuePersistence = .unknown,
|
||||
hideContent: Bool = false,
|
||||
apiOrigin: String,
|
||||
accountScope: AccountScope
|
||||
) {
|
||||
self.forwardingEnabled = forwardingEnabled
|
||||
self.mode = mode
|
||||
self.admission = admission
|
||||
self.queuePersistence = queuePersistence
|
||||
self.hideContent = hideContent
|
||||
self.apiOrigin = apiOrigin
|
||||
self.accountScope = accountScope
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case forwardingEnabled = "forwarding_enabled"
|
||||
case mode
|
||||
case admission
|
||||
case queuePersistence = "queue_persistence"
|
||||
case hideContent = "hide_content"
|
||||
case apiOrigin = "api_origin"
|
||||
case accountScope = "account_scope"
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
forwardingEnabled = try container.decode(
|
||||
Bool.self,
|
||||
forKey: .forwardingEnabled
|
||||
)
|
||||
mode = try container.decode(Mode.self, forKey: .mode)
|
||||
admission = try container.decodeIfPresent(
|
||||
Admission.self,
|
||||
forKey: .admission
|
||||
) ?? .unknown
|
||||
queuePersistence = try container.decodeIfPresent(
|
||||
QueuePersistence.self,
|
||||
forKey: .queuePersistence
|
||||
) ?? .unknown
|
||||
hideContent = try container.decodeIfPresent(
|
||||
Bool.self,
|
||||
forKey: .hideContent
|
||||
) ?? false
|
||||
apiOrigin = try container.decode(String.self, forKey: .apiOrigin)
|
||||
accountScope = try container.decode(
|
||||
AccountScope.self,
|
||||
forKey: .accountScope
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ public struct MobileHostStatusResponse: Decodable, Sendable {
|
||||
/// colors. `nil` from older Macs that predate the field, in which case the
|
||||
/// phone keeps its built-in Monokai default.
|
||||
public let theme: TerminalTheme?
|
||||
/// Authenticated Mac-side phone-forwarding status. `nil` means the caller
|
||||
/// could not prove same-account ownership, the Mac predates this field, or
|
||||
/// the value was malformed. None of those states is ready.
|
||||
public let phonePush: MobileHostPhonePushStatus?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case capabilities
|
||||
@@ -49,6 +53,7 @@ public struct MobileHostStatusResponse: Decodable, Sendable {
|
||||
case macAppVersion = "mac_app_version"
|
||||
case macAppBuild = "mac_app_build"
|
||||
case theme
|
||||
case phonePush = "phone_push"
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
@@ -69,6 +74,12 @@ public struct MobileHostStatusResponse: Decodable, Sendable {
|
||||
// leniently: a bad theme object yields `nil` and the phone keeps its
|
||||
// built-in Monokai default, exactly like an older Mac that omits it.
|
||||
theme = (try? container.decodeIfPresent(TerminalTheme.self, forKey: .theme)) ?? nil
|
||||
// Keep an unknown future mode/account value from invalidating the
|
||||
// transport and identity fields in the same status response.
|
||||
phonePush = (try? container.decodeIfPresent(
|
||||
MobileHostPhonePushStatus.self,
|
||||
forKey: .phonePush
|
||||
)) ?? nil
|
||||
}
|
||||
|
||||
/// Decode a host-status response from raw JSON data.
|
||||
|
||||
+51
@@ -49,6 +49,57 @@ import Testing
|
||||
#expect(response.terminalThemeRevisionEpoch == "boot-one")
|
||||
}
|
||||
|
||||
@Test func hostStatusDecodesAuthenticatedPhonePushReadiness() throws {
|
||||
let response = try MobileHostStatusResponse.decode(Data(
|
||||
"""
|
||||
{
|
||||
"mac_device_id": "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE",
|
||||
"phone_push": {
|
||||
"forwarding_enabled": true,
|
||||
"mode": "onlyWhenAway",
|
||||
"admission": "suppressed_mac_active",
|
||||
"queue_persistence": "healthy",
|
||||
"hide_content": true,
|
||||
"api_origin": "https://cmux-staging.vercel.app",
|
||||
"account_scope": "verified_same_account"
|
||||
}
|
||||
}
|
||||
""".utf8
|
||||
))
|
||||
|
||||
#expect(response.phonePush == MobileHostPhonePushStatus(
|
||||
forwardingEnabled: true,
|
||||
mode: .onlyWhenAway,
|
||||
admission: .suppressedMacActive,
|
||||
queuePersistence: .healthy,
|
||||
hideContent: true,
|
||||
apiOrigin: "https://cmux-staging.vercel.app",
|
||||
accountScope: .verifiedSameAccount
|
||||
))
|
||||
}
|
||||
|
||||
@Test func hostStatusKeepsMissingQueueHealthDistinctFromFailure() throws {
|
||||
let missing = try MobileHostStatusResponse.decode(Data(
|
||||
#"{"phone_push":{"forwarding_enabled":true,"mode":"always","admission":"allowed","api_origin":"https://cmux.com","account_scope":"verified_same_account"}}"#.utf8
|
||||
))
|
||||
let failed = try MobileHostStatusResponse.decode(Data(
|
||||
#"{"phone_push":{"forwarding_enabled":true,"mode":"always","admission":"allowed","queue_persistence":"save_failed","api_origin":"https://cmux.com","account_scope":"verified_same_account"}}"#.utf8
|
||||
))
|
||||
|
||||
#expect(missing.phonePush?.queuePersistence == .unknown)
|
||||
#expect(failed.phonePush?.queuePersistence == .saveFailed)
|
||||
}
|
||||
|
||||
@Test func hostStatusTreatsMissingOrUnknownPhonePushStateAsUnavailable() throws {
|
||||
let missing = try MobileHostStatusResponse.decode(Data("{}".utf8))
|
||||
let unknown = try MobileHostStatusResponse.decode(Data(
|
||||
#"{"phone_push":{"forwarding_enabled":true,"mode":"future","api_origin":"x","account_scope":"future"}}"#.utf8
|
||||
))
|
||||
|
||||
#expect(missing.phonePush == nil)
|
||||
#expect(unknown.phonePush == nil)
|
||||
}
|
||||
|
||||
@Test func hostStatusCanonicalizesOnlyUUIDDeviceIDs() throws {
|
||||
let uppercaseUUID = "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE"
|
||||
let uuidResponse = try MobileHostStatusResponse.decode(Data(
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/// The furthest stage a user-triggered test alert has confirmed.
|
||||
///
|
||||
/// `queuedOnMac` deliberately does not claim APNs acceptance or visible iOS
|
||||
/// presentation. Those later stages remain observable through correlated Mac,
|
||||
/// backend, and device evidence rather than a false-success button label.
|
||||
public enum MobilePhonePushTestStage: String, Equatable, Sendable {
|
||||
case queuedOnMac = "queued"
|
||||
case forwardingDisabled = "forwarding_disabled"
|
||||
case macActive = "suppressed_mac_active"
|
||||
case authenticationUnavailable = "authentication_unavailable"
|
||||
case encodingFailed = "encoding_failed"
|
||||
case queueFull = "queue_full"
|
||||
case unavailable
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import CmuxMobileShellModel
|
||||
|
||||
@MainActor
|
||||
extension MobileShellComposite {
|
||||
/// Observes the shared Settings/onboarding choice and replaces any live
|
||||
/// foreground connection whose route was selected under the old method.
|
||||
func startObservingConnectionMethodChanges() {
|
||||
guard connectionMethodObservationTask == nil,
|
||||
let connectionMethodStore else { return }
|
||||
let initialMethod = connectionMethodStore.method
|
||||
connectionMethodObservationTask = Task { @MainActor [weak self, connectionMethodStore] in
|
||||
var observedMethod = initialMethod
|
||||
for await method in connectionMethodStore.changes() {
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
guard method != observedMethod else { continue }
|
||||
observedMethod = method
|
||||
self.recoverMobileConnection(trigger: .connectionMethodChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-5
@@ -85,7 +85,7 @@ extension MobileShellComposite {
|
||||
}
|
||||
if let accountID = identityProvider?.currentUserID {
|
||||
switch trigger {
|
||||
case .manual, .networkChange, .foreground:
|
||||
case .manual, .networkChange, .foreground, .connectionMethodChanged:
|
||||
clearTransientAutomaticReconnectBackoff(accountID: accountID)
|
||||
case .presencePush:
|
||||
guard !automaticIrohReconnectIsBlocked(accountID: accountID) else {
|
||||
@@ -96,10 +96,24 @@ extension MobileShellComposite {
|
||||
break
|
||||
}
|
||||
}
|
||||
let connectionMethodChanged: Bool
|
||||
if case .connectionMethodChanged = trigger {
|
||||
connectionMethodChanged = true
|
||||
// A method change invalidates every route decision made by an
|
||||
// in-flight recovery. The replacement below owns a new generation
|
||||
// and is the only attempt allowed to publish a foreground client.
|
||||
connectionRecoveryOwner.cancel()
|
||||
applyConnectionRecoveryOwnerState()
|
||||
invalidateStoredMacReconnectAttempt()
|
||||
} else {
|
||||
connectionMethodChanged = false
|
||||
}
|
||||
beginConnectionRecovery(
|
||||
trigger: trigger,
|
||||
expectedClient: remoteClient,
|
||||
probeCurrentConnection: connectionState == .connected && remoteClient != nil,
|
||||
probeCurrentConnection: !connectionMethodChanged
|
||||
&& connectionState == .connected
|
||||
&& remoteClient != nil,
|
||||
resyncAfterHealthy: true
|
||||
)
|
||||
// A disconnected redial has cleared its foreground identity. Starting
|
||||
@@ -198,7 +212,8 @@ extension MobileShellComposite {
|
||||
markMacConnectionReconnecting()
|
||||
resyncTerminalOutput(reason: trigger.description, restartEventStream: true)
|
||||
case .manual, .presencePush, .foreground, .eventStreamEnded,
|
||||
.subscriptionStartFailed, .transportWriteTimedOut, .automaticBackoffExpired:
|
||||
.subscriptionStartFailed, .transportWriteTimedOut, .automaticBackoffExpired,
|
||||
.connectionMethodChanged:
|
||||
markMacConnectionUnavailableIfNoStore()
|
||||
}
|
||||
return
|
||||
@@ -615,8 +630,8 @@ extension MobileShellComposite {
|
||||
routes,
|
||||
supportedKinds: supportedKinds,
|
||||
preferNonLoopback: Self.prefersNonLoopbackRoutes,
|
||||
tailscalePreference: connectionMethodStore?.method == .tailscale
|
||||
? Self.TailscaleRoutePreference(
|
||||
tailscaleRequirement: connectionMethodStore?.method == .tailscale
|
||||
? Self.TailscaleRouteRequirement(
|
||||
macDeviceID: pairedMacDeviceID,
|
||||
grantRoutes: legacyTailscaleRoutes
|
||||
)
|
||||
|
||||
+15
-21
@@ -110,9 +110,9 @@ extension MobileShellComposite {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The Tailscale ordering preference for one paired Mac: which grant routes
|
||||
/// may promote an exact stored Tailscale route ahead of the Iroh pin.
|
||||
struct TailscaleRoutePreference {
|
||||
/// The strict Tailscale policy for one paired Mac: only exact grant routes
|
||||
/// remain dialable while the user has selected Tailscale.
|
||||
struct TailscaleRouteRequirement {
|
||||
let macDeviceID: String
|
||||
let grantRoutes: [CmxAttachRoute]
|
||||
}
|
||||
@@ -127,16 +127,15 @@ extension MobileShellComposite {
|
||||
/// or revocation failure could silently downgrade around the Iroh device
|
||||
/// grant. Pairings without an authenticated Iroh identity remain fail-closed.
|
||||
///
|
||||
/// `tailscalePreference` (the user's explicit Tailscale connection-method
|
||||
/// choice) relaxes only the ORDER of that pin: stored Tailscale routes that
|
||||
/// carry a device-local grant dial first, and the Iroh routes stay as the
|
||||
/// fallback instead of being exclusive. Unauthorized Tailscale routes are
|
||||
/// still never dialable, so a preference flip alone grants nothing.
|
||||
/// `tailscaleRequirement` represents the user's explicit Tailscale-only
|
||||
/// connection method. Only stored Tailscale routes carrying a device-local
|
||||
/// grant remain; Iroh is not retained as a fallback, and a method change
|
||||
/// alone grants nothing.
|
||||
static func storedReconnectRoutes(
|
||||
_ routes: [CmxAttachRoute],
|
||||
supportedKinds: [CmxAttachTransportKind],
|
||||
preferNonLoopback: Bool = false,
|
||||
tailscalePreference: TailscaleRoutePreference? = nil
|
||||
tailscaleRequirement: TailscaleRouteRequirement? = nil
|
||||
) -> [CmxAttachRoute] {
|
||||
let supportedKinds = Set(supportedKinds)
|
||||
var ordered = CmxAttachRoute.addingIrohPrivatePaths(
|
||||
@@ -149,20 +148,15 @@ extension MobileShellComposite {
|
||||
ordered.removeAll { $0.kind == .debugLoopback }
|
||||
}
|
||||
let irohRoutes = ordered.filter { $0.kind == .iroh }
|
||||
if let tailscalePreference {
|
||||
if let tailscaleRequirement {
|
||||
let authorizedTailscale = ordered.filter { route in
|
||||
legacyTailscaleAuthorizationEvidence(
|
||||
for: route,
|
||||
macDeviceID: tailscalePreference.macDeviceID,
|
||||
persistedRoutes: tailscalePreference.grantRoutes
|
||||
macDeviceID: tailscaleRequirement.macDeviceID,
|
||||
persistedRoutes: tailscaleRequirement.grantRoutes
|
||||
) != nil
|
||||
}
|
||||
if !authorizedTailscale.isEmpty {
|
||||
let rest = ordered.filter { route in
|
||||
route.kind != .iroh && route.kind != .tailscale
|
||||
}
|
||||
return authorizedTailscale + irohRoutes + rest
|
||||
}
|
||||
return authorizedTailscale
|
||||
}
|
||||
if !irohRoutes.isEmpty {
|
||||
return irohRoutes
|
||||
@@ -172,7 +166,7 @@ extension MobileShellComposite {
|
||||
|
||||
/// The dial order for one stored Mac, honoring the user's connection-method
|
||||
/// choice. With the default automatic method this is exactly
|
||||
/// ``storedReconnectRoutes(_:supportedKinds:preferNonLoopback:tailscalePreference:)``
|
||||
/// ``storedReconnectRoutes(_:supportedKinds:preferNonLoopback:tailscaleRequirement:)``
|
||||
/// without a preference.
|
||||
func orderedReconnectRoutes(
|
||||
for mac: MobilePairedMac,
|
||||
@@ -182,8 +176,8 @@ extension MobileShellComposite {
|
||||
mac.routes,
|
||||
supportedKinds: supportedKinds,
|
||||
preferNonLoopback: Self.prefersNonLoopbackRoutes,
|
||||
tailscalePreference: connectionMethodStore?.method == .tailscale
|
||||
? TailscaleRoutePreference(
|
||||
tailscaleRequirement: connectionMethodStore?.method == .tailscale
|
||||
? TailscaleRouteRequirement(
|
||||
macDeviceID: mac.macDeviceID,
|
||||
grantRoutes: mac.legacyTailscaleRoutes ?? []
|
||||
)
|
||||
|
||||
@@ -54,6 +54,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
"workspace.updated", "mobile.sync.delta",
|
||||
"terminal.bytes", "terminal.render_grid", "terminal.set_font",
|
||||
"notification.dismissed", "notification.badge", "notification.feed.changed",
|
||||
"phone_push.status.changed",
|
||||
"browser.frame", "browser.state", "browser.closed", "browser.dialog", "browser.dialog.resolved",
|
||||
]
|
||||
case .renderGrid:
|
||||
@@ -61,6 +62,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
"workspace.updated", "mobile.sync.delta",
|
||||
"terminal.render_grid", "terminal.set_font",
|
||||
"notification.dismissed", "notification.badge", "notification.feed.changed",
|
||||
"phone_push.status.changed",
|
||||
"browser.frame", "browser.state", "browser.closed", "browser.dialog", "browser.dialog.resolved",
|
||||
]
|
||||
case .rawBytes:
|
||||
@@ -68,6 +70,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
"workspace.updated", "mobile.sync.delta",
|
||||
"terminal.bytes", "terminal.set_font",
|
||||
"notification.dismissed", "notification.badge", "notification.feed.changed",
|
||||
"phone_push.status.changed",
|
||||
"browser.frame", "browser.state", "browser.closed", "browser.dialog", "browser.dialog.resolved",
|
||||
]
|
||||
}
|
||||
@@ -130,7 +133,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
static let dogfoodFeedbackCapability = "dogfood.v1"
|
||||
static let workspaceGroupsCapability = "workspace.groups.v1"
|
||||
static let notificationFeedCapability = "notification.feed.v1"
|
||||
private static let terminalOutputCapabilityTimeoutNanoseconds: UInt64 = 750_000_000
|
||||
static let phonePushSettingsCapability = "phone_push.settings.v1"
|
||||
static let phonePushTestCapability = "phone_push.test.v1"
|
||||
nonisolated private static let terminalOutputCapabilityTimeoutNanoseconds: UInt64 = 750_000_000
|
||||
/// How long the render-grid stream may stay silent (no event of any topic)
|
||||
/// before the liveness watchdog suspects the push subscription is dead and
|
||||
/// runs a bounded host probe; only repeated failed probes force the
|
||||
@@ -446,6 +451,21 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Authenticated phone-forwarding readiness from the focused Mac. `nil`
|
||||
/// means no attached Mac has proved same-account ownership and exposed the
|
||||
/// independent Mac privacy gate.
|
||||
public internal(set) var phonePushMacStatus: MobileHostPhonePushStatus?
|
||||
|
||||
/// Whether the authenticated Mac supports changing its independent phone
|
||||
/// forwarding privacy gates from iOS.
|
||||
public var supportsPhonePushSettings: Bool {
|
||||
supportedHostCapabilities.contains(Self.phonePushSettingsCapability)
|
||||
}
|
||||
|
||||
/// Whether the authenticated Mac can enqueue a correlated test alert.
|
||||
public var supportsPhonePushTest: Bool {
|
||||
supportedHostCapabilities.contains(Self.phonePushTestCapability)
|
||||
}
|
||||
/// Published workspace-list chip snapshots keyed by Mac-local workspace id.
|
||||
///
|
||||
/// Like ``workspaces``, this is a materialized immutable-value surface on the
|
||||
@@ -717,8 +737,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
|
||||
let runtime: (any MobileSyncRuntime)?
|
||||
let pairedMacStore: (any MobilePairedMacStoring)?
|
||||
/// The user's connection-method choice. `nil` (previews/tests without one)
|
||||
/// behaves like the default automatic method.
|
||||
/// The user's connection-method choice. The shipping app always injects
|
||||
/// this at the composition root (`AppCompositionRoot` holds it
|
||||
/// non-optional), so a user-selected Tailscale Only choice can never be
|
||||
/// dropped at runtime. `nil` exists only for DEBUG previews, the
|
||||
/// hide-computers verifier, and unit-test fixtures, which have no user
|
||||
/// preference and behave like the default automatic method.
|
||||
let connectionMethodStore: MobileConnectionMethodStore?
|
||||
/// Single compatibility authority shared by registry, persistence, and live connections.
|
||||
let buildCompatibilityPolicy: MobileMacBuildCompatibilityPolicy?
|
||||
@@ -1511,6 +1535,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
browserStreamEvents?.configureBrowserStreamRestart { [weak self] panelID in
|
||||
await self?.forceRestartMobileBrowserStream(panelID: panelID)
|
||||
}
|
||||
startObservingConnectionMethodChanges()
|
||||
}
|
||||
|
||||
isolated deinit {
|
||||
@@ -1518,6 +1543,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
automaticReconnectRetryTask?.cancel()
|
||||
presenceTask?.cancel()
|
||||
networkPathObservationTask?.cancel()
|
||||
connectionMethodObservationTask?.cancel()
|
||||
terminalEventListenerTask?.cancel()
|
||||
terminalSubscriptionStartTask?.cancel()
|
||||
renderGridLivenessTimer?.cancel()
|
||||
@@ -1975,6 +2001,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
|
||||
var networkPathObservationStarted = false
|
||||
var networkPathObservationTask: Task<Void, Never>?
|
||||
var connectionMethodObservationTask: Task<Void, Never>?
|
||||
let connectionRecoveryOwner = MobileConnectionRecoveryOwner()
|
||||
var lastReconnectStackUserID: String?
|
||||
/// Whether the scene is in the active phase. Set by
|
||||
@@ -1999,6 +2026,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
case subscriptionStartFailed
|
||||
case transportWriteTimedOut
|
||||
case automaticBackoffExpired
|
||||
case connectionMethodChanged
|
||||
|
||||
var reschedulesSecondaryAggregation: Bool { self != .presencePush }
|
||||
|
||||
@@ -2016,6 +2044,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
case .subscriptionStartFailed: 7
|
||||
case .transportWriteTimedOut: 8
|
||||
case .automaticBackoffExpired: 9
|
||||
case .connectionMethodChanged: 10
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2030,6 +2059,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
case .subscriptionStartFailed: return "subscriptionStartFailed"
|
||||
case .transportWriteTimedOut: return "transportWriteTimedOut"
|
||||
case .automaticBackoffExpired: return "automaticBackoffExpired"
|
||||
case .connectionMethodChanged: return "connectionMethodChanged"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2450,7 +2480,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
if hasKnownStoredMac {
|
||||
setHasKnownPairedMac(true, generation: generation)
|
||||
}
|
||||
let irohReconnectIsBlocked = automaticIrohReconnectIsBlocked(accountID: scope.userID)
|
||||
let tailscaleOnly = connectionMethodStore?.method == .tailscale
|
||||
let irohReconnectIsBlocked = tailscaleOnly
|
||||
|| automaticIrohReconnectIsBlocked(accountID: scope.userID)
|
||||
// Capture one coherent post-request view of the registry and paired-Mac
|
||||
// store. The store read happens after the registry await, so an
|
||||
// authenticated Presence write that lands during the request wins. The
|
||||
@@ -2480,7 +2512,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
instanceTag: mac.instanceTag,
|
||||
scope: scope
|
||||
) else { break }
|
||||
let irohReconnectIsBlocked = automaticIrohReconnectIsBlocked(accountID: scope.userID)
|
||||
let irohReconnectIsBlocked = tailscaleOnly
|
||||
|| automaticIrohReconnectIsBlocked(accountID: scope.userID)
|
||||
let localRoutes = storedReconnectRoutes(mac).filter {
|
||||
!irohReconnectIsBlocked || $0.kind != .iroh
|
||||
}
|
||||
@@ -2514,7 +2547,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
}
|
||||
)
|
||||
}
|
||||
if connectionState != .connected,
|
||||
if connectionState != .connected, !tailscaleOnly,
|
||||
!automaticIrohReconnectIsBlocked(accountID: scope.userID) {
|
||||
switch await freshReconnectRoutesAfterLocalFailure(
|
||||
for: mac,
|
||||
@@ -2553,7 +2586,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
// saved candidate failed. This keeps a healthy saved Mac from sitting
|
||||
// behind an unrelated account-wide discovery request.
|
||||
var zeroTouchCandidates: [MobilePairedMac] = []
|
||||
if connectionState != .connected,
|
||||
if connectionState != .connected, !tailscaleOnly,
|
||||
!automaticIrohReconnectIsBlocked(accountID: scope.userID) {
|
||||
zeroTouchCandidates = await discoverZeroTouchIrohCandidates(
|
||||
scope: scope,
|
||||
@@ -8272,6 +8305,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
foregroundMacDeviceID = resolvedForegroundMacID
|
||||
}
|
||||
supportedHostCapabilities = authenticatedCapabilities
|
||||
phonePushMacStatus = status.phonePush
|
||||
// Publish transport selection with the authenticated
|
||||
// capability snapshot before exposing `.connected`.
|
||||
// The listener reuses this same status below, but starts in
|
||||
@@ -8423,10 +8457,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
let irohRoutes = supportedRoutes.filter { route in
|
||||
route.kind == .iroh
|
||||
}
|
||||
// The user's explicit Tailscale method relaxes only the Iroh pin's
|
||||
// ORDER: authorized Tailscale routes dial first and Iroh remains the
|
||||
// fallback. Routes without a grant or a user-entered code stay
|
||||
// undialable regardless of the preference.
|
||||
// The explicit Tailscale method is strict: only authorized Tailscale
|
||||
// destinations may be dialed, and an unavailable route leaves the app
|
||||
// disconnected instead of silently switching to Iroh.
|
||||
if connectionMethodStore?.method == .tailscale {
|
||||
let authorizedTailscale = supportedRoutes.filter { route in
|
||||
Self.legacyTailscaleAuthorizationEvidence(
|
||||
@@ -8439,12 +8472,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
authorizations: userTailscalePairingAuthorizations
|
||||
) != nil
|
||||
}
|
||||
if !authorizedTailscale.isEmpty {
|
||||
let rest = supportedRoutes.filter { route in
|
||||
route.kind != .iroh && route.kind != .tailscale
|
||||
}
|
||||
return authorizedTailscale + irohRoutes + rest
|
||||
}
|
||||
return authorizedTailscale
|
||||
}
|
||||
return irohRoutes.isEmpty ? supportedRoutes : irohRoutes
|
||||
}
|
||||
@@ -9002,6 +9030,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
terminalOutputTransport = .rawBytes
|
||||
deactivateAllTerminalLanes()
|
||||
supportedHostCapabilities = []
|
||||
phonePushMacStatus = nil
|
||||
clearMacUpdateHint()
|
||||
terminalSubscriptionRefreshTask?.cancel()
|
||||
terminalSubscriptionRefreshTask = nil
|
||||
@@ -10221,6 +10250,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
return .rawBytes
|
||||
}
|
||||
supportedHostCapabilities = Set(payload.capabilities)
|
||||
phonePushMacStatus = payload.phonePush
|
||||
restartActiveMobileBrowserStreams()
|
||||
refreshVisibleMobileBrowserPanels()
|
||||
prepareTerminalThemeRevisionAuthority(
|
||||
@@ -10441,6 +10471,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
macDeviceID: macDeviceID
|
||||
)
|
||||
)
|
||||
} else if event.topic == "phone_push.status.changed" {
|
||||
await self.refreshPhonePushStatus(
|
||||
client: client,
|
||||
generation: self.connectionGeneration
|
||||
)
|
||||
} else if event.topic == "browser.frame" {
|
||||
self.handleMobileBrowserFrameEvent(event)
|
||||
} else if event.topic == "browser.state" {
|
||||
@@ -10467,6 +10502,152 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshPhonePushStatus(
|
||||
client: MobileCoreRPCClient,
|
||||
generation: UUID
|
||||
) async {
|
||||
let exchange: (response: Data, hostStatusResponse: Data)
|
||||
do {
|
||||
exchange = try await client.sendRequestAndAuthenticatedHostStatus(
|
||||
MobileCoreRPCClient.requestData(
|
||||
method: "phone_push.status.get",
|
||||
params: [:]
|
||||
),
|
||||
timeoutNanoseconds: Self.terminalOutputCapabilityTimeoutNanoseconds,
|
||||
hostStatusTimeoutNanoseconds: {
|
||||
Self.terminalOutputCapabilityTimeoutNanoseconds
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
guard isCurrentRemoteConnection(
|
||||
client: client,
|
||||
generation: generation
|
||||
) else { return }
|
||||
// This status probe is authenticated: a definitive authorization
|
||||
// failure here means the session itself is revoked or mismatched,
|
||||
// not merely that push readiness is unknown. Route it to the
|
||||
// shared reauth disconnect instead of staying connected with a
|
||||
// silently cleared readiness.
|
||||
guard !disconnectForAuthorizationFailureIfNeeded(error) else {
|
||||
return
|
||||
}
|
||||
phonePushMacStatus = nil
|
||||
return
|
||||
}
|
||||
guard isCurrentRemoteConnection(
|
||||
client: client,
|
||||
generation: generation
|
||||
) else { return }
|
||||
guard let status = try? MobileHostStatusResponse.decode(
|
||||
exchange.hostStatusResponse
|
||||
) else {
|
||||
phonePushMacStatus = nil
|
||||
return
|
||||
}
|
||||
phonePushMacStatus = status.phonePush
|
||||
}
|
||||
|
||||
/// Applies one or more Mac-owned phone-forwarding settings over the current
|
||||
/// authenticated attach channel, then reads the authoritative status back.
|
||||
///
|
||||
/// The mutation fails closed when the Mac is unavailable, predates the
|
||||
/// capability, rejects the caller, or the connection changes mid-flight.
|
||||
/// Local UI never writes a speculative Mac value into this store.
|
||||
@discardableResult
|
||||
public func updatePhonePushSettings(
|
||||
forwardingEnabled: Bool? = nil,
|
||||
mode: MobileHostPhonePushStatus.Mode? = nil,
|
||||
hideContent: Bool? = nil
|
||||
) async -> Bool {
|
||||
guard supportsPhonePushSettings,
|
||||
let client = remoteClient,
|
||||
forwardingEnabled != nil || mode != nil || hideContent != nil
|
||||
else { return false }
|
||||
|
||||
var params: [String: Any] = [:]
|
||||
if let forwardingEnabled {
|
||||
params["forwarding_enabled"] = forwardingEnabled
|
||||
}
|
||||
if let mode {
|
||||
params["mode"] = mode.rawValue
|
||||
}
|
||||
if let hideContent {
|
||||
params["hide_content"] = hideContent
|
||||
}
|
||||
|
||||
let generation = connectionGeneration
|
||||
do {
|
||||
let exchange = try await client.sendRequestAndAuthenticatedHostStatus(
|
||||
MobileCoreRPCClient.requestData(
|
||||
method: "phone_push.settings.update",
|
||||
params: params
|
||||
),
|
||||
hostStatusTimeoutNanoseconds: {
|
||||
Self.terminalOutputCapabilityTimeoutNanoseconds
|
||||
}
|
||||
)
|
||||
let status = try MobileHostStatusResponse.decode(
|
||||
exchange.hostStatusResponse
|
||||
)
|
||||
guard isCurrentRemoteConnection(
|
||||
client: client,
|
||||
generation: generation
|
||||
) else { return false }
|
||||
phonePushMacStatus = status.phonePush
|
||||
return true
|
||||
} catch {
|
||||
guard generation == connectionGeneration else { return false }
|
||||
guard !disconnectForAuthorizationFailureIfNeeded(error) else {
|
||||
return false
|
||||
}
|
||||
handleMacAvailabilityFailureIfCurrent(
|
||||
after: error,
|
||||
expectedClient: client,
|
||||
expectedGeneration: generation
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Requests a test alert through the Mac's real durable queue and returns
|
||||
/// only the furthest stage the synchronous RPC can prove.
|
||||
public func sendPhonePushTest() async -> MobilePhonePushTestStage {
|
||||
guard supportsPhonePushTest, let client = remoteClient else {
|
||||
return .unavailable
|
||||
}
|
||||
let generation = connectionGeneration
|
||||
do {
|
||||
let data = try await client.sendRequest(
|
||||
MobileCoreRPCClient.requestData(
|
||||
method: "phone_push.test",
|
||||
params: [:]
|
||||
)
|
||||
)
|
||||
guard isCurrentRemoteConnection(
|
||||
client: client,
|
||||
generation: generation
|
||||
), let object = try JSONSerialization.jsonObject(with: data)
|
||||
as? [String: Any],
|
||||
let rawStage = object["stage"] as? String,
|
||||
let stage = MobilePhonePushTestStage(rawValue: rawStage)
|
||||
else { return .unavailable }
|
||||
return stage
|
||||
} catch {
|
||||
guard generation == connectionGeneration else {
|
||||
return .unavailable
|
||||
}
|
||||
guard !disconnectForAuthorizationFailureIfNeeded(error) else {
|
||||
return .unavailable
|
||||
}
|
||||
handleMacAvailabilityFailureIfCurrent(
|
||||
after: error,
|
||||
expectedClient: client,
|
||||
expectedGeneration: generation
|
||||
)
|
||||
return .unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the `mobile.events.subscribe` (reason `start`) handshake for one
|
||||
/// listener generation, concurrently with that generation's consumer loop.
|
||||
///
|
||||
|
||||
+86
-13
@@ -1,6 +1,7 @@
|
||||
import CMUXMobileCore
|
||||
import CmuxMobilePairedMac
|
||||
import CmuxMobileRPC
|
||||
import CmuxMobileShellModel
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CmuxMobileShell
|
||||
@@ -541,51 +542,123 @@ import Testing
|
||||
return store
|
||||
}
|
||||
|
||||
@Test func tailscalePreferencePromotesGrantedRouteAheadOfIrohPin() throws {
|
||||
@Test func tailscaleMethodUsesOnlyGrantedTailscaleRoute() throws {
|
||||
let tailscale = try tailscale()
|
||||
let routes = MobileShellComposite.storedReconnectRoutes(
|
||||
[tailscale, try iroh()],
|
||||
supportedKinds: [.iroh, .tailscale],
|
||||
preferNonLoopback: true,
|
||||
tailscalePreference: MobileShellComposite.TailscaleRoutePreference(
|
||||
tailscaleRequirement: MobileShellComposite.TailscaleRouteRequirement(
|
||||
macDeviceID: "test-mac",
|
||||
grantRoutes: [tailscale]
|
||||
)
|
||||
)
|
||||
|
||||
// The granted Tailscale destination dials first; Iroh stays as the
|
||||
// fallback instead of being exclusive.
|
||||
#expect(routes.map(\.kind) == [.tailscale, .iroh])
|
||||
#expect(routes.map(\.kind) == [.tailscale])
|
||||
}
|
||||
|
||||
@Test func tailscalePreferenceWithoutGrantKeepsIrohExclusivePin() throws {
|
||||
// A preference flip alone grants nothing: without a device-local grant
|
||||
// the Iroh pin still drops every raw host/port fallback.
|
||||
@Test func tailscaleMethodWithoutGrantRejectsEveryRoute() throws {
|
||||
let routes = MobileShellComposite.storedReconnectRoutes(
|
||||
[try tailscale(), try iroh()],
|
||||
supportedKinds: [.iroh, .tailscale],
|
||||
preferNonLoopback: true,
|
||||
tailscalePreference: MobileShellComposite.TailscaleRoutePreference(
|
||||
tailscaleRequirement: MobileShellComposite.TailscaleRouteRequirement(
|
||||
macDeviceID: "test-mac",
|
||||
grantRoutes: []
|
||||
)
|
||||
)
|
||||
|
||||
#expect(routes.map(\.kind) == [.iroh])
|
||||
#expect(routes.isEmpty)
|
||||
}
|
||||
|
||||
@Test func tailscalePreferenceIgnoresGrantForDifferentDestination() throws {
|
||||
@Test func tailscaleMethodRejectsMismatchedGrantWithoutIrohFallback() throws {
|
||||
let otherDestination = try tailscale(50907)
|
||||
let routes = MobileShellComposite.storedReconnectRoutes(
|
||||
[try tailscale(), try iroh()],
|
||||
supportedKinds: [.iroh, .tailscale],
|
||||
preferNonLoopback: true,
|
||||
tailscalePreference: MobileShellComposite.TailscaleRoutePreference(
|
||||
tailscaleRequirement: MobileShellComposite.TailscaleRouteRequirement(
|
||||
macDeviceID: "test-mac",
|
||||
grantRoutes: [otherDestination]
|
||||
)
|
||||
)
|
||||
|
||||
#expect(routes.map(\.kind) == [.iroh])
|
||||
#expect(routes.isEmpty)
|
||||
}
|
||||
|
||||
@Test func changingToUnavailableTailscaleDropsLiveIrohWithoutFallback() async throws {
|
||||
let clock = TestClock()
|
||||
let router = LivenessHostRouter()
|
||||
// The factory boxes the live Iroh transport it hands out, so the test
|
||||
// can observe physical teardown, not just the store's logical route.
|
||||
let liveTransportBox = TransportBox()
|
||||
let factory = KindRecordingTransportFactory(
|
||||
router: router,
|
||||
box: liveTransportBox,
|
||||
failingKinds: [.tailscale]
|
||||
)
|
||||
let tailscale = try tailscale()
|
||||
let iroh = try iroh()
|
||||
let (pairedStore, directory) = try makePairedMacStore()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
try await pairedStore.upsert(
|
||||
macDeviceID: "test-mac",
|
||||
displayName: "Test Mac",
|
||||
routes: [tailscale, iroh],
|
||||
instanceTag: "default",
|
||||
markActive: true,
|
||||
stackUserID: "user-1",
|
||||
teamID: nil,
|
||||
now: clock.now
|
||||
)
|
||||
try await pairedStore.authorizeUserTailscaleRoutes(
|
||||
macDeviceID: "test-mac",
|
||||
instanceTag: "default",
|
||||
stackUserID: "user-1",
|
||||
teamID: nil,
|
||||
routes: [tailscale]
|
||||
)
|
||||
let methodDefaults = UserDefaults(
|
||||
suiteName: "connection-method-live-switch-\(UUID().uuidString)"
|
||||
)!
|
||||
let methodStore = MobileConnectionMethodStore(defaults: methodDefaults)
|
||||
let store = MobileShellComposite(
|
||||
runtime: LivenessTestRuntime(
|
||||
transportFactory: factory,
|
||||
now: { clock.now },
|
||||
supportedRouteKinds: [.iroh, .tailscale]
|
||||
),
|
||||
isSignedIn: true,
|
||||
pairedMacStore: pairedStore,
|
||||
connectionMethodStore: methodStore,
|
||||
identityProvider: StaticIdentityProvider(userID: "user-1"),
|
||||
reachability: AlwaysOnlineReachability(),
|
||||
pairingHintDefaults: UserDefaults(
|
||||
suiteName: "connection-method-pairing-hint-\(UUID().uuidString)"
|
||||
)!,
|
||||
hiddenMacStore: InMemoryPairedMacHiddenStore()
|
||||
)
|
||||
await store.loadPairedMacs()
|
||||
|
||||
#expect(await store.reconnectActiveMacIfAvailable(stackUserID: "user-1"))
|
||||
#expect(store.activeRoute?.kind == .iroh)
|
||||
#expect(factory.attemptedKinds().filter { $0 == .iroh }.count == 1)
|
||||
|
||||
methodStore.method = .tailscale
|
||||
|
||||
// `activeRoute == nil` only proves the store cleared its logical
|
||||
// route; the dropped live Iroh transport must also finish closing so
|
||||
// no physical cleanup work is still pending when the test completes.
|
||||
let applied = try await pollUntil {
|
||||
let liveTransportClosed =
|
||||
await liveTransportBox.get()?.isClosedForTesting() == true
|
||||
return factory.attemptedKinds().contains(.tailscale)
|
||||
&& store.connectionState == .disconnected
|
||||
&& store.activeRoute == nil
|
||||
&& liveTransportClosed
|
||||
}
|
||||
#expect(applied)
|
||||
#expect(store.activeRoute == nil)
|
||||
#expect(factory.attemptedKinds().filter { $0 == .iroh }.count == 1)
|
||||
}
|
||||
}
|
||||
|
||||
+30
-7
@@ -6,18 +6,18 @@ public enum MobileConnectionMethod: String, CaseIterable, Sendable {
|
||||
/// Dial the built-in encrypted peer-to-peer transport (direct paths with
|
||||
/// managed relays as fallback). The default; no setup required.
|
||||
case automatic
|
||||
/// Prefer the user's Tailscale network. Requires entering the Tailscale
|
||||
/// pairing code shown on the Mac once, which authorizes that exact peer.
|
||||
/// Require the user's Tailscale network. Requires entering the Tailscale
|
||||
/// pairing code shown on the Mac once, which authorizes that exact peer;
|
||||
/// Iroh is never used as a fallback while this method is selected.
|
||||
case tailscale
|
||||
}
|
||||
|
||||
/// Persists the user's connection-method choice.
|
||||
///
|
||||
/// The preference only reorders dialing: `tailscale` puts authorized Tailscale
|
||||
/// routes ahead of the automatic transport instead of the default pin that
|
||||
/// dials the automatic transport exclusively. It never manufactures Tailscale
|
||||
/// authorization by itself; a pairing code entry remains the authorization
|
||||
/// event for each Mac.
|
||||
/// The choice is exclusive: `automatic` uses the built-in encrypted transport,
|
||||
/// while `tailscale` dials only an authorized Tailscale route. It never
|
||||
/// manufactures Tailscale authorization by itself; a pairing code entry remains
|
||||
/// the authorization event for each Mac.
|
||||
///
|
||||
/// The backing `UserDefaults` is injected so the store is testable without
|
||||
/// touching `.standard`; the app constructs it at the composition root.
|
||||
@@ -29,12 +29,17 @@ public final class MobileConnectionMethodStore {
|
||||
|
||||
// UserDefaults is Apple-documented thread-safe; OK to hold nonisolated.
|
||||
private nonisolated(unsafe) let defaults: UserDefaults
|
||||
@ObservationIgnored private var continuations:
|
||||
[UUID: AsyncStream<MobileConnectionMethod>.Continuation] = [:]
|
||||
|
||||
/// The user's current connection-method choice.
|
||||
public var method: MobileConnectionMethod {
|
||||
didSet {
|
||||
guard method != oldValue else { return }
|
||||
defaults.set(method.rawValue, forKey: Self.methodKey)
|
||||
for continuation in continuations.values {
|
||||
continuation.yield(method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,4 +53,22 @@ public final class MobileConnectionMethodStore {
|
||||
self.method = .automatic
|
||||
}
|
||||
}
|
||||
|
||||
/// Observes connection-method changes, beginning with the current method.
|
||||
///
|
||||
/// Each subscriber owns an independent stream. Cancelling iteration removes
|
||||
/// that subscriber without affecting Settings or other connection owners.
|
||||
public func changes() -> AsyncStream<MobileConnectionMethod> {
|
||||
let id = UUID()
|
||||
let current = method
|
||||
return AsyncStream { continuation in
|
||||
continuations[id] = continuation
|
||||
continuation.yield(current)
|
||||
continuation.onTermination = { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.continuations[id] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ let package = Package(
|
||||
.package(path: "../CmuxMobileChanges"),
|
||||
.package(path: "../CmuxMobileDiagnostics"),
|
||||
.package(path: "../CmuxMobilePairedMac"),
|
||||
.package(path: "../CmuxMobileRPC"),
|
||||
.package(path: "../CmuxMobileShell"),
|
||||
.package(path: "../CmuxMobileShellModel"),
|
||||
.package(path: "../CmuxMobileSupport"),
|
||||
@@ -48,6 +49,7 @@ let package = Package(
|
||||
"CmuxMobileChanges",
|
||||
"CmuxMobileDiagnostics",
|
||||
"CmuxMobilePairedMac",
|
||||
"CmuxMobileRPC",
|
||||
"CmuxMobileShell",
|
||||
"CmuxMobileShellModel",
|
||||
"CmuxMobileSupport",
|
||||
@@ -69,6 +71,7 @@ let package = Package(
|
||||
"CMUXMobileCore",
|
||||
"CmuxAuthRuntime",
|
||||
"CmuxMobilePairedMac",
|
||||
"CmuxMobileRPC",
|
||||
"CmuxMobileShellUI",
|
||||
"CmuxAgentChat",
|
||||
"CmuxMobileShell",
|
||||
|
||||
@@ -123,6 +123,14 @@ struct CMUXMobileRootView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
private var shouldShowPushReadinessPreview: Bool {
|
||||
#if os(iOS) && DEBUG
|
||||
return UITestConfig.pushReadinessPreviewState != nil
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// A configured launch attach route (dev/UITest auto-pair) owns startup
|
||||
/// connections outright; background onboarding discovery must not race it.
|
||||
@@ -167,6 +175,16 @@ struct CMUXMobileRootView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var pushReadinessPreview: some View {
|
||||
#if os(iOS) && DEBUG
|
||||
MobilePushReadinessPreviewView(
|
||||
state: UITestConfig.pushReadinessPreviewState ?? "healthy"
|
||||
)
|
||||
#else
|
||||
EmptyView()
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var hiddenComputersPreview: some View {
|
||||
#if os(iOS) && DEBUG
|
||||
HiddenComputersPreviewView()
|
||||
@@ -308,7 +326,9 @@ struct CMUXMobileRootView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var rootContent: some View {
|
||||
if shouldShowChangesPreview {
|
||||
if shouldShowPushReadinessPreview {
|
||||
pushReadinessPreview
|
||||
} else if shouldShowChangesPreview {
|
||||
changesPreview
|
||||
} else if shouldShowHideComputersVerifier {
|
||||
hideComputersVerifier
|
||||
@@ -364,7 +384,10 @@ struct CMUXMobileRootView: View {
|
||||
signOut: signOut,
|
||||
showAddDevice: showAddDevice,
|
||||
showPairingScanner: showPairingScanner,
|
||||
reconnectStoredMac: reconnectStoredMacIfNeeded
|
||||
reconnectStoredMac: reconnectStoredMacIfNeeded,
|
||||
workspaceListDidBecomeVisible: {
|
||||
await pushCoordinator.workspaceListDidBecomeVisible()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -504,6 +527,8 @@ struct CMUXMobileRootView: View {
|
||||
connectionPhase: UITestConfig.onboardingConnectionFallbackEnabled
|
||||
? .fallback
|
||||
: .searching,
|
||||
connectionMethod: connectionMethodStore?.method ?? .automatic,
|
||||
onSelectConnectionMethod: { connectionMethodStore?.method = $0 },
|
||||
onReachedConnection: markOnboardingReadyToConnect,
|
||||
onSkip: completeOnboarding,
|
||||
onRetryConnection: {},
|
||||
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
#if os(iOS) && DEBUG
|
||||
import CmuxAuthRuntime
|
||||
import CmuxMobileRPC
|
||||
import CmuxMobileSupport
|
||||
import SwiftUI
|
||||
|
||||
/// Deterministic production-settings harness for XCUITest.
|
||||
///
|
||||
/// It mounts the same ``MobilePushSettingsContent`` used by Settings. Only the
|
||||
/// network/OS seams are fixtures, so accessibility, localization, optimistic
|
||||
/// mutation, rollback, and every rendered repair action remain production code.
|
||||
struct MobilePushReadinessPreviewView: View {
|
||||
private let fixture: Fixture
|
||||
private let rejectsMacMutations: Bool
|
||||
|
||||
@State private var phoneEnabled: Bool
|
||||
@State private var authorization: MobilePushAuthorization
|
||||
@State private var registration: PushRegistrationSnapshot
|
||||
@State private var macStatus: MobileHostPhonePushStatus?
|
||||
|
||||
init(state: String, environment: [String: String] = ProcessInfo.processInfo.environment) {
|
||||
let fixture = Fixture(rawValue: state) ?? .healthy
|
||||
self.fixture = fixture
|
||||
self.rejectsMacMutations = environment["CMUX_UITEST_PUSH_MUTATION_FAILURE"] == "1"
|
||||
self._phoneEnabled = State(initialValue: fixture.registration.isEnabled)
|
||||
self._authorization = State(initialValue: fixture.authorization)
|
||||
self._registration = State(initialValue: fixture.registration)
|
||||
self._macStatus = State(initialValue: fixture.macStatus)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(L10n.string(
|
||||
"mobile.settings.notifications",
|
||||
defaultValue: "Push Alerts"
|
||||
)) {
|
||||
MobilePushSettingsContent(
|
||||
readiness: readiness,
|
||||
phoneEnabled: $phoneEnabled,
|
||||
macStatus: macStatus,
|
||||
supportsMacSettings: macStatus != nil,
|
||||
supportsMacTest: macStatus != nil,
|
||||
onPhoneEnabledChange: setPhoneEnabled,
|
||||
onRepair: repair,
|
||||
onMacMutation: mutateMac,
|
||||
onSendTest: { .queuedOnMac }
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle(L10n.string(
|
||||
"mobile.workspaces.settings",
|
||||
defaultValue: "Settings"
|
||||
))
|
||||
}
|
||||
.accessibilityIdentifier("MobilePushReadinessPreview")
|
||||
}
|
||||
|
||||
private var readiness: MobilePushReadiness {
|
||||
MobilePushReadiness.resolve(
|
||||
authorization: authorization,
|
||||
registration: registration,
|
||||
mac: macStatus.map(MobilePushReadiness.MacStatus.init),
|
||||
systemSettings: .authorizationOnly(authorization),
|
||||
phoneAPIOrigin: Self.apiOrigin
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func setPhoneEnabled(_ enabled: Bool) async -> Bool {
|
||||
phoneEnabled = enabled
|
||||
registration = enabled
|
||||
? Self.registered
|
||||
: .disabled
|
||||
return true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func repair(_ repair: MobilePushReadiness.Repair) async -> Bool {
|
||||
switch repair {
|
||||
case .enableOnPhone:
|
||||
return await setPhoneEnabled(true)
|
||||
case .retryDeviceTokenRegistration, .retryRegistration:
|
||||
registration = Self.registered
|
||||
return true
|
||||
case .connectMac:
|
||||
macStatus = Self.healthyMac
|
||||
return true
|
||||
case .enableOnMac:
|
||||
return await mutateMac(.forwardingEnabled(true))
|
||||
case .leaveMacOrUseAlwaysMode:
|
||||
return await mutateMac(.mode(.always))
|
||||
case .openSystemSettings, .signInAgain, .finishAccountDeletion,
|
||||
.disablePushOnAnotherDevice, .signIntoMatchingAccount,
|
||||
.rebuildMatchingApps, .waitForDeviceToken:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func mutateMac(_ mutation: MobilePushMacMutation) async -> Bool {
|
||||
guard !rejectsMacMutations, let current = macStatus else {
|
||||
return false
|
||||
}
|
||||
let forwardingEnabled: Bool
|
||||
let mode: MobileHostPhonePushStatus.Mode
|
||||
let hideContent: Bool
|
||||
switch mutation {
|
||||
case let .forwardingEnabled(value):
|
||||
forwardingEnabled = value
|
||||
mode = current.mode
|
||||
hideContent = current.hideContent
|
||||
case let .mode(value):
|
||||
forwardingEnabled = current.forwardingEnabled
|
||||
mode = value
|
||||
hideContent = current.hideContent
|
||||
case let .hideContent(value):
|
||||
forwardingEnabled = current.forwardingEnabled
|
||||
mode = current.mode
|
||||
hideContent = value
|
||||
}
|
||||
macStatus = MobileHostPhonePushStatus(
|
||||
forwardingEnabled: forwardingEnabled,
|
||||
mode: mode,
|
||||
admission: forwardingEnabled ? .allowed : .forwardingDisabled,
|
||||
queuePersistence: current.queuePersistence,
|
||||
hideContent: hideContent,
|
||||
apiOrigin: current.apiOrigin,
|
||||
accountScope: current.accountScope
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private enum Fixture: String {
|
||||
case healthy
|
||||
case osDenied = "os_denied"
|
||||
case backendRetry = "backend_retry"
|
||||
case macForwardingOff = "mac_forwarding_off"
|
||||
case macUnavailable = "mac_unavailable"
|
||||
case limitedProvisional = "limited_provisional"
|
||||
|
||||
var authorization: MobilePushAuthorization {
|
||||
switch self {
|
||||
case .osDenied: .denied
|
||||
case .limitedProvisional: .provisional
|
||||
case .healthy, .backendRetry, .macForwardingOff, .macUnavailable:
|
||||
.authorized
|
||||
}
|
||||
}
|
||||
|
||||
var registration: PushRegistrationSnapshot {
|
||||
switch self {
|
||||
case .backendRetry:
|
||||
PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .failed(.serviceUnavailable)
|
||||
)
|
||||
case .healthy, .osDenied, .macForwardingOff, .macUnavailable,
|
||||
.limitedProvisional:
|
||||
MobilePushReadinessPreviewView.registered
|
||||
}
|
||||
}
|
||||
|
||||
var macStatus: MobileHostPhonePushStatus? {
|
||||
switch self {
|
||||
case .macUnavailable:
|
||||
nil
|
||||
case .macForwardingOff:
|
||||
MobileHostPhonePushStatus(
|
||||
forwardingEnabled: false,
|
||||
mode: .onlyWhenAway,
|
||||
admission: .forwardingDisabled,
|
||||
queuePersistence: .healthy,
|
||||
apiOrigin: MobilePushReadinessPreviewView.apiOrigin,
|
||||
accountScope: .verifiedSameAccount
|
||||
)
|
||||
case .healthy, .osDenied, .backendRetry, .limitedProvisional:
|
||||
MobilePushReadinessPreviewView.healthyMac
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let apiOrigin = "https://cmux.com"
|
||||
private static let registered = PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .registered
|
||||
)
|
||||
private static let healthyMac = MobileHostPhonePushStatus(
|
||||
forwardingEnabled: true,
|
||||
mode: .onlyWhenAway,
|
||||
admission: .allowed,
|
||||
queuePersistence: .healthy,
|
||||
apiOrigin: apiOrigin,
|
||||
accountScope: .verifiedSameAccount
|
||||
)
|
||||
}
|
||||
#endif
|
||||
+4
-3
@@ -27,7 +27,7 @@ struct MobileConnectionMethodSection: View {
|
||||
.tag(MobileConnectionMethod.automatic)
|
||||
Text(L10n.string(
|
||||
"mobile.settings.connectionMethod.tailscale",
|
||||
defaultValue: "Tailscale"
|
||||
defaultValue: "Tailscale Only"
|
||||
))
|
||||
.tag(MobileConnectionMethod.tailscale)
|
||||
}
|
||||
@@ -62,8 +62,9 @@ struct MobileConnectionMethodSection: View {
|
||||
L10n.string(
|
||||
"mobile.settings.connectionMethod.tailscaleFooter",
|
||||
defaultValue: """
|
||||
Install Tailscale on this iPhone and your Mac, then connect both to the same Tailscale network. \
|
||||
On your Mac, open Tailscale Pairing in cmux to show the QR, then scan it here once.
|
||||
Connects only over Tailscale. If unavailable, cmux stays disconnected instead of using Iroh. \
|
||||
Install Tailscale on both devices, join the same network, then scan the pairing code shown by \
|
||||
cmux on your Mac once.
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
+322
-19
@@ -1,6 +1,7 @@
|
||||
#if os(iOS)
|
||||
import CMUXMobileCore
|
||||
import CmuxAuthRuntime
|
||||
import CmuxMobileRPC
|
||||
import CmuxMobileShell
|
||||
import CmuxMobileShellModel
|
||||
import Foundation
|
||||
@@ -36,6 +37,7 @@ public final class MobilePushCoordinator {
|
||||
// the opt-in flag for the menu UI without awaiting the actor service.
|
||||
private nonisolated(unsafe) let defaults: UserDefaults
|
||||
private static let enabledKey = "cmux.notifications.pushEnabled"
|
||||
private var enabledMirror: Bool
|
||||
|
||||
/// APNs `aps.category` the web sets on every cmux terminal push (see
|
||||
/// `CMUX_APNS_CATEGORY` in `web/services/apns/payload.ts`). The matching
|
||||
@@ -68,6 +70,30 @@ public final class MobilePushCoordinator {
|
||||
/// launch plus sign-in plus a slow attach.
|
||||
private static let pendingDeeplinkLifetime: TimeInterval = 120
|
||||
@ObservationIgnored private let now: () -> Date
|
||||
/// The iOS API endpoint that accepted this installation's APNs token.
|
||||
public let phoneAPIOrigin: String
|
||||
/// Live OS authorization, refreshed at launch, on foreground, and when
|
||||
/// Settings opens or performs a repair.
|
||||
public private(set) var authorization: MobilePushAuthorization = .notDetermined
|
||||
/// Live independent iOS presentation policies. Authorization alone is not
|
||||
/// enough to promise a visible, audible, timely banner.
|
||||
public private(set) var systemSettings = MobilePushSystemSettings
|
||||
.authorizationOnly(.notDetermined)
|
||||
/// Local/APNs/backend registration stage streamed from the actor service.
|
||||
public private(set) var registrationSnapshot: PushRegistrationSnapshot = .disabled
|
||||
@ObservationIgnored private let notificationSettings:
|
||||
@MainActor () async -> MobilePushSystemSettings
|
||||
@ObservationIgnored private let requestAuthorization:
|
||||
@MainActor () async -> Bool
|
||||
@ObservationIgnored private let registerForRemoteNotifications:
|
||||
@MainActor () -> Void
|
||||
@ObservationIgnored private let unregisterForRemoteNotifications:
|
||||
@MainActor () -> Void
|
||||
@ObservationIgnored private var registrationSnapshotTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var registrationRecoveryTask:
|
||||
Task<PushRegistrationSnapshot, Never>?
|
||||
@ObservationIgnored private var workspaceAuthorizationRequestInFlight = false
|
||||
@ObservationIgnored private var hasRequestedRemoteRegistration = false
|
||||
|
||||
/// Creates a push coordinator.
|
||||
/// - Parameters:
|
||||
@@ -87,21 +113,55 @@ public final class MobilePushCoordinator {
|
||||
public init(
|
||||
registration: any PushRegistering,
|
||||
analytics: any AnalyticsEmitting = NoopAnalytics(),
|
||||
phoneAPIOrigin: String = "https://cmux.com",
|
||||
defaults: UserDefaults = .standard,
|
||||
deliveredNotificationClearer: any DeliveredNotificationClearing = SystemDeliveredNotificationClearer(),
|
||||
pendingDismissQueue: PendingNotificationDismissQueue = PendingNotificationDismissQueue(),
|
||||
now: @escaping () -> Date = Date.init
|
||||
now: @escaping () -> Date = Date.init,
|
||||
authorizationStatus: (@MainActor () async -> UNAuthorizationStatus)? = nil,
|
||||
notificationSettings: (@MainActor () async -> MobilePushSystemSettings)? = nil,
|
||||
requestAuthorization: @escaping @MainActor () async -> Bool = {
|
||||
(try? await UNUserNotificationCenter.current()
|
||||
.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
||||
},
|
||||
registerForRemoteNotifications: @escaping @MainActor () -> Void = {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
},
|
||||
unregisterForRemoteNotifications: @escaping @MainActor () -> Void = {
|
||||
UIApplication.shared.unregisterForRemoteNotifications()
|
||||
}
|
||||
) {
|
||||
self.registration = registration
|
||||
self.analytics = analytics
|
||||
self.phoneAPIOrigin = phoneAPIOrigin
|
||||
self.defaults = defaults
|
||||
self.enabledMirror = defaults.bool(forKey: Self.enabledKey)
|
||||
self.deliveredNotificationClearer = deliveredNotificationClearer
|
||||
self.pendingDismissQueue = pendingDismissQueue
|
||||
self.now = now
|
||||
if let notificationSettings {
|
||||
self.notificationSettings = notificationSettings
|
||||
} else if let authorizationStatus {
|
||||
self.notificationSettings = {
|
||||
.authorizationOnly(
|
||||
Self.authorization(from: await authorizationStatus())
|
||||
)
|
||||
}
|
||||
} else {
|
||||
self.notificationSettings = {
|
||||
Self.systemSettings(
|
||||
from: await UNUserNotificationCenter.current()
|
||||
.notificationSettings()
|
||||
)
|
||||
}
|
||||
}
|
||||
self.requestAuthorization = requestAuthorization
|
||||
self.registerForRemoteNotifications = registerForRemoteNotifications
|
||||
self.unregisterForRemoteNotifications = unregisterForRemoteNotifications
|
||||
}
|
||||
|
||||
/// Whether the user has opted into phone notifications (synchronous mirror).
|
||||
public var isEnabled: Bool { defaults.bool(forKey: Self.enabledKey) }
|
||||
public var isEnabled: Bool { enabledMirror }
|
||||
|
||||
/// Point routing at the active store (called by the root view on appear).
|
||||
public func bind(store: CMUXMobileShellStore) {
|
||||
@@ -116,10 +176,10 @@ public final class MobilePushCoordinator {
|
||||
applyPendingDeeplinkIfReady()
|
||||
}
|
||||
|
||||
/// Install the notification-center delegate, register the dismiss-sync
|
||||
/// notification category, and, if already opted in, re-assert remote
|
||||
/// registration so a rotated token re-uploads. Call once at launch from the
|
||||
/// AppDelegate.
|
||||
/// Install the notification-center delegate and dismiss-sync category, then
|
||||
/// start live readiness observation. The workspace/foreground lifecycle
|
||||
/// requests APNs registration after system authorization permits delivery.
|
||||
/// Call once at launch from the AppDelegate.
|
||||
public func configure(delegate: any UNUserNotificationCenterDelegate) {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.delegate = delegate
|
||||
@@ -133,55 +193,285 @@ public final class MobilePushCoordinator {
|
||||
options: [.customDismissAction]
|
||||
)
|
||||
center.setNotificationCategories([dismissSyncCategory])
|
||||
if isEnabled {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
startRegistrationSnapshotObservation()
|
||||
Task { await refreshReadiness() }
|
||||
}
|
||||
|
||||
/// Opt in: request system authorization, register for remote notifications,
|
||||
/// and persist the flag. Returns whether authorization was granted.
|
||||
@discardableResult
|
||||
public func enable() async -> Bool {
|
||||
let priorStatus = await UNUserNotificationCenter.current()
|
||||
.notificationSettings().authorizationStatus
|
||||
await enable(trigger: "settings_toggle")
|
||||
}
|
||||
|
||||
/// Requests or recovers push only after the authenticated workspace shell
|
||||
/// is mounted. An explicit app opt-out remains authoritative.
|
||||
public func workspaceListDidBecomeVisible() async {
|
||||
if defaults.object(forKey: Self.enabledKey) as? Bool == false {
|
||||
return
|
||||
}
|
||||
let settings = await notificationSettings()
|
||||
apply(settings: settings)
|
||||
switch settings.authorization {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
persistEnabledIntent()
|
||||
await activateRegistrationIfNeeded()
|
||||
await recoverRegistrationIfNeeded()
|
||||
case .denied:
|
||||
// Preserve intent so Settings can explain the blocked OS gate and
|
||||
// a later foreground return can recover without another app launch.
|
||||
persistEnabledIntent()
|
||||
case .notDetermined:
|
||||
guard !workspaceAuthorizationRequestInFlight else { return }
|
||||
workspaceAuthorizationRequestInFlight = true
|
||||
defer { workspaceAuthorizationRequestInFlight = false }
|
||||
_ = await enable(trigger: "workspace_list")
|
||||
case .unsupported:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func enable(trigger: String) async -> Bool {
|
||||
let priorSettings = await notificationSettings()
|
||||
apply(settings: priorSettings)
|
||||
let priorStatus = priorSettings.authorization
|
||||
persistEnabledIntent()
|
||||
// Only an undetermined status produces a real OS prompt; gate the
|
||||
// "shown" event on it so a re-toggle of an already-decided status does
|
||||
// not log a phantom prompt.
|
||||
if priorStatus == .notDetermined {
|
||||
analytics.capture("ios_push_optin_prompt_shown", [
|
||||
"trigger": .string("settings_toggle"),
|
||||
"trigger": .string(trigger),
|
||||
"prior_authorization_status": .string("not_determined"),
|
||||
])
|
||||
}
|
||||
let granted = (try? await UNUserNotificationCenter.current()
|
||||
.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
||||
let granted: Bool
|
||||
switch priorStatus {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
granted = true
|
||||
case .notDetermined:
|
||||
granted = await requestAuthorization()
|
||||
case .denied, .unsupported:
|
||||
granted = false
|
||||
}
|
||||
guard granted else {
|
||||
await refreshReadiness()
|
||||
analytics.capture("ios_push_optin_declined", [
|
||||
"trigger": .string("settings_toggle"),
|
||||
"trigger": .string(trigger),
|
||||
"was_os_level_predenied": .bool(priorStatus == .denied),
|
||||
])
|
||||
return false
|
||||
}
|
||||
analytics.capture("ios_push_optin_granted", ["trigger": .string("settings_toggle")])
|
||||
await registration.setEnabled(true)
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
if priorStatus == .notDetermined {
|
||||
apply(settings: await notificationSettings())
|
||||
}
|
||||
analytics.capture("ios_push_optin_granted", ["trigger": .string(trigger)])
|
||||
await activateRegistrationIfNeeded()
|
||||
await recoverRegistrationIfNeeded()
|
||||
return true
|
||||
}
|
||||
|
||||
/// Opt out: stop receiving pushes and remove the token server-side.
|
||||
public func disable() async {
|
||||
enabledMirror = false
|
||||
registrationSnapshot = .disabled
|
||||
hasRequestedRemoteRegistration = false
|
||||
unregisterForRemoteNotifications()
|
||||
// The production registration service owns this same persisted key
|
||||
// and checks its previous value to decide whether server cleanup is
|
||||
// required. Let it observe the prior `true` before mirroring the final
|
||||
// preference here; writing `false` first would skip token removal.
|
||||
await registration.setEnabled(false)
|
||||
UIApplication.shared.unregisterForRemoteNotifications()
|
||||
defaults.set(false, forKey: Self.enabledKey)
|
||||
registrationSnapshot = await registration.snapshot
|
||||
}
|
||||
|
||||
/// Hand a freshly-registered APNs token to the network layer.
|
||||
public func handleDeviceToken(_ token: Data) async {
|
||||
await registration.register(deviceToken: token)
|
||||
registrationSnapshot = await registration.snapshot
|
||||
}
|
||||
|
||||
/// Make the APNs callback failure visible without retaining Apple's
|
||||
/// free-form error text, which can contain unstable device details.
|
||||
public func handleDeviceTokenFailure() async {
|
||||
await registration.deviceTokenRegistrationFailed()
|
||||
registrationSnapshot = await registration.snapshot
|
||||
}
|
||||
|
||||
/// User-triggered repair for a failed APNs token callback.
|
||||
public func retryDeviceTokenRegistration() {
|
||||
hasRequestedRemoteRegistration = true
|
||||
registerForRemoteNotifications()
|
||||
}
|
||||
|
||||
/// Re-upload the cached token when possible (e.g. after sign-in).
|
||||
public func syncTokenIfPossible() async {
|
||||
await registration.syncTokenIfPossible()
|
||||
registrationSnapshot = await registration.snapshot
|
||||
}
|
||||
|
||||
/// Refreshes live OS authorization and the current registration stage.
|
||||
///
|
||||
/// Call on every foreground transition because users can revoke permission
|
||||
/// in iOS Settings while cmux is suspended.
|
||||
public func refreshReadiness() async {
|
||||
let settings = await notificationSettings()
|
||||
apply(settings: settings)
|
||||
if enabledMirror, Self.permitsDelivery(settings.authorization) {
|
||||
await activateRegistrationIfNeeded()
|
||||
}
|
||||
await recoverRegistrationIfNeeded()
|
||||
}
|
||||
|
||||
private func persistEnabledIntent() {
|
||||
enabledMirror = true
|
||||
defaults.set(true, forKey: Self.enabledKey)
|
||||
}
|
||||
|
||||
private func apply(settings: MobilePushSystemSettings) {
|
||||
systemSettings = settings
|
||||
authorization = settings.authorization
|
||||
}
|
||||
|
||||
private func activateRegistrationIfNeeded() async {
|
||||
guard enabledMirror, Self.permitsDelivery(authorization) else { return }
|
||||
let current = await registration.snapshot
|
||||
registrationSnapshot = PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: current.hasDeviceToken,
|
||||
backendState: current.hasDeviceToken
|
||||
? .registrationRequired
|
||||
: .awaitingDeviceToken
|
||||
)
|
||||
requestRemoteRegistrationIfNeeded()
|
||||
if !current.isEnabled {
|
||||
await registration.setEnabled(true)
|
||||
}
|
||||
registrationSnapshot = await registration.snapshot
|
||||
}
|
||||
|
||||
private func requestRemoteRegistrationIfNeeded() {
|
||||
guard !hasRequestedRemoteRegistration else { return }
|
||||
hasRequestedRemoteRegistration = true
|
||||
registerForRemoteNotifications()
|
||||
}
|
||||
|
||||
private static func permitsDelivery(
|
||||
_ authorization: MobilePushAuthorization
|
||||
) -> Bool {
|
||||
switch authorization {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
true
|
||||
case .notDetermined, .denied, .unsupported:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Retries an exhausted registration when a meaningful network path
|
||||
/// change reports that the API may be reachable again.
|
||||
public func networkDidBecomeReachable() async {
|
||||
await recoverRegistrationIfNeeded()
|
||||
}
|
||||
|
||||
private func recoverRegistrationIfNeeded() async {
|
||||
let current = await registration.snapshot
|
||||
registrationSnapshot = current
|
||||
guard current.isEnabled, current.hasDeviceToken,
|
||||
current.backendState == .registrationRequired
|
||||
|| current.backendState.isRecoverable
|
||||
else { return }
|
||||
|
||||
let recovery: Task<PushRegistrationSnapshot, Never>
|
||||
let ownsRecovery: Bool
|
||||
if let registrationRecoveryTask {
|
||||
recovery = registrationRecoveryTask
|
||||
ownsRecovery = false
|
||||
} else {
|
||||
let registration = self.registration
|
||||
recovery = Task {
|
||||
await registration.syncTokenIfPossible()
|
||||
return await registration.snapshot
|
||||
}
|
||||
registrationRecoveryTask = recovery
|
||||
ownsRecovery = true
|
||||
}
|
||||
let recovered = await recovery.value
|
||||
if ownsRecovery {
|
||||
registrationRecoveryTask = nil
|
||||
}
|
||||
registrationSnapshot = recovered
|
||||
}
|
||||
|
||||
/// Computes readiness against the currently focused Mac's authenticated
|
||||
/// status. A missing Mac status fails closed.
|
||||
public func readiness(
|
||||
macStatus: MobileHostPhonePushStatus?,
|
||||
macAccountMismatch: Bool = false
|
||||
) -> MobilePushReadiness {
|
||||
MobilePushReadiness.resolve(
|
||||
authorization: authorization,
|
||||
registration: registrationSnapshot,
|
||||
mac: macStatus.map(MobilePushReadiness.MacStatus.init),
|
||||
macAccountMismatch: macAccountMismatch,
|
||||
systemSettings: systemSettings,
|
||||
phoneAPIOrigin: phoneAPIOrigin
|
||||
)
|
||||
}
|
||||
|
||||
/// Opens this app's iOS notification settings for a denied authorization.
|
||||
public func openSystemSettings() {
|
||||
guard let url = URL(
|
||||
string: UIApplication.openNotificationSettingsURLString
|
||||
) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
private func startRegistrationSnapshotObservation() {
|
||||
registrationSnapshotTask?.cancel()
|
||||
registrationSnapshotTask = Task { [weak self, registration] in
|
||||
let snapshots = await registration.snapshots()
|
||||
for await snapshot in snapshots {
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.registrationSnapshot = snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func authorization(
|
||||
from status: UNAuthorizationStatus
|
||||
) -> MobilePushAuthorization {
|
||||
switch status {
|
||||
case .notDetermined:
|
||||
.notDetermined
|
||||
case .denied:
|
||||
.denied
|
||||
case .authorized:
|
||||
.authorized
|
||||
case .provisional:
|
||||
.provisional
|
||||
case .ephemeral:
|
||||
.ephemeral
|
||||
@unknown default:
|
||||
.unsupported
|
||||
}
|
||||
}
|
||||
|
||||
private static func systemSettings(
|
||||
from settings: UNNotificationSettings
|
||||
) -> MobilePushSystemSettings {
|
||||
MobilePushSystemSettings(
|
||||
authorization: authorization(from: settings.authorizationStatus),
|
||||
alertsEnabled: settings.alertSetting == .enabled,
|
||||
soundsEnabled: settings.soundSetting == .enabled,
|
||||
badgesEnabled: settings.badgeSetting == .enabled,
|
||||
lockScreenEnabled: settings.lockScreenSetting == .enabled,
|
||||
notificationCenterEnabled:
|
||||
settings.notificationCenterSetting == .enabled,
|
||||
timeSensitiveEnabled: settings.timeSensitiveSetting == .enabled,
|
||||
scheduledDeliveryEnabled:
|
||||
settings.scheduledDeliverySetting == .enabled
|
||||
)
|
||||
}
|
||||
|
||||
/// Remove the cached token from the server (on sign-out), authenticating
|
||||
@@ -191,6 +481,19 @@ public final class MobilePushCoordinator {
|
||||
await registration.unregisterFromServer(accessToken: accessToken, refreshToken: refreshToken)
|
||||
}
|
||||
|
||||
/// Sign-out cleanup pinned to the user id captured before auth clear.
|
||||
public func unregisterFromServer(
|
||||
accountID: String?,
|
||||
accessToken: String?,
|
||||
refreshToken: String?
|
||||
) async {
|
||||
await registration.unregisterFromServer(
|
||||
accountID: accountID,
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether to show a banner while the app is foreground. Suppressed when the
|
||||
/// user is already viewing the terminal the notification is about.
|
||||
public func shouldPresentInForeground(workspaceId: String?, surfaceId: String?) -> Bool {
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
import CmuxAuthRuntime
|
||||
import CmuxMobileRPC
|
||||
import Foundation
|
||||
|
||||
/// The live iOS notification authorization state used by push readiness.
|
||||
public enum MobilePushAuthorization: Equatable, Sendable {
|
||||
case notDetermined
|
||||
case denied
|
||||
case authorized
|
||||
case provisional
|
||||
case ephemeral
|
||||
case unsupported
|
||||
|
||||
fileprivate var permitsDelivery: Bool {
|
||||
switch self {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
true
|
||||
case .notDetermined, .denied, .unsupported:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User-controlled notification presentation switches read live from iOS.
|
||||
///
|
||||
/// Authorization can remain `.authorized` while the user independently turns
|
||||
/// off alerts, sound, badges, Lock Screen, Notification Center, Time Sensitive,
|
||||
/// or enables Scheduled Delivery. Keeping those controls separate prevents a
|
||||
/// healthy APNs pipeline from being mislabeled as a fully visible banner path.
|
||||
public struct MobilePushSystemSettings: Equatable, Sendable {
|
||||
public let authorization: MobilePushAuthorization
|
||||
public let alertsEnabled: Bool
|
||||
public let soundsEnabled: Bool
|
||||
public let badgesEnabled: Bool
|
||||
public let lockScreenEnabled: Bool
|
||||
public let notificationCenterEnabled: Bool
|
||||
public let timeSensitiveEnabled: Bool
|
||||
public let scheduledDeliveryEnabled: Bool
|
||||
|
||||
public init(
|
||||
authorization: MobilePushAuthorization,
|
||||
alertsEnabled: Bool,
|
||||
soundsEnabled: Bool,
|
||||
badgesEnabled: Bool,
|
||||
lockScreenEnabled: Bool,
|
||||
notificationCenterEnabled: Bool,
|
||||
timeSensitiveEnabled: Bool,
|
||||
scheduledDeliveryEnabled: Bool
|
||||
) {
|
||||
self.authorization = authorization
|
||||
self.alertsEnabled = alertsEnabled
|
||||
self.soundsEnabled = soundsEnabled
|
||||
self.badgesEnabled = badgesEnabled
|
||||
self.lockScreenEnabled = lockScreenEnabled
|
||||
self.notificationCenterEnabled = notificationCenterEnabled
|
||||
self.timeSensitiveEnabled = timeSensitiveEnabled
|
||||
self.scheduledDeliveryEnabled = scheduledDeliveryEnabled
|
||||
}
|
||||
|
||||
public static func authorizationOnly(
|
||||
_ authorization: MobilePushAuthorization
|
||||
) -> Self {
|
||||
Self(
|
||||
authorization: authorization,
|
||||
alertsEnabled: true,
|
||||
soundsEnabled: true,
|
||||
badgesEnabled: true,
|
||||
lockScreenEnabled: true,
|
||||
notificationCenterEnabled: true,
|
||||
timeSensitiveEnabled: true,
|
||||
scheduledDeliveryEnabled: false
|
||||
)
|
||||
}
|
||||
|
||||
public var presentationLimitations: Set<MobilePushPresentationLimitation> {
|
||||
var result: Set<MobilePushPresentationLimitation> = []
|
||||
if !alertsEnabled { result.insert(.alertsDisabled) }
|
||||
if !soundsEnabled { result.insert(.soundsDisabled) }
|
||||
if !badgesEnabled { result.insert(.badgesDisabled) }
|
||||
if !lockScreenEnabled { result.insert(.lockScreenDisabled) }
|
||||
if !notificationCenterEnabled {
|
||||
result.insert(.notificationCenterDisabled)
|
||||
}
|
||||
if !timeSensitiveEnabled { result.insert(.timeSensitiveDisabled) }
|
||||
if scheduledDeliveryEnabled && !timeSensitiveEnabled {
|
||||
result.insert(.scheduledDeliveryEnabled)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/// An iOS policy that can make a healthy pipeline quiet, delayed, or partial.
|
||||
public enum MobilePushPresentationLimitation:
|
||||
String,
|
||||
CaseIterable,
|
||||
Hashable,
|
||||
Sendable
|
||||
{
|
||||
case alertsDisabled
|
||||
case soundsDisabled
|
||||
case badgesDisabled
|
||||
case lockScreenDisabled
|
||||
case notificationCenterDisabled
|
||||
case timeSensitiveDisabled
|
||||
case scheduledDeliveryEnabled
|
||||
}
|
||||
|
||||
/// End-to-end readiness for Mac notification delivery on this iOS device.
|
||||
///
|
||||
/// A local opt-in is deliberately insufficient. Readiness requires the live OS
|
||||
/// permission, an APNs token acknowledged by the backend, and authenticated
|
||||
/// status from the currently attached Mac proving its independent privacy gate,
|
||||
/// account scope, mode, and API endpoint all agree.
|
||||
public enum MobilePushReadiness: Equatable, Sendable {
|
||||
/// Every gate passed, including the attached Mac's current presence mode.
|
||||
case ready(mode: MobileHostPhonePushStatus.Mode)
|
||||
/// APNs can deliver, but provisional/ephemeral authorization is quiet or
|
||||
/// temporary rather than a fully authorized banner path.
|
||||
case limited(
|
||||
mode: MobileHostPhonePushStatus.Mode,
|
||||
authorization: MobilePushAuthorization
|
||||
)
|
||||
/// Every transport gate passed, but one or more independent iOS
|
||||
/// presentation policies can make delivery quiet, delayed, or invisible.
|
||||
case presentationLimited(
|
||||
mode: MobileHostPhonePushStatus.Mode,
|
||||
limitations: Set<MobilePushPresentationLimitation>
|
||||
)
|
||||
/// The live path is eligible, but the Mac cannot currently prove that its
|
||||
/// bounded retry queue will survive a process restart.
|
||||
case reliabilityLimited(
|
||||
mode: MobileHostPhonePushStatus.Mode,
|
||||
queuePersistence: MobileHostPhonePushStatus.QueuePersistence
|
||||
)
|
||||
/// Delivery stopped at the named gate.
|
||||
case blocked(Blocker)
|
||||
|
||||
/// Authenticated readiness fields read from the attached Mac.
|
||||
public struct MacStatus: Equatable, Sendable {
|
||||
public let forwardingEnabled: Bool
|
||||
public let mode: MobileHostPhonePushStatus.Mode
|
||||
public let admission: MobileHostPhonePushStatus.Admission
|
||||
public let queuePersistence:
|
||||
MobileHostPhonePushStatus.QueuePersistence
|
||||
public let apiOrigin: String
|
||||
public let accountVerified: Bool
|
||||
|
||||
public init(
|
||||
forwardingEnabled: Bool,
|
||||
mode: MobileHostPhonePushStatus.Mode,
|
||||
admission: MobileHostPhonePushStatus.Admission = .allowed,
|
||||
queuePersistence:
|
||||
MobileHostPhonePushStatus.QueuePersistence = .healthy,
|
||||
apiOrigin: String,
|
||||
accountVerified: Bool
|
||||
) {
|
||||
self.forwardingEnabled = forwardingEnabled
|
||||
self.mode = mode
|
||||
self.admission = admission
|
||||
self.queuePersistence = queuePersistence
|
||||
self.apiOrigin = apiOrigin
|
||||
self.accountVerified = accountVerified
|
||||
}
|
||||
|
||||
public init(_ status: MobileHostPhonePushStatus) {
|
||||
self.init(
|
||||
forwardingEnabled: status.forwardingEnabled,
|
||||
mode: status.mode,
|
||||
admission: status.admission,
|
||||
queuePersistence: status.queuePersistence,
|
||||
apiOrigin: status.apiOrigin,
|
||||
accountVerified: status.accountScope == .verifiedSameAccount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The first gate that currently prevents end-to-end delivery.
|
||||
public enum Blocker: Equatable, Sendable {
|
||||
case phoneOptInDisabled
|
||||
case systemPermissionNotRequested
|
||||
case systemPermissionDenied
|
||||
case systemNotificationsUnsupported
|
||||
case awaitingDeviceToken
|
||||
case deviceTokenRegistrationFailed
|
||||
case registeringDevice
|
||||
case backendRegistrationRequired
|
||||
case authenticationRequired
|
||||
case accountDeletionInProgress
|
||||
case registrationRateLimited
|
||||
case deviceLimitReached(limit: Int)
|
||||
case networkUnavailable
|
||||
case pushServiceUnavailable
|
||||
case invalidConfiguration
|
||||
case invalidServerResponse
|
||||
case registrationRejected
|
||||
case macStatusUnavailable
|
||||
case macAccountMismatch
|
||||
case macForwardingDisabled
|
||||
case macCurrentlyActive
|
||||
case macAdmissionUnavailable
|
||||
case apiOriginMismatch
|
||||
}
|
||||
|
||||
/// The concrete next action that repairs the current blocker.
|
||||
public enum Repair: Equatable, Sendable {
|
||||
case enableOnPhone
|
||||
case openSystemSettings
|
||||
case waitForDeviceToken
|
||||
case retryDeviceTokenRegistration
|
||||
case retryRegistration
|
||||
case signInAgain
|
||||
case finishAccountDeletion
|
||||
case disablePushOnAnotherDevice
|
||||
case connectMac
|
||||
case signIntoMatchingAccount
|
||||
case enableOnMac
|
||||
case leaveMacOrUseAlwaysMode
|
||||
case rebuildMatchingApps
|
||||
}
|
||||
|
||||
/// The repair action for a blocked state, or `nil` when already ready.
|
||||
public var repair: Repair? {
|
||||
switch self {
|
||||
case .ready:
|
||||
return nil
|
||||
case .limited, .presentationLimited:
|
||||
return .openSystemSettings
|
||||
case .reliabilityLimited:
|
||||
return nil
|
||||
case let .blocked(blocker):
|
||||
return Self.repair(for: blocker)
|
||||
}
|
||||
}
|
||||
|
||||
private static func repair(for blocker: Blocker) -> Repair {
|
||||
switch blocker {
|
||||
case .phoneOptInDisabled, .systemPermissionNotRequested:
|
||||
.enableOnPhone
|
||||
case .systemPermissionDenied, .systemNotificationsUnsupported:
|
||||
.openSystemSettings
|
||||
case .awaitingDeviceToken, .registeringDevice:
|
||||
.waitForDeviceToken
|
||||
case .deviceTokenRegistrationFailed:
|
||||
.retryDeviceTokenRegistration
|
||||
case .authenticationRequired:
|
||||
.signInAgain
|
||||
case .accountDeletionInProgress:
|
||||
.finishAccountDeletion
|
||||
case .deviceLimitReached:
|
||||
.disablePushOnAnotherDevice
|
||||
case .backendRegistrationRequired, .registrationRateLimited,
|
||||
.networkUnavailable,
|
||||
.pushServiceUnavailable, .invalidServerResponse,
|
||||
.registrationRejected:
|
||||
.retryRegistration
|
||||
case .invalidConfiguration, .apiOriginMismatch:
|
||||
.rebuildMatchingApps
|
||||
case .macStatusUnavailable:
|
||||
.connectMac
|
||||
case .macAccountMismatch:
|
||||
.signIntoMatchingAccount
|
||||
case .macForwardingDisabled:
|
||||
.enableOnMac
|
||||
case .macCurrentlyActive:
|
||||
.leaveMacOrUseAlwaysMode
|
||||
case .macAdmissionUnavailable:
|
||||
.connectMac
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the furthest confirmed stage in deterministic gate order.
|
||||
public static func resolve(
|
||||
authorization: MobilePushAuthorization,
|
||||
registration: PushRegistrationSnapshot,
|
||||
mac: MacStatus?,
|
||||
macAccountMismatch: Bool = false,
|
||||
systemSettings: MobilePushSystemSettings? = nil,
|
||||
phoneAPIOrigin: String
|
||||
) -> MobilePushReadiness {
|
||||
let liveAuthorization = systemSettings?.authorization ?? authorization
|
||||
switch liveAuthorization {
|
||||
case .denied:
|
||||
return .blocked(.systemPermissionDenied)
|
||||
case .unsupported:
|
||||
return .blocked(.systemNotificationsUnsupported)
|
||||
case .notDetermined, .authorized, .provisional, .ephemeral:
|
||||
break
|
||||
}
|
||||
guard registration.isEnabled else {
|
||||
return .blocked(.phoneOptInDisabled)
|
||||
}
|
||||
guard liveAuthorization.permitsDelivery else {
|
||||
switch liveAuthorization {
|
||||
case .notDetermined:
|
||||
return .blocked(.systemPermissionNotRequested)
|
||||
case .denied:
|
||||
return .blocked(.systemPermissionDenied)
|
||||
case .unsupported:
|
||||
return .blocked(.systemNotificationsUnsupported)
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
break
|
||||
}
|
||||
return .blocked(.systemNotificationsUnsupported)
|
||||
}
|
||||
switch registration.backendState {
|
||||
case .deviceTokenRegistrationFailed:
|
||||
return .blocked(.deviceTokenRegistrationFailed)
|
||||
case .awaitingDeviceToken:
|
||||
return .blocked(.awaitingDeviceToken)
|
||||
case .registrationRequired:
|
||||
return .blocked(.backendRegistrationRequired)
|
||||
case .registering:
|
||||
return .blocked(.registeringDevice)
|
||||
case .registered:
|
||||
break
|
||||
case let .failed(failure):
|
||||
return .blocked(blocker(for: failure))
|
||||
}
|
||||
guard registration.hasDeviceToken else {
|
||||
return .blocked(.awaitingDeviceToken)
|
||||
}
|
||||
if macAccountMismatch {
|
||||
return .blocked(.macAccountMismatch)
|
||||
}
|
||||
guard let mac else {
|
||||
return .blocked(.macStatusUnavailable)
|
||||
}
|
||||
guard mac.accountVerified else {
|
||||
return .blocked(.macAccountMismatch)
|
||||
}
|
||||
guard let macEndpoint = canonicalEndpoint(mac.apiOrigin),
|
||||
let phoneEndpoint = canonicalEndpoint(phoneAPIOrigin),
|
||||
macEndpoint == phoneEndpoint else {
|
||||
return .blocked(.apiOriginMismatch)
|
||||
}
|
||||
guard mac.forwardingEnabled else {
|
||||
return .blocked(.macForwardingDisabled)
|
||||
}
|
||||
switch mac.admission {
|
||||
case .allowed:
|
||||
break
|
||||
case .suppressedMacActive:
|
||||
return .blocked(.macCurrentlyActive)
|
||||
case .forwardingDisabled:
|
||||
return .blocked(.macForwardingDisabled)
|
||||
case .unknown:
|
||||
return .blocked(.macAdmissionUnavailable)
|
||||
}
|
||||
switch liveAuthorization {
|
||||
case .provisional, .ephemeral:
|
||||
return .limited(
|
||||
mode: mac.mode,
|
||||
authorization: liveAuthorization
|
||||
)
|
||||
case .notDetermined, .denied, .unsupported:
|
||||
return .blocked(.systemNotificationsUnsupported)
|
||||
case .authorized:
|
||||
break
|
||||
}
|
||||
if let limitations = systemSettings?.presentationLimitations,
|
||||
!limitations.isEmpty {
|
||||
return .presentationLimited(
|
||||
mode: mac.mode,
|
||||
limitations: limitations
|
||||
)
|
||||
}
|
||||
guard mac.queuePersistence == .healthy else {
|
||||
return .reliabilityLimited(
|
||||
mode: mac.mode,
|
||||
queuePersistence: mac.queuePersistence
|
||||
)
|
||||
}
|
||||
return .ready(mode: mac.mode)
|
||||
}
|
||||
|
||||
private static func blocker(for failure: PushRegistrationFailure) -> Blocker {
|
||||
switch failure {
|
||||
case .authenticationRequired:
|
||||
.authenticationRequired
|
||||
case .accountDeletionInProgress:
|
||||
.accountDeletionInProgress
|
||||
case .rateLimited:
|
||||
.registrationRateLimited
|
||||
case let .deviceLimitReached(limit):
|
||||
.deviceLimitReached(limit: limit)
|
||||
case .networkUnavailable:
|
||||
.networkUnavailable
|
||||
case .serviceUnavailable:
|
||||
.pushServiceUnavailable
|
||||
case .invalidConfiguration:
|
||||
.invalidConfiguration
|
||||
case .invalidServerResponse:
|
||||
.invalidServerResponse
|
||||
case .rejected:
|
||||
.registrationRejected
|
||||
}
|
||||
}
|
||||
|
||||
private static func canonicalEndpoint(_ rawValue: String) -> String? {
|
||||
guard var components = URLComponents(string: rawValue),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
["http", "https"].contains(scheme),
|
||||
let host = components.host?.lowercased(),
|
||||
!host.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
components.scheme = scheme
|
||||
components.host = host
|
||||
if (scheme == "https" && components.port == 443)
|
||||
|| (scheme == "http" && components.port == 80) {
|
||||
components.port = nil
|
||||
}
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
while components.path.hasSuffix("/") {
|
||||
components.path.removeLast()
|
||||
}
|
||||
return components.string
|
||||
}
|
||||
}
|
||||
+672
@@ -0,0 +1,672 @@
|
||||
#if os(iOS)
|
||||
import CmuxAuthRuntime
|
||||
import CmuxMobileRPC
|
||||
import CmuxMobileShell
|
||||
import CmuxMobileSupport
|
||||
import SwiftUI
|
||||
|
||||
enum MobilePushMacMutation: Equatable, Sendable {
|
||||
case forwardingEnabled(Bool)
|
||||
case mode(MobileHostPhonePushStatus.Mode)
|
||||
case hideContent(Bool)
|
||||
}
|
||||
|
||||
/// The production Push Alerts settings surface.
|
||||
///
|
||||
/// Its status is computed from the full readiness pipeline, while Mac-owned
|
||||
/// privacy controls are optimistic only until the authenticated Mac confirms
|
||||
/// the mutation. A failed mutation rolls every control back to the last
|
||||
/// authoritative status instead of leaving a misleading local value behind.
|
||||
struct MobilePushSettingsContent: View {
|
||||
let readiness: MobilePushReadiness
|
||||
@Binding var phoneEnabled: Bool
|
||||
let macStatus: MobileHostPhonePushStatus?
|
||||
let supportsMacSettings: Bool
|
||||
let supportsMacTest: Bool
|
||||
let onPhoneEnabledChange: @MainActor (Bool) async -> Bool
|
||||
let onRepair: @MainActor (MobilePushReadiness.Repair) async -> Bool
|
||||
let onMacMutation: @MainActor (MobilePushMacMutation) async -> Bool
|
||||
let onSendTest: @MainActor () async -> MobilePhonePushTestStage
|
||||
|
||||
@State private var macForwardingEnabled: Bool
|
||||
@State private var macMode: MobileHostPhonePushStatus.Mode
|
||||
@State private var macHideContent: Bool
|
||||
@State private var confirmedMacStatus: MobileHostPhonePushStatus?
|
||||
@State private var isMutatingPhone = false
|
||||
@State private var isMutatingMac = false
|
||||
@State private var mutationFailed = false
|
||||
@State private var testStage: MobilePhonePushTestStage?
|
||||
@State private var isSendingTest = false
|
||||
|
||||
init(
|
||||
readiness: MobilePushReadiness,
|
||||
phoneEnabled: Binding<Bool>,
|
||||
macStatus: MobileHostPhonePushStatus?,
|
||||
supportsMacSettings: Bool,
|
||||
supportsMacTest: Bool,
|
||||
onPhoneEnabledChange: @escaping @MainActor (Bool) async -> Bool,
|
||||
onRepair: @escaping @MainActor (MobilePushReadiness.Repair) async -> Bool,
|
||||
onMacMutation: @escaping @MainActor (MobilePushMacMutation) async -> Bool,
|
||||
onSendTest: @escaping @MainActor () async -> MobilePhonePushTestStage
|
||||
) {
|
||||
self.readiness = readiness
|
||||
self._phoneEnabled = phoneEnabled
|
||||
self.macStatus = macStatus
|
||||
self.supportsMacSettings = supportsMacSettings
|
||||
self.supportsMacTest = supportsMacTest
|
||||
self.onPhoneEnabledChange = onPhoneEnabledChange
|
||||
self.onRepair = onRepair
|
||||
self.onMacMutation = onMacMutation
|
||||
self.onSendTest = onSendTest
|
||||
self._macForwardingEnabled = State(
|
||||
initialValue: macStatus?.forwardingEnabled ?? false
|
||||
)
|
||||
self._macMode = State(initialValue: macStatus?.mode ?? .onlyWhenAway)
|
||||
self._macHideContent = State(
|
||||
initialValue: macStatus?.hideContent ?? false
|
||||
)
|
||||
self._confirmedMacStatus = State(initialValue: macStatus)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
statusRow
|
||||
|
||||
Toggle(
|
||||
L10n.string(
|
||||
"mobile.notifications.phoneEnabled",
|
||||
defaultValue: "Allow Push Alerts on This iPhone"
|
||||
),
|
||||
isOn: phoneEnabledBinding
|
||||
)
|
||||
.accessibilityIdentifier("MobileSettingsNotifications")
|
||||
.disabled(isMutatingPhone)
|
||||
|
||||
if let repair = readiness.repair,
|
||||
let repairPresentation = repairPresentation(for: repair) {
|
||||
Button {
|
||||
guard !isMutatingPhone else { return }
|
||||
isMutatingPhone = true
|
||||
Task {
|
||||
defer { isMutatingPhone = false }
|
||||
let succeeded = await onRepair(repair)
|
||||
if repair == .enableOnPhone {
|
||||
phoneEnabled = succeeded
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
repairPresentation.title,
|
||||
systemImage: repairPresentation.systemImage
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier(repairPresentation.identifier)
|
||||
.disabled(isMutatingPhone || isMutatingMac)
|
||||
}
|
||||
|
||||
if let macStatus {
|
||||
Toggle(
|
||||
L10n.string(
|
||||
"mobile.notifications.macForwarding",
|
||||
defaultValue: "Forward Alerts from This Mac"
|
||||
),
|
||||
isOn: macForwardingBinding
|
||||
)
|
||||
.accessibilityIdentifier("MobileSettingsPushMacForwardingToggle")
|
||||
.disabled(!supportsMacSettings || isMutatingMac)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(L10n.string(
|
||||
"mobile.notifications.macMode",
|
||||
defaultValue: "Forwarding Mode"
|
||||
))
|
||||
.font(.subheadline)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
modeButton(
|
||||
.onlyWhenAway,
|
||||
title: L10n.string(
|
||||
"mobile.notifications.mode.onlyWhenAway",
|
||||
defaultValue: "Only When Away"
|
||||
),
|
||||
identifier: "MobileSettingsPushModeOnlyWhenAway"
|
||||
)
|
||||
modeButton(
|
||||
.always,
|
||||
title: L10n.string(
|
||||
"mobile.notifications.mode.always",
|
||||
defaultValue: "Always"
|
||||
),
|
||||
identifier: "MobileSettingsPushModeAlways"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Toggle(
|
||||
L10n.string(
|
||||
"mobile.notifications.hideContent",
|
||||
defaultValue: "Hide Notification Content"
|
||||
),
|
||||
isOn: macHideContentBinding
|
||||
)
|
||||
.accessibilityIdentifier("MobileSettingsPushHideContentToggle")
|
||||
.disabled(!supportsMacSettings || isMutatingMac)
|
||||
|
||||
if !supportsMacSettings {
|
||||
Text(L10n.string(
|
||||
"mobile.notifications.macUpdateRequired",
|
||||
defaultValue: "Update cmux on this Mac to change forwarding from iPhone."
|
||||
))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if mutationFailed {
|
||||
Text(L10n.string(
|
||||
"mobile.notifications.macMutationFailed",
|
||||
defaultValue: "The Mac did not save that change. Its last confirmed settings were restored."
|
||||
))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
.accessibilityIdentifier("MobileSettingsPushMutationError")
|
||||
}
|
||||
|
||||
if macStatus.mode == .onlyWhenAway {
|
||||
Text(L10n.string(
|
||||
"mobile.notifications.awayExplanation",
|
||||
defaultValue: "Only When Away sends after the Mac is locked, asleep, or inactive."
|
||||
))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Button {
|
||||
isSendingTest = true
|
||||
testStage = nil
|
||||
Task {
|
||||
testStage = await onSendTest()
|
||||
isSendingTest = false
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
L10n.string(
|
||||
"mobile.notifications.test.send",
|
||||
defaultValue: "Send Test Alert"
|
||||
),
|
||||
systemImage: "paperplane"
|
||||
)
|
||||
}
|
||||
.disabled(!supportsMacTest || isSendingTest || isMutatingMac)
|
||||
.accessibilityIdentifier("MobileSettingsPushSendTest")
|
||||
|
||||
if let testStage {
|
||||
Text(testStageText(testStage))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityIdentifier("MobileSettingsPushTestResult")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: macStatus) { _, confirmed in
|
||||
confirmedMacStatus = confirmed
|
||||
guard let confirmed else { return }
|
||||
macForwardingEnabled = confirmed.forwardingEnabled
|
||||
macMode = confirmed.mode
|
||||
macHideContent = confirmed.hideContent
|
||||
mutationFailed = false
|
||||
}
|
||||
}
|
||||
|
||||
private var statusRow: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: readinessSymbol)
|
||||
.foregroundStyle(readinessTint)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(L10n.string(
|
||||
"mobile.notifications.readiness",
|
||||
defaultValue: "Delivery Status"
|
||||
))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(readinessText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(readinessText)
|
||||
.accessibilityIdentifier("MobileSettingsPushReadinessStatus")
|
||||
}
|
||||
|
||||
private var phoneEnabledBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { phoneEnabled },
|
||||
set: { requested in
|
||||
guard !isMutatingPhone else { return }
|
||||
let confirmed = phoneEnabled
|
||||
phoneEnabled = requested
|
||||
isMutatingPhone = true
|
||||
Task {
|
||||
let succeeded = await onPhoneEnabledChange(requested)
|
||||
if !succeeded {
|
||||
phoneEnabled = confirmed
|
||||
}
|
||||
isMutatingPhone = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var macForwardingBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { macForwardingEnabled },
|
||||
set: { requested in
|
||||
guard !isMutatingMac else { return }
|
||||
macForwardingEnabled = requested
|
||||
performMacMutation(.forwardingEnabled(requested))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var macHideContentBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { macHideContent },
|
||||
set: { requested in
|
||||
guard !isMutatingMac else { return }
|
||||
macHideContent = requested
|
||||
performMacMutation(.hideContent(requested))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func modeButton(
|
||||
_ mode: MobileHostPhonePushStatus.Mode,
|
||||
title: String,
|
||||
identifier: String
|
||||
) -> some View {
|
||||
Button {
|
||||
guard !isMutatingMac, macMode != mode else { return }
|
||||
macMode = mode
|
||||
performMacMutation(.mode(mode))
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: macMode == mode ? "checkmark.circle.fill" : "circle")
|
||||
Text(title)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!supportsMacSettings || isMutatingMac)
|
||||
.accessibilityIdentifier(identifier)
|
||||
.accessibilityValue(
|
||||
macMode == mode
|
||||
? L10n.string(
|
||||
"mobile.accessibility.selected",
|
||||
defaultValue: "selected"
|
||||
)
|
||||
: L10n.string(
|
||||
"mobile.accessibility.notSelected",
|
||||
defaultValue: "not selected"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func performMacMutation(_ mutation: MobilePushMacMutation) {
|
||||
mutationFailed = false
|
||||
isMutatingMac = true
|
||||
Task {
|
||||
let succeeded = await onMacMutation(mutation)
|
||||
isMutatingMac = false
|
||||
if !succeeded {
|
||||
if let confirmedMacStatus {
|
||||
macForwardingEnabled = confirmedMacStatus.forwardingEnabled
|
||||
macMode = confirmedMacStatus.mode
|
||||
macHideContent = confirmedMacStatus.hideContent
|
||||
}
|
||||
mutationFailed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension MobilePushSettingsContent {
|
||||
|
||||
private var readinessText: String {
|
||||
switch readiness {
|
||||
case let .ready(mode):
|
||||
switch mode {
|
||||
case .onlyWhenAway:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.readyAway",
|
||||
defaultValue: "Ready, Only When Away"
|
||||
)
|
||||
case .always:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.readyAlways",
|
||||
defaultValue: "Ready, Always"
|
||||
)
|
||||
}
|
||||
case .limited:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.limitedAuthorization",
|
||||
defaultValue: "Limited, Delivered Quietly"
|
||||
)
|
||||
case .presentationLimited:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.presentationLimited",
|
||||
defaultValue: "Limited, Check iOS Presentation Settings"
|
||||
)
|
||||
case let .reliabilityLimited(_, queuePersistence):
|
||||
switch queuePersistence {
|
||||
case .unknown:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.queueUnconfirmed",
|
||||
defaultValue: "Limited, Delivery Recovery Unconfirmed"
|
||||
)
|
||||
case .loadFailed, .saveFailed, .clearFailed:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.queueFailed",
|
||||
defaultValue: "Limited, Delivery Recovery Unavailable"
|
||||
)
|
||||
case .healthy:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.readyAlways",
|
||||
defaultValue: "Ready, Always"
|
||||
)
|
||||
}
|
||||
case let .blocked(blocker):
|
||||
return blockedText(blocker)
|
||||
}
|
||||
}
|
||||
|
||||
func blockedText(_ blocker: MobilePushReadiness.Blocker) -> String {
|
||||
switch blocker {
|
||||
case .phoneOptInDisabled, .systemPermissionNotRequested,
|
||||
.systemPermissionDenied, .systemNotificationsUnsupported,
|
||||
.awaitingDeviceToken, .deviceTokenRegistrationFailed:
|
||||
phoneBlockerText(blocker)
|
||||
case .registeringDevice, .backendRegistrationRequired,
|
||||
.authenticationRequired, .accountDeletionInProgress,
|
||||
.registrationRateLimited, .deviceLimitReached,
|
||||
.networkUnavailable, .pushServiceUnavailable,
|
||||
.invalidServerResponse, .registrationRejected,
|
||||
.invalidConfiguration:
|
||||
registrationBlockerText(blocker)
|
||||
case .macStatusUnavailable, .macAdmissionUnavailable,
|
||||
.macAccountMismatch, .macForwardingDisabled,
|
||||
.macCurrentlyActive, .apiOriginMismatch:
|
||||
macBlockerText(blocker)
|
||||
}
|
||||
}
|
||||
|
||||
func phoneBlockerText(_ blocker: MobilePushReadiness.Blocker) -> String {
|
||||
switch blocker {
|
||||
case .phoneOptInDisabled:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.phoneOff",
|
||||
defaultValue: "Blocked, Off on This iPhone"
|
||||
)
|
||||
case .systemPermissionNotRequested:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.permissionNotRequested",
|
||||
defaultValue: "Blocked, iOS Permission Not Requested"
|
||||
)
|
||||
case .systemPermissionDenied:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.permissionDenied",
|
||||
defaultValue: "Blocked, iOS Permission Denied"
|
||||
)
|
||||
case .systemNotificationsUnsupported:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.unsupported",
|
||||
defaultValue: "Blocked, Notifications Unsupported"
|
||||
)
|
||||
case .awaitingDeviceToken:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.awaitingToken",
|
||||
defaultValue: "Blocked, Waiting for Notification Setup"
|
||||
)
|
||||
case .deviceTokenRegistrationFailed:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.tokenFailed",
|
||||
defaultValue: "Blocked, Notification Setup Failed"
|
||||
)
|
||||
default:
|
||||
assertionFailure("Expected a phone-side push blocker")
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func registrationBlockerText(
|
||||
_ blocker: MobilePushReadiness.Blocker
|
||||
) -> String {
|
||||
switch blocker {
|
||||
case .registeringDevice:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.registering",
|
||||
defaultValue: "Blocked, Registering This Device"
|
||||
)
|
||||
case .backendRegistrationRequired:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.backendRequired",
|
||||
defaultValue: "Blocked, Finishing Notification Setup"
|
||||
)
|
||||
case .authenticationRequired:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.authenticationRequired",
|
||||
defaultValue: "Blocked, Sign In Again"
|
||||
)
|
||||
case .accountDeletionInProgress:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.accountDeletion",
|
||||
defaultValue: "Blocked, Account Deletion in Progress"
|
||||
)
|
||||
case .registrationRateLimited:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.rateLimited",
|
||||
defaultValue: "Blocked, Registration Rate Limited"
|
||||
)
|
||||
case let .deviceLimitReached(limit):
|
||||
return String.localizedStringWithFormat(
|
||||
L10n.string(
|
||||
"mobile.notifications.status.deviceLimitFormat",
|
||||
defaultValue: "Blocked, %d-Device Limit Reached"
|
||||
),
|
||||
limit
|
||||
)
|
||||
case .networkUnavailable:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.offline",
|
||||
defaultValue: "Blocked, Network Unavailable"
|
||||
)
|
||||
case .pushServiceUnavailable, .invalidServerResponse,
|
||||
.registrationRejected:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.registrationFailed",
|
||||
defaultValue: "Blocked, Registration Failed"
|
||||
)
|
||||
case .invalidConfiguration:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.invalidConfiguration",
|
||||
defaultValue: "Blocked, Invalid Push Configuration"
|
||||
)
|
||||
default:
|
||||
assertionFailure("Expected a registration push blocker")
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func macBlockerText(_ blocker: MobilePushReadiness.Blocker) -> String {
|
||||
switch blocker {
|
||||
case .macStatusUnavailable, .macAdmissionUnavailable:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.macUnavailable",
|
||||
defaultValue: "Blocked, Mac Status Unavailable"
|
||||
)
|
||||
case .macAccountMismatch:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.accountMismatch",
|
||||
defaultValue: "Blocked, Mac Account Does Not Match"
|
||||
)
|
||||
case .macForwardingDisabled:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.macForwardingOff",
|
||||
defaultValue: "Blocked, Mac Forwarding Is Off"
|
||||
)
|
||||
case .macCurrentlyActive:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.macActive",
|
||||
defaultValue: "Paused, Mac Is Active"
|
||||
)
|
||||
case .apiOriginMismatch:
|
||||
return L10n.string(
|
||||
"mobile.notifications.status.originMismatch",
|
||||
defaultValue: "Blocked, Mac and iPhone Servers Differ"
|
||||
)
|
||||
default:
|
||||
assertionFailure("Expected a Mac-side push blocker")
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
private func testStageText(_ stage: MobilePhonePushTestStage) -> String {
|
||||
switch stage {
|
||||
case .queuedOnMac:
|
||||
L10n.string(
|
||||
"mobile.notifications.test.queued",
|
||||
defaultValue: "Queued on Mac. iOS delivery is still pending."
|
||||
)
|
||||
case .forwardingDisabled:
|
||||
L10n.string(
|
||||
"mobile.notifications.test.forwardingOff",
|
||||
defaultValue: "Not queued because Mac forwarding is off."
|
||||
)
|
||||
case .macActive:
|
||||
L10n.string(
|
||||
"mobile.notifications.test.macActive",
|
||||
defaultValue: "Not queued because Only When Away is active and the Mac is in use."
|
||||
)
|
||||
case .authenticationUnavailable:
|
||||
L10n.string(
|
||||
"mobile.notifications.test.authentication",
|
||||
defaultValue: "Not queued because the Mac is not signed in."
|
||||
)
|
||||
case .encodingFailed:
|
||||
L10n.string(
|
||||
"mobile.notifications.test.encodingFailed",
|
||||
defaultValue: "The alert could not be prepared for delivery."
|
||||
)
|
||||
case .queueFull:
|
||||
L10n.string(
|
||||
"mobile.notifications.test.queueFull",
|
||||
defaultValue: "Delivery is busy. Try again shortly."
|
||||
)
|
||||
case .unavailable:
|
||||
L10n.string(
|
||||
"mobile.notifications.test.unavailable",
|
||||
defaultValue: "The Mac could not confirm a queue stage."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var readinessSymbol: String {
|
||||
switch readiness {
|
||||
case .ready: "checkmark.circle.fill"
|
||||
case .limited, .presentationLimited, .reliabilityLimited:
|
||||
"exclamationmark.triangle.fill"
|
||||
case .blocked: "xmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var readinessTint: Color {
|
||||
switch readiness {
|
||||
case .ready: .green
|
||||
case .limited, .presentationLimited, .reliabilityLimited: .orange
|
||||
case .blocked: .red
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension MobilePushSettingsContent {
|
||||
struct RepairPresentation {
|
||||
let title: String
|
||||
let systemImage: String
|
||||
let identifier: String
|
||||
}
|
||||
|
||||
func repairPresentation(
|
||||
for repair: MobilePushReadiness.Repair
|
||||
) -> RepairPresentation? {
|
||||
switch repair {
|
||||
case .enableOnPhone:
|
||||
RepairPresentation(
|
||||
title: L10n.string(
|
||||
"mobile.notifications.repair.enablePhone",
|
||||
defaultValue: "Enable on This iPhone"
|
||||
),
|
||||
systemImage: "bell.badge",
|
||||
identifier: "MobileSettingsPushRepairEnablePhone"
|
||||
)
|
||||
case .openSystemSettings:
|
||||
RepairPresentation(
|
||||
title: L10n.string(
|
||||
"mobile.notifications.repair.openSettings",
|
||||
defaultValue: "Open iOS Notification Settings"
|
||||
),
|
||||
systemImage: "gear",
|
||||
identifier: "MobileSettingsPushRepairOpenSettings"
|
||||
)
|
||||
case .retryDeviceTokenRegistration:
|
||||
RepairPresentation(
|
||||
title: L10n.string(
|
||||
"mobile.notifications.repair.retryAPNs",
|
||||
defaultValue: "Retry Notification Setup"
|
||||
),
|
||||
systemImage: "arrow.clockwise",
|
||||
identifier: "MobileSettingsPushRepairRetryAPNs"
|
||||
)
|
||||
case .retryRegistration:
|
||||
RepairPresentation(
|
||||
title: L10n.string(
|
||||
"mobile.notifications.repair.retryRegistration",
|
||||
defaultValue: "Retry Registration"
|
||||
),
|
||||
systemImage: "arrow.clockwise",
|
||||
identifier: "MobileSettingsPushRepairRetryRegistration"
|
||||
)
|
||||
case .signInAgain, .signIntoMatchingAccount:
|
||||
RepairPresentation(
|
||||
title: L10n.string(
|
||||
"mobile.notifications.repair.signInAgain",
|
||||
defaultValue: "Sign In with the Matching Account"
|
||||
),
|
||||
systemImage: "person.crop.circle.badge.exclamationmark",
|
||||
identifier: "MobileSettingsPushRepairSignIn"
|
||||
)
|
||||
case .connectMac:
|
||||
RepairPresentation(
|
||||
title: L10n.string(
|
||||
"mobile.notifications.repair.connectMac",
|
||||
defaultValue: "Connect a Mac"
|
||||
),
|
||||
systemImage: "desktopcomputer",
|
||||
identifier: "MobileSettingsPushRepairConnectMac"
|
||||
)
|
||||
case .leaveMacOrUseAlwaysMode:
|
||||
RepairPresentation(
|
||||
title: L10n.string(
|
||||
"mobile.notifications.repair.useAlways",
|
||||
defaultValue: "Use Always Mode"
|
||||
),
|
||||
systemImage: "bell.fill",
|
||||
identifier: "MobileSettingsPushRepairUseAlways"
|
||||
)
|
||||
case .waitForDeviceToken, .finishAccountDeletion,
|
||||
.disablePushOnAnotherDevice, .enableOnMac,
|
||||
.rebuildMatchingApps:
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+130
-19
@@ -1,4 +1,5 @@
|
||||
#if os(iOS)
|
||||
import CMUXMobileCore
|
||||
import CmuxAuthRuntime
|
||||
import CmuxMobileShell
|
||||
import CmuxMobileShellModel
|
||||
@@ -120,6 +121,18 @@ struct MobileSettingsView: View {
|
||||
value: connectedHostName
|
||||
)
|
||||
}
|
||||
if let store,
|
||||
store.connectionState == .connected,
|
||||
let routeKind = store.activeRoute?.kind {
|
||||
LabeledContent(
|
||||
L10n.string(
|
||||
"mobile.settings.activeTransport",
|
||||
defaultValue: "Active Transport"
|
||||
),
|
||||
value: activeTransportName(routeKind)
|
||||
)
|
||||
.accessibilityIdentifier("MobileSettingsActiveTransport")
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasConnectionSection {
|
||||
@@ -380,24 +393,20 @@ struct MobileSettingsView: View {
|
||||
}
|
||||
|
||||
Section(L10n.string("mobile.settings.notifications", defaultValue: "Push Alerts")) {
|
||||
Button {
|
||||
Task {
|
||||
if notificationsEnabled {
|
||||
await pushCoordinator.disable()
|
||||
notificationsEnabled = false
|
||||
} else {
|
||||
notificationsEnabled = await pushCoordinator.enable()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
notificationsEnabled
|
||||
? L10n.string("mobile.notifications.disable", defaultValue: "Turn Off Push Alerts")
|
||||
: L10n.string("mobile.notifications.enable", defaultValue: "Notify Me When Agents Need Me"),
|
||||
systemImage: notificationsEnabled ? "bell.slash" : "bell"
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier("MobileSettingsNotifications")
|
||||
MobilePushSettingsContent(
|
||||
readiness: pushCoordinator.readiness(
|
||||
macStatus: store?.phonePushMacStatus,
|
||||
macAccountMismatch: store?.connectionRequiresReauth == true
|
||||
),
|
||||
phoneEnabled: $notificationsEnabled,
|
||||
macStatus: store?.phonePushMacStatus,
|
||||
supportsMacSettings: store?.supportsPhonePushSettings == true,
|
||||
supportsMacTest: store?.supportsPhonePushTest == true,
|
||||
onPhoneEnabledChange: updatePhonePushEnabled,
|
||||
onRepair: repairPhonePush,
|
||||
onMacMutation: updateMacPhonePush,
|
||||
onSendTest: sendPhonePushTest
|
||||
)
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -441,7 +450,13 @@ struct MobileSettingsView: View {
|
||||
.accessibilityIdentifier("MobileSettingsVersionRow")
|
||||
}
|
||||
}
|
||||
.onAppear { notificationsEnabled = pushCoordinator.isEnabled }
|
||||
.task {
|
||||
notificationsEnabled = pushCoordinator.isEnabled
|
||||
await pushCoordinator.refreshReadiness()
|
||||
}
|
||||
.onChange(of: pushCoordinator.isEnabled) { _, enabled in
|
||||
notificationsEnabled = enabled
|
||||
}
|
||||
.navigationTitle(L10n.string("mobile.workspaces.settings", defaultValue: "Settings"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -501,6 +516,102 @@ struct MobileSettingsView: View {
|
||||
.accessibilityIdentifier("MobileSettingsView")
|
||||
}
|
||||
|
||||
private func activeTransportName(_ kind: CmxAttachTransportKind) -> String {
|
||||
switch kind {
|
||||
case .tailscale:
|
||||
L10n.string(
|
||||
"mobile.settings.activeTransport.tailscale",
|
||||
defaultValue: "Tailscale"
|
||||
)
|
||||
case .iroh:
|
||||
L10n.string(
|
||||
"mobile.settings.activeTransport.iroh",
|
||||
defaultValue: "Iroh"
|
||||
)
|
||||
case .websocket:
|
||||
L10n.string(
|
||||
"mobile.settings.activeTransport.websocket",
|
||||
defaultValue: "WebSocket"
|
||||
)
|
||||
case .debugLoopback:
|
||||
L10n.string(
|
||||
"mobile.settings.activeTransport.simulator",
|
||||
defaultValue: "Simulator"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updatePhonePushEnabled(_ enabled: Bool) async -> Bool {
|
||||
if enabled {
|
||||
_ = await pushCoordinator.enable()
|
||||
// A denied OS authorization still accepts the user's app-level
|
||||
// intent. Keep the toggle on so readiness can surface the Settings
|
||||
// recovery action instead of rolling the preference back.
|
||||
return pushCoordinator.isEnabled
|
||||
}
|
||||
await pushCoordinator.disable()
|
||||
return !pushCoordinator.isEnabled
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func repairPhonePush(
|
||||
_ repair: MobilePushReadiness.Repair
|
||||
) async -> Bool {
|
||||
switch repair {
|
||||
case .enableOnPhone:
|
||||
return await updatePhonePushEnabled(true)
|
||||
case .openSystemSettings:
|
||||
pushCoordinator.openSystemSettings()
|
||||
return true
|
||||
case .retryDeviceTokenRegistration:
|
||||
pushCoordinator.retryDeviceTokenRegistration()
|
||||
await pushCoordinator.refreshReadiness()
|
||||
return true
|
||||
case .retryRegistration:
|
||||
await pushCoordinator.syncTokenIfPossible()
|
||||
await pushCoordinator.refreshReadiness()
|
||||
return true
|
||||
case .signInAgain, .signIntoMatchingAccount:
|
||||
signOut?()
|
||||
return signOut != nil
|
||||
case .connectMac:
|
||||
startPairingScanner?()
|
||||
return startPairingScanner != nil
|
||||
case .leaveMacOrUseAlwaysMode:
|
||||
return await store?.updatePhonePushSettings(mode: .always) == true
|
||||
case .enableOnMac:
|
||||
return await store?.updatePhonePushSettings(
|
||||
forwardingEnabled: true
|
||||
) == true
|
||||
case .waitForDeviceToken, .finishAccountDeletion,
|
||||
.disablePushOnAnotherDevice, .rebuildMatchingApps:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateMacPhonePush(
|
||||
_ mutation: MobilePushMacMutation
|
||||
) async -> Bool {
|
||||
guard let store else { return false }
|
||||
switch mutation {
|
||||
case let .forwardingEnabled(enabled):
|
||||
return await store.updatePhonePushSettings(
|
||||
forwardingEnabled: enabled
|
||||
)
|
||||
case let .mode(mode):
|
||||
return await store.updatePhonePushSettings(mode: mode)
|
||||
case let .hideContent(hidden):
|
||||
return await store.updatePhonePushSettings(hideContent: hidden)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func sendPhonePushTest() async -> MobilePhonePushTestStage {
|
||||
await store?.sendPhonePushTest() ?? .unavailable
|
||||
}
|
||||
|
||||
private static var crashReportingEnabled: Bool {
|
||||
switch Bundle.main.object(forInfoDictionaryKey: "CMUXCrashReportingEnabled") {
|
||||
case let enabled as Bool:
|
||||
|
||||
+2
-2
@@ -29,11 +29,11 @@ struct OnboardingConnectionMethodPicker: View {
|
||||
.tailscale,
|
||||
title: L10n.string(
|
||||
"mobile.onboarding.connect.method.tailscale",
|
||||
defaultValue: "Tailscale"
|
||||
defaultValue: "Tailscale Only"
|
||||
),
|
||||
subtitle: L10n.string(
|
||||
"mobile.onboarding.connect.method.tailscaleDetail",
|
||||
defaultValue: "Uses your Tailscale network. Scan the pairing code on your Mac."
|
||||
defaultValue: "Uses only your Tailscale network. Scan the pairing code on your Mac."
|
||||
),
|
||||
systemImage: "qrcode",
|
||||
accessibilityIdentifier: "MobileOnboardingConnectionMethodTailscale"
|
||||
|
||||
+2
-2
@@ -72,8 +72,8 @@ struct OnboardingConnectionView: View {
|
||||
return L10n.string(
|
||||
"mobile.onboarding.connect.tailscaleBody",
|
||||
defaultValue: """
|
||||
Install Tailscale on this iPhone and your Mac, then connect both to the same Tailscale network. \
|
||||
On your Mac, open Tailscale Pairing in cmux to show the QR, then scan it here.
|
||||
Connect only over Tailscale. Install it on both devices, join the same network, then scan the \
|
||||
pairing code shown by cmux on your Mac.
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ struct WorkspaceShellHost: View {
|
||||
let showAddDevice: (() -> Void)?
|
||||
let showPairingScanner: (() -> Void)?
|
||||
let reconnectStoredMac: () -> Void
|
||||
let workspaceListDidBecomeVisible: @MainActor @Sendable () async -> Void
|
||||
|
||||
@Environment(AuthCoordinator.self) private var authManager
|
||||
@State private var loadingTimedOut = false
|
||||
@@ -40,6 +41,9 @@ struct WorkspaceShellHost: View {
|
||||
.task(id: deadlineTaskID) {
|
||||
await updateLoadingDeadline()
|
||||
}
|
||||
.task {
|
||||
await workspaceListDidBecomeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
private struct DeadlineTaskID: Equatable {
|
||||
|
||||
+566
@@ -0,0 +1,566 @@
|
||||
import CmuxAuthRuntime
|
||||
import Foundation
|
||||
import Testing
|
||||
import UserNotifications
|
||||
|
||||
@testable import CmuxMobileShellUI
|
||||
|
||||
private actor LifecyclePushRegistration: PushRegistering {
|
||||
private var value: PushRegistrationSnapshot
|
||||
private let setEnabledGate: LifecycleSetEnabledGate?
|
||||
private let syncGate: LifecycleSyncGate?
|
||||
|
||||
init(
|
||||
enabled: Bool = true,
|
||||
snapshot: PushRegistrationSnapshot? = nil,
|
||||
setEnabledGate: LifecycleSetEnabledGate? = nil,
|
||||
syncGate: LifecycleSyncGate? = nil
|
||||
) {
|
||||
value = snapshot
|
||||
?? (enabled ? PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: false,
|
||||
backendState: .awaitingDeviceToken
|
||||
)
|
||||
: .disabled)
|
||||
self.setEnabledGate = setEnabledGate
|
||||
self.syncGate = syncGate
|
||||
}
|
||||
|
||||
var isEnabled: Bool { value.isEnabled }
|
||||
var snapshot: PushRegistrationSnapshot { value }
|
||||
|
||||
func snapshots() -> AsyncStream<PushRegistrationSnapshot> {
|
||||
AsyncStream { continuation in
|
||||
continuation.yield(value)
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool) async {
|
||||
await setEnabledGate?.pause()
|
||||
value = enabled
|
||||
? PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: value.hasDeviceToken,
|
||||
backendState: value.hasDeviceToken
|
||||
? .registrationRequired
|
||||
: .awaitingDeviceToken
|
||||
)
|
||||
: .disabled
|
||||
}
|
||||
|
||||
func register(deviceToken: Data) {
|
||||
value = PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .registered
|
||||
)
|
||||
}
|
||||
|
||||
func deviceTokenRegistrationFailed() {
|
||||
value = PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: false,
|
||||
backendState: .deviceTokenRegistrationFailed
|
||||
)
|
||||
}
|
||||
|
||||
func syncTokenIfPossible() async {
|
||||
await syncGate?.pause()
|
||||
guard value.isEnabled, value.hasDeviceToken else { return }
|
||||
value = PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .registered
|
||||
)
|
||||
}
|
||||
func unregisterFromServer() {}
|
||||
func unregisterFromServer(accessToken: String?, refreshToken: String?) {}
|
||||
func unregisterFromServer(
|
||||
accountID: String?,
|
||||
accessToken: String?,
|
||||
refreshToken: String?
|
||||
) {}
|
||||
}
|
||||
|
||||
private actor LifecycleSetEnabledGate {
|
||||
private var didStart = false
|
||||
private var released = false
|
||||
private var startWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
private var releaseWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
func pause() async {
|
||||
didStart = true
|
||||
let waiters = startWaiters
|
||||
startWaiters.removeAll()
|
||||
for waiter in waiters {
|
||||
waiter.resume()
|
||||
}
|
||||
guard !released else { return }
|
||||
await withCheckedContinuation { continuation in
|
||||
releaseWaiters.append(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
func waitUntilStarted() async {
|
||||
guard !didStart else { return }
|
||||
await withCheckedContinuation { continuation in
|
||||
startWaiters.append(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
func release() {
|
||||
released = true
|
||||
let waiters = releaseWaiters
|
||||
releaseWaiters.removeAll()
|
||||
for waiter in waiters {
|
||||
waiter.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private actor LifecycleSyncGate {
|
||||
private(set) var starts = 0
|
||||
private var released = false
|
||||
private var startWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
private var releaseWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
func pause() async {
|
||||
starts += 1
|
||||
let waiters = startWaiters
|
||||
startWaiters.removeAll()
|
||||
for waiter in waiters {
|
||||
waiter.resume()
|
||||
}
|
||||
guard !released else { return }
|
||||
await withCheckedContinuation { continuation in
|
||||
releaseWaiters.append(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
func waitUntilStarted() async {
|
||||
guard starts == 0 else { return }
|
||||
await withCheckedContinuation { continuation in
|
||||
startWaiters.append(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
func release() {
|
||||
released = true
|
||||
let waiters = releaseWaiters
|
||||
releaseWaiters.removeAll()
|
||||
for waiter in waiters {
|
||||
waiter.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LifecycleTokenProvider: TokenProviding {
|
||||
private let session = AuthenticatedSessionSnapshot(
|
||||
generation: 1,
|
||||
accountID: "push-lifecycle-user",
|
||||
accessToken: "push-lifecycle-access",
|
||||
refreshToken: "push-lifecycle-refresh"
|
||||
)
|
||||
|
||||
func authenticatedSessionSnapshot() async throws
|
||||
-> AuthenticatedSessionSnapshot {
|
||||
session
|
||||
}
|
||||
|
||||
func isAuthenticatedSessionCurrent(
|
||||
_ snapshot: AuthenticatedSessionSnapshot
|
||||
) async -> Bool {
|
||||
snapshot == session
|
||||
}
|
||||
|
||||
func accessToken() async throws -> String { session.accessToken }
|
||||
func storedAccessToken() async -> String? { session.accessToken }
|
||||
func refreshToken() async -> String? { session.refreshToken }
|
||||
func forceRefreshAccessToken() async throws -> String {
|
||||
session.accessToken
|
||||
}
|
||||
}
|
||||
|
||||
private final class LifecycleRequestRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var storedMethods: [String] = []
|
||||
|
||||
var methods: [String] { lock.withLock { storedMethods } }
|
||||
|
||||
func record(_ request: URLRequest) {
|
||||
lock.withLock { storedMethods.append(request.httpMethod ?? "?") }
|
||||
}
|
||||
|
||||
func reset() {
|
||||
lock.withLock { storedMethods.removeAll() }
|
||||
}
|
||||
}
|
||||
|
||||
private final class LifecyclePushURLProtocol: URLProtocol,
|
||||
@unchecked Sendable {
|
||||
static let recorder = LifecycleRequestRecorder()
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
|
||||
override class func canonicalRequest(
|
||||
for request: URLRequest
|
||||
) -> URLRequest {
|
||||
request
|
||||
}
|
||||
|
||||
override func startLoading() {
|
||||
Self.recorder.record(request)
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: nil
|
||||
)!
|
||||
client?.urlProtocol(
|
||||
self,
|
||||
didReceive: response,
|
||||
cacheStoragePolicy: .notAllowed
|
||||
)
|
||||
client?.urlProtocol(self, didLoad: Data(#"{"ok":true}"#.utf8))
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
}
|
||||
|
||||
@Suite struct MobilePushCoordinatorLifecycleTests {
|
||||
@MainActor
|
||||
@Test func callbackFailureOffersRetryAndSuccessfulTokenRecoversReadiness() async {
|
||||
let registration = LifecyclePushRegistration()
|
||||
var registrationRequests = 0
|
||||
let suiteName = "push-coordinator-callback-\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
let coordinator = MobilePushCoordinator(
|
||||
registration: registration,
|
||||
defaults: defaults,
|
||||
authorizationStatus: { .authorized },
|
||||
requestAuthorization: { true },
|
||||
registerForRemoteNotifications: { registrationRequests += 1 }
|
||||
)
|
||||
await coordinator.refreshReadiness()
|
||||
|
||||
await coordinator.handleDeviceTokenFailure()
|
||||
|
||||
#expect(
|
||||
coordinator.registrationSnapshot.backendState
|
||||
== .deviceTokenRegistrationFailed
|
||||
)
|
||||
#expect(
|
||||
coordinator.readiness(macStatus: nil)
|
||||
== .blocked(.deviceTokenRegistrationFailed)
|
||||
)
|
||||
|
||||
coordinator.retryDeviceTokenRegistration()
|
||||
#expect(registrationRequests == 1)
|
||||
|
||||
await coordinator.handleDeviceToken(Data(repeating: 0xCD, count: 32))
|
||||
#expect(coordinator.registrationSnapshot.backendState == .registered)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func enableRegistersWithOSBeforeBackendSyncCompletes() async {
|
||||
let gate = LifecycleSetEnabledGate()
|
||||
let registration = LifecyclePushRegistration(
|
||||
enabled: false,
|
||||
setEnabledGate: gate
|
||||
)
|
||||
let suiteName = "push-coordinator-enable-\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
var registrationRequests = 0
|
||||
let coordinator = MobilePushCoordinator(
|
||||
registration: registration,
|
||||
defaults: defaults,
|
||||
authorizationStatus: { .authorized },
|
||||
requestAuthorization: { true },
|
||||
registerForRemoteNotifications: { registrationRequests += 1 }
|
||||
)
|
||||
|
||||
let enabling = Task { await coordinator.enable() }
|
||||
await gate.waitUntilStarted()
|
||||
|
||||
#expect(registrationRequests == 1)
|
||||
#expect(coordinator.isEnabled)
|
||||
#expect(coordinator.registrationSnapshot.isEnabled)
|
||||
|
||||
await gate.release()
|
||||
#expect(await enabling.value)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func authorizedEnableRecoversWithoutRequestingAuthorizationAgain() async {
|
||||
let registration = LifecyclePushRegistration(enabled: false)
|
||||
let suiteName = "push-coordinator-authorized-recovery-\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
var authorizationRequests = 0
|
||||
var registrationRequests = 0
|
||||
let coordinator = MobilePushCoordinator(
|
||||
registration: registration,
|
||||
defaults: defaults,
|
||||
authorizationStatus: { .authorized },
|
||||
requestAuthorization: {
|
||||
authorizationRequests += 1
|
||||
return false
|
||||
},
|
||||
registerForRemoteNotifications: { registrationRequests += 1 }
|
||||
)
|
||||
|
||||
#expect(await coordinator.enable())
|
||||
#expect(authorizationRequests == 0)
|
||||
#expect(registrationRequests == 1)
|
||||
#expect(coordinator.isEnabled)
|
||||
#expect(await registration.snapshot.isEnabled)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func deniedEnablePersistsIntentAndDoesNotReaskTheSystem() async {
|
||||
let registration = LifecyclePushRegistration(enabled: false)
|
||||
let suiteName = "push-coordinator-denied-intent-\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
var authorizationRequests = 0
|
||||
let coordinator = MobilePushCoordinator(
|
||||
registration: registration,
|
||||
defaults: defaults,
|
||||
authorizationStatus: { .denied },
|
||||
requestAuthorization: {
|
||||
authorizationRequests += 1
|
||||
return false
|
||||
}
|
||||
)
|
||||
|
||||
#expect(!(await coordinator.enable()))
|
||||
#expect(authorizationRequests == 0)
|
||||
#expect(coordinator.isEnabled)
|
||||
#expect(
|
||||
defaults.object(forKey: "cmux.notifications.pushEnabled") as? Bool
|
||||
== true
|
||||
)
|
||||
#expect(
|
||||
coordinator.readiness(macStatus: nil)
|
||||
== .blocked(.systemPermissionDenied)
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func foregroundRefreshRegistersAfterPermissionIsEnabledInSettings() async {
|
||||
let registration = LifecyclePushRegistration(enabled: false)
|
||||
let suiteName = "push-coordinator-settings-return-\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
defaults.set(true, forKey: "cmux.notifications.pushEnabled")
|
||||
var status = UNAuthorizationStatus.denied
|
||||
var registrationRequests = 0
|
||||
let coordinator = MobilePushCoordinator(
|
||||
registration: registration,
|
||||
defaults: defaults,
|
||||
authorizationStatus: { status },
|
||||
registerForRemoteNotifications: { registrationRequests += 1 }
|
||||
)
|
||||
|
||||
await coordinator.refreshReadiness()
|
||||
#expect(registrationRequests == 0)
|
||||
#expect(!(await registration.snapshot.isEnabled))
|
||||
|
||||
status = .authorized
|
||||
await coordinator.refreshReadiness()
|
||||
|
||||
#expect(registrationRequests == 1)
|
||||
#expect(await registration.snapshot.isEnabled)
|
||||
#expect(coordinator.authorization == .authorized)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func foregroundRefreshPreservesExplicitAppOptOut() async {
|
||||
let registration = LifecyclePushRegistration(enabled: false)
|
||||
let suiteName = "push-coordinator-explicit-optout-\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
defaults.set(false, forKey: "cmux.notifications.pushEnabled")
|
||||
var registrationRequests = 0
|
||||
let coordinator = MobilePushCoordinator(
|
||||
registration: registration,
|
||||
defaults: defaults,
|
||||
authorizationStatus: { .provisional },
|
||||
registerForRemoteNotifications: { registrationRequests += 1 }
|
||||
)
|
||||
|
||||
await coordinator.refreshReadiness()
|
||||
|
||||
#expect(registrationRequests == 0)
|
||||
#expect(!(await registration.snapshot.isEnabled))
|
||||
#expect(!coordinator.isEnabled)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func repeatedForegroundAndWorkspaceActivationRequestsAPNsOnce() async {
|
||||
let registration = LifecyclePushRegistration(enabled: true)
|
||||
let suiteName = "push-coordinator-apns-dedupe-\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
defaults.set(true, forKey: "cmux.notifications.pushEnabled")
|
||||
var registrationRequests = 0
|
||||
let coordinator = MobilePushCoordinator(
|
||||
registration: registration,
|
||||
defaults: defaults,
|
||||
authorizationStatus: { .authorized },
|
||||
registerForRemoteNotifications: { registrationRequests += 1 }
|
||||
)
|
||||
|
||||
await coordinator.refreshReadiness()
|
||||
await coordinator.workspaceListDidBecomeVisible()
|
||||
await coordinator.refreshReadiness()
|
||||
|
||||
#expect(registrationRequests == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func sharedDefaultsDisableInvokesProductionBackendUnregister() async {
|
||||
LifecyclePushURLProtocol.recorder.reset()
|
||||
let suiteName = "push-coordinator-shared-disable-\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [LifecyclePushURLProtocol.self]
|
||||
let registration = PushRegistrationService(
|
||||
tokenProvider: LifecycleTokenProvider(),
|
||||
apiBaseURL: "https://push-lifecycle.test",
|
||||
bundleID: "dev.cmux.ios.push-lifecycle",
|
||||
apnsEnvironment: "sandbox",
|
||||
suiteName: suiteName,
|
||||
session: URLSession(configuration: configuration),
|
||||
retryDelays: []
|
||||
)
|
||||
let coordinator = MobilePushCoordinator(
|
||||
registration: registration,
|
||||
defaults: defaults,
|
||||
authorizationStatus: { .authorized },
|
||||
registerForRemoteNotifications: {},
|
||||
unregisterForRemoteNotifications: {}
|
||||
)
|
||||
|
||||
#expect(await coordinator.enable())
|
||||
await coordinator.handleDeviceToken(Data([0xAB, 0xCD]))
|
||||
await coordinator.disable()
|
||||
|
||||
#expect(LifecyclePushURLProtocol.recorder.methods == ["POST", "DELETE"])
|
||||
#expect(!defaults.bool(forKey: "cmux.notifications.pushEnabled"))
|
||||
#expect(await registration.snapshot == .disabled)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func optInPersistsAcrossCoordinatorRecreation() async {
|
||||
let suiteName = "push-coordinator-persistence-\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
let registration = LifecyclePushRegistration(enabled: false)
|
||||
let enabled = MobilePushCoordinator(
|
||||
registration: registration,
|
||||
defaults: defaults,
|
||||
authorizationStatus: { .authorized },
|
||||
requestAuthorization: { true }
|
||||
)
|
||||
|
||||
#expect(await enabled.enable())
|
||||
#expect(
|
||||
defaults.object(
|
||||
forKey: "cmux.notifications.pushEnabled"
|
||||
) as? Bool == true
|
||||
)
|
||||
#expect(MobilePushCoordinator(
|
||||
registration: registration,
|
||||
defaults: defaults,
|
||||
authorizationStatus: { .authorized }
|
||||
).isEnabled)
|
||||
|
||||
await enabled.disable()
|
||||
#expect(
|
||||
defaults.object(
|
||||
forKey: "cmux.notifications.pushEnabled"
|
||||
) as? Bool == false
|
||||
)
|
||||
#expect(!MobilePushCoordinator(
|
||||
registration: registration,
|
||||
defaults: defaults,
|
||||
authorizationStatus: { .authorized }
|
||||
).isEnabled)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func disableUnregistersWithOSBeforeBackendCleanupCompletes() async {
|
||||
let gate = LifecycleSetEnabledGate()
|
||||
let registration = LifecyclePushRegistration(
|
||||
enabled: true,
|
||||
setEnabledGate: gate
|
||||
)
|
||||
let suiteName = "push-coordinator-disable-\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.set(true, forKey: "cmux.notifications.pushEnabled")
|
||||
var unregistrationRequests = 0
|
||||
let coordinator = MobilePushCoordinator(
|
||||
registration: registration,
|
||||
defaults: defaults,
|
||||
authorizationStatus: { .authorized },
|
||||
unregisterForRemoteNotifications: {
|
||||
unregistrationRequests += 1
|
||||
}
|
||||
)
|
||||
|
||||
let disabling = Task { await coordinator.disable() }
|
||||
await gate.waitUntilStarted()
|
||||
|
||||
#expect(unregistrationRequests == 1)
|
||||
#expect(!coordinator.isEnabled)
|
||||
#expect(coordinator.registrationSnapshot == .disabled)
|
||||
|
||||
await gate.release()
|
||||
await disabling.value
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func foregroundAndReachabilityRecoveryShareOneExhaustedRegistrationRetry() async {
|
||||
let gate = LifecycleSyncGate()
|
||||
let registration = LifecyclePushRegistration(
|
||||
snapshot: PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .failed(.networkUnavailable)
|
||||
),
|
||||
syncGate: gate
|
||||
)
|
||||
let suiteName = "push-coordinator-shared-retry-\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
let coordinator = MobilePushCoordinator(
|
||||
registration: registration,
|
||||
defaults: defaults,
|
||||
authorizationStatus: { .authorized }
|
||||
)
|
||||
|
||||
let firstRefresh = Task { @MainActor in
|
||||
await coordinator.refreshReadiness()
|
||||
}
|
||||
await gate.waitUntilStarted()
|
||||
let secondRefresh = Task { @MainActor in
|
||||
await coordinator.networkDidBecomeReachable()
|
||||
}
|
||||
await Task.yield()
|
||||
|
||||
#expect(await gate.starts == 1)
|
||||
|
||||
await gate.release()
|
||||
await firstRefresh.value
|
||||
await secondRefresh.value
|
||||
|
||||
#expect(await gate.starts == 1)
|
||||
#expect(coordinator.registrationSnapshot.backendState == .registered)
|
||||
}
|
||||
}
|
||||
+440
@@ -0,0 +1,440 @@
|
||||
import CmuxAuthRuntime
|
||||
import CmuxMobileRPC
|
||||
import Testing
|
||||
|
||||
@testable import CmuxMobileShellUI
|
||||
|
||||
@Suite struct MobilePushReadinessTests {
|
||||
private let registered = PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .registered
|
||||
)
|
||||
|
||||
@Test func localOptInAloneIsNeverReportedAsReady() {
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: .authorized,
|
||||
registration: PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: false,
|
||||
backendState: .awaitingDeviceToken
|
||||
),
|
||||
mac: nil,
|
||||
phoneAPIOrigin: "https://cmux.com"
|
||||
)
|
||||
|
||||
#expect(readiness == .blocked(.awaitingDeviceToken))
|
||||
}
|
||||
|
||||
@Test func cachedTokenAwaitingBackendAcknowledgementOffersRetry() {
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: .authorized,
|
||||
registration: PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .registrationRequired
|
||||
),
|
||||
mac: nil,
|
||||
phoneAPIOrigin: "https://cmux.com"
|
||||
)
|
||||
|
||||
#expect(readiness == .blocked(.backendRegistrationRequired))
|
||||
#expect(readiness.repair == .retryRegistration)
|
||||
}
|
||||
|
||||
@Test func liveSystemDenialOverridesPersistedOptIn() {
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: .denied,
|
||||
registration: registered,
|
||||
mac: .init(
|
||||
forwardingEnabled: true,
|
||||
mode: .always,
|
||||
apiOrigin: "https://cmux.com",
|
||||
accountVerified: true
|
||||
),
|
||||
phoneAPIOrigin: "https://cmux.com"
|
||||
)
|
||||
|
||||
#expect(readiness == .blocked(.systemPermissionDenied))
|
||||
#expect(readiness.repair == .openSystemSettings)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
MobilePushAuthorization.denied,
|
||||
.unsupported,
|
||||
])
|
||||
func terminalOSStateOverridesLocalOptOut(
|
||||
authorization: MobilePushAuthorization
|
||||
) {
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: authorization,
|
||||
registration: .disabled,
|
||||
mac: nil,
|
||||
phoneAPIOrigin: "https://cmux.com"
|
||||
)
|
||||
|
||||
#expect(
|
||||
readiness == .blocked(
|
||||
authorization == .denied
|
||||
? .systemPermissionDenied
|
||||
: .systemNotificationsUnsupported
|
||||
)
|
||||
)
|
||||
#expect(readiness.repair == .openSystemSettings)
|
||||
}
|
||||
|
||||
@Test func undeterminedOSStateWithLocalOptOutStillOffersEnable() {
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: .notDetermined,
|
||||
registration: .disabled,
|
||||
mac: nil,
|
||||
phoneAPIOrigin: "https://cmux.com"
|
||||
)
|
||||
|
||||
#expect(readiness == .blocked(.phoneOptInDisabled))
|
||||
#expect(readiness.repair == .enableOnPhone)
|
||||
}
|
||||
|
||||
@Test func registeredPhoneWithoutAttachedMacReportsMacUnavailable() {
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: .authorized,
|
||||
registration: registered,
|
||||
mac: nil,
|
||||
phoneAPIOrigin: "https://cmux.com"
|
||||
)
|
||||
|
||||
#expect(readiness == .blocked(.macStatusUnavailable))
|
||||
#expect(readiness.repair == .connectMac)
|
||||
}
|
||||
|
||||
@Test func failedAPNsTokenCallbackHasItsOwnRetryAction() {
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: .authorized,
|
||||
registration: PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: false,
|
||||
backendState: .deviceTokenRegistrationFailed
|
||||
),
|
||||
mac: nil,
|
||||
phoneAPIOrigin: "https://cmux.com"
|
||||
)
|
||||
|
||||
#expect(readiness == .blocked(.deviceTokenRegistrationFailed))
|
||||
#expect(readiness.repair == .retryDeviceTokenRegistration)
|
||||
}
|
||||
|
||||
@Test func backendDeviceCeilingNamesTheLimitAndItsOnlySafeRepair() {
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: .authorized,
|
||||
registration: PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .failed(.deviceLimitReached(limit: 200))
|
||||
),
|
||||
mac: nil,
|
||||
phoneAPIOrigin: "https://cmux.com"
|
||||
)
|
||||
|
||||
#expect(readiness == .blocked(.deviceLimitReached(limit: 200)))
|
||||
#expect(readiness.repair == .disablePushOnAnotherDevice)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
MobilePushAuthorization.provisional,
|
||||
.ephemeral,
|
||||
])
|
||||
func quietOrTemporaryOSAuthorizationDoesNotMasqueradeAsFullReadiness(
|
||||
authorization: MobilePushAuthorization
|
||||
) {
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: authorization,
|
||||
registration: registered,
|
||||
mac: .init(
|
||||
forwardingEnabled: true,
|
||||
mode: .always,
|
||||
apiOrigin: "https://cmux.com",
|
||||
accountVerified: true
|
||||
),
|
||||
phoneAPIOrigin: "https://cmux.com"
|
||||
)
|
||||
|
||||
#expect(readiness == .limited(mode: .always, authorization: authorization))
|
||||
#expect(readiness.repair == .openSystemSettings)
|
||||
}
|
||||
|
||||
@Test(arguments: MobilePushPresentationLimitation.allCases.filter {
|
||||
$0 != .scheduledDeliveryEnabled
|
||||
})
|
||||
func eachSystemPresentationPolicyIsReportedIndividually(
|
||||
limitation: MobilePushPresentationLimitation
|
||||
) {
|
||||
let settings = MobilePushSystemSettings(
|
||||
authorization: .authorized,
|
||||
alertsEnabled: limitation != .alertsDisabled,
|
||||
soundsEnabled: limitation != .soundsDisabled,
|
||||
badgesEnabled: limitation != .badgesDisabled,
|
||||
lockScreenEnabled: limitation != .lockScreenDisabled,
|
||||
notificationCenterEnabled:
|
||||
limitation != .notificationCenterDisabled,
|
||||
timeSensitiveEnabled: limitation != .timeSensitiveDisabled,
|
||||
scheduledDeliveryEnabled: false
|
||||
)
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: .authorized,
|
||||
registration: registered,
|
||||
mac: .init(
|
||||
forwardingEnabled: true,
|
||||
mode: .always,
|
||||
apiOrigin: "https://cmux.com",
|
||||
accountVerified: true
|
||||
),
|
||||
systemSettings: settings,
|
||||
phoneAPIOrigin: "https://cmux.com"
|
||||
)
|
||||
|
||||
#expect(
|
||||
readiness == .presentationLimited(
|
||||
mode: .always,
|
||||
limitations: [limitation]
|
||||
)
|
||||
)
|
||||
#expect(readiness.repair == .openSystemSettings)
|
||||
}
|
||||
|
||||
@Test func scheduledDeliveryIsAWarningWhenTimeSensitiveIsDisabled() {
|
||||
let settings = MobilePushSystemSettings(
|
||||
authorization: .authorized,
|
||||
alertsEnabled: true,
|
||||
soundsEnabled: true,
|
||||
badgesEnabled: true,
|
||||
lockScreenEnabled: true,
|
||||
notificationCenterEnabled: true,
|
||||
timeSensitiveEnabled: false,
|
||||
scheduledDeliveryEnabled: true
|
||||
)
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: .authorized,
|
||||
registration: registered,
|
||||
mac: .init(
|
||||
forwardingEnabled: true,
|
||||
mode: .always,
|
||||
apiOrigin: "https://cmux.com",
|
||||
accountVerified: true
|
||||
),
|
||||
systemSettings: settings,
|
||||
phoneAPIOrigin: "https://cmux.com"
|
||||
)
|
||||
|
||||
#expect(
|
||||
readiness == .presentationLimited(
|
||||
mode: .always,
|
||||
limitations: [.timeSensitiveDisabled, .scheduledDeliveryEnabled]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test func timeSensitiveDeliveryBypassesScheduledSummary() {
|
||||
let settings = MobilePushSystemSettings(
|
||||
authorization: .authorized,
|
||||
alertsEnabled: true,
|
||||
soundsEnabled: true,
|
||||
badgesEnabled: true,
|
||||
lockScreenEnabled: true,
|
||||
notificationCenterEnabled: true,
|
||||
timeSensitiveEnabled: true,
|
||||
scheduledDeliveryEnabled: true
|
||||
)
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: .authorized,
|
||||
registration: registered,
|
||||
mac: .init(
|
||||
forwardingEnabled: true,
|
||||
mode: .always,
|
||||
apiOrigin: "https://cmux.com",
|
||||
accountVerified: true
|
||||
),
|
||||
systemSettings: settings,
|
||||
phoneAPIOrigin: "https://cmux.com"
|
||||
)
|
||||
|
||||
#expect(readiness == .ready(mode: .always))
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
MobileHostPhonePushStatus.QueuePersistence.loadFailed,
|
||||
.saveFailed,
|
||||
.clearFailed,
|
||||
])
|
||||
func retryQueuePersistenceFailuresDegradeRatherThanClaimFullReadiness(
|
||||
queuePersistence: MobileHostPhonePushStatus.QueuePersistence
|
||||
) {
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: .authorized,
|
||||
registration: registered,
|
||||
mac: .init(
|
||||
forwardingEnabled: true,
|
||||
mode: .always,
|
||||
admission: .allowed,
|
||||
queuePersistence: queuePersistence,
|
||||
apiOrigin: "https://cmux.com",
|
||||
accountVerified: true
|
||||
),
|
||||
phoneAPIOrigin: "https://cmux.com"
|
||||
)
|
||||
|
||||
#expect(
|
||||
readiness == .reliabilityLimited(
|
||||
mode: .always,
|
||||
queuePersistence: queuePersistence
|
||||
)
|
||||
)
|
||||
#expect(readiness.repair == nil)
|
||||
}
|
||||
|
||||
@Test func uninitializedRetryQueueIsReportedAsUnconfirmed() {
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: .authorized,
|
||||
registration: registered,
|
||||
mac: .init(
|
||||
forwardingEnabled: true,
|
||||
mode: .always,
|
||||
admission: .allowed,
|
||||
queuePersistence: .unknown,
|
||||
apiOrigin: "https://cmux.com",
|
||||
accountVerified: true
|
||||
),
|
||||
phoneAPIOrigin: "https://cmux.com"
|
||||
)
|
||||
|
||||
#expect(
|
||||
readiness == .reliabilityLimited(
|
||||
mode: .always,
|
||||
queuePersistence: .unknown
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test func unknownAdmissionFailsClosedInsteadOfReportingReady() {
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: .authorized,
|
||||
registration: registered,
|
||||
mac: .init(
|
||||
forwardingEnabled: true,
|
||||
mode: .always,
|
||||
admission: .unknown,
|
||||
apiOrigin: "https://cmux.com",
|
||||
accountVerified: true
|
||||
),
|
||||
phoneAPIOrigin: "https://cmux.com"
|
||||
)
|
||||
|
||||
#expect(readiness == .blocked(.macAdmissionUnavailable))
|
||||
#expect(readiness.repair == .connectMac)
|
||||
}
|
||||
|
||||
@Test func authenticatedConnectionAccountMismatchIsDistinctFromUnavailableMac() {
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: .authorized,
|
||||
registration: registered,
|
||||
mac: nil,
|
||||
macAccountMismatch: true,
|
||||
phoneAPIOrigin: "https://cmux.com"
|
||||
)
|
||||
|
||||
#expect(readiness == .blocked(.macAccountMismatch))
|
||||
#expect(readiness.repair == .signIntoMatchingAccount)
|
||||
}
|
||||
|
||||
@Test func attachedMacWithForwardingOffReportsTheSecondGate() {
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: .authorized,
|
||||
registration: registered,
|
||||
mac: .init(
|
||||
forwardingEnabled: false,
|
||||
mode: .onlyWhenAway,
|
||||
apiOrigin: "https://cmux.com",
|
||||
accountVerified: true
|
||||
),
|
||||
phoneAPIOrigin: "https://cmux.com"
|
||||
)
|
||||
|
||||
#expect(readiness == .blocked(.macForwardingDisabled))
|
||||
#expect(readiness.repair == .enableOnMac)
|
||||
}
|
||||
|
||||
@Test func mismatchedAPIOriginsCannotReportReady() {
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: .authorized,
|
||||
registration: registered,
|
||||
mac: .init(
|
||||
forwardingEnabled: true,
|
||||
mode: .always,
|
||||
apiOrigin: "http://localhost:4381",
|
||||
accountVerified: true
|
||||
),
|
||||
phoneAPIOrigin: "https://cmux-staging.vercel.app"
|
||||
)
|
||||
|
||||
#expect(readiness == .blocked(.apiOriginMismatch))
|
||||
#expect(readiness.repair == .rebuildMatchingApps)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
"ws://cmux.example",
|
||||
"wss://cmux.example",
|
||||
"file:///tmp/cmux",
|
||||
"relative/cmux",
|
||||
])
|
||||
func nonHTTPAPIOriginsFailClosedEvenWhenBothSidesMatch(
|
||||
origin: String
|
||||
) {
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: .authorized,
|
||||
registration: registered,
|
||||
mac: .init(
|
||||
forwardingEnabled: true,
|
||||
mode: .always,
|
||||
apiOrigin: origin,
|
||||
accountVerified: true
|
||||
),
|
||||
phoneAPIOrigin: origin
|
||||
)
|
||||
|
||||
#expect(readiness == .blocked(.apiOriginMismatch))
|
||||
#expect(readiness.repair == .rebuildMatchingApps)
|
||||
}
|
||||
|
||||
@Test func everyGateMustPassBeforeReadyIncludesTheLiveMode() {
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: .authorized,
|
||||
registration: registered,
|
||||
mac: .init(
|
||||
forwardingEnabled: true,
|
||||
mode: .onlyWhenAway,
|
||||
apiOrigin: "https://cmux.com/",
|
||||
accountVerified: true
|
||||
),
|
||||
phoneAPIOrigin: "https://cmux.com"
|
||||
)
|
||||
|
||||
#expect(readiness == .ready(mode: .onlyWhenAway))
|
||||
}
|
||||
|
||||
@Test func activeMacInOnlyWhenAwayModeNeverReportsReady() {
|
||||
let readiness = MobilePushReadiness.resolve(
|
||||
authorization: .authorized,
|
||||
registration: registered,
|
||||
mac: .init(
|
||||
forwardingEnabled: true,
|
||||
mode: .onlyWhenAway,
|
||||
admission: .suppressedMacActive,
|
||||
apiOrigin: "https://cmux.com",
|
||||
accountVerified: true
|
||||
),
|
||||
phoneAPIOrigin: "https://cmux.com"
|
||||
)
|
||||
|
||||
#expect(readiness == .blocked(.macCurrentlyActive))
|
||||
#expect(readiness.repair == .leaveMacOrUseAlwaysMode)
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,31 @@ public struct UITestConfig {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Push readiness preview state selected by
|
||||
/// `CMUX_UITEST_PUSH_READINESS_PREVIEW`. A set value routes the root view
|
||||
/// to the readiness preview and names its fixture state. DEBUG-only.
|
||||
public static var pushReadinessPreviewState: String? {
|
||||
pushReadinessPreviewState(
|
||||
from: ProcessInfo.processInfo.environment,
|
||||
arguments: ProcessInfo.processInfo.arguments
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolves the push-readiness preview fixture from explicit process inputs.
|
||||
public static func pushReadinessPreviewState(
|
||||
from env: [String: String],
|
||||
arguments: [String] = []
|
||||
) -> String? {
|
||||
#if DEBUG
|
||||
return env["CMUX_UITEST_PUSH_READINESS_PREVIEW"]
|
||||
?? arguments.first(where: {
|
||||
$0.hasPrefix("CMUX_UITEST_PUSH_READINESS_PREVIEW=")
|
||||
})?.split(separator: "=", maxSplits: 1).last.map(String.init)
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Changes preview mode selected by `CMUX_UITEST_CHANGES_PREVIEW`.
|
||||
///
|
||||
/// Supported DEBUG-only values are `1`, `diff`, `empty`, and `states`.
|
||||
|
||||
@@ -158,6 +158,23 @@ import Testing
|
||||
#endif
|
||||
}
|
||||
|
||||
@Test func pushReadinessPreviewUsesExplicitInputsWithEnvironmentPrecedence() {
|
||||
#if DEBUG
|
||||
#expect(UITestConfig.pushReadinessPreviewState(
|
||||
from: ["CMUX_UITEST_PUSH_READINESS_PREVIEW": "healthy"],
|
||||
arguments: ["CMUX_UITEST_PUSH_READINESS_PREVIEW=unavailable"]
|
||||
) == "healthy")
|
||||
#expect(UITestConfig.pushReadinessPreviewState(
|
||||
from: [:],
|
||||
arguments: ["CMUX_UITEST_PUSH_READINESS_PREVIEW=permission-denied"]
|
||||
) == "permission-denied")
|
||||
#else
|
||||
#expect(UITestConfig.pushReadinessPreviewState(
|
||||
from: ["CMUX_UITEST_PUSH_READINESS_PREVIEW": "healthy"]
|
||||
) == nil)
|
||||
#endif
|
||||
}
|
||||
|
||||
@Test func notificationFeedPreviewFlagIsDebugOnly() {
|
||||
let env = ["CMUX_UITEST_NOTIFICATION_FEED_PREVIEW": "1"]
|
||||
#if DEBUG
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/// Materializes agent launch-policy metadata before a resource-pressure path needs it.
|
||||
public struct AgentLaunchPolicyPrewarmer: Sendable {
|
||||
private let loadPolicyCount: @Sendable () -> Int
|
||||
|
||||
/// Creates a prewarmer for cmux's registered agent launch policies.
|
||||
public init() {
|
||||
loadPolicyCount = {
|
||||
let policies = [
|
||||
AgentLaunchSanitizer.claudePolicy,
|
||||
AgentLaunchSanitizer.codexPolicy,
|
||||
AgentLaunchSanitizer.piPolicy,
|
||||
AgentLaunchSanitizer.ampPolicy,
|
||||
AgentLaunchSanitizer.geminiPolicy,
|
||||
AgentLaunchSanitizer.antigravityPolicy,
|
||||
AgentLaunchSanitizer.cursorPolicy,
|
||||
AgentLaunchSanitizer.openCodePolicy,
|
||||
AgentLaunchSanitizer.grokPolicy,
|
||||
AgentLaunchSanitizer.kimiPolicy,
|
||||
AgentLaunchSanitizer.copilotPolicy,
|
||||
AgentLaunchSanitizer.codeBuddyPolicy,
|
||||
AgentLaunchSanitizer.factoryPolicy,
|
||||
AgentLaunchSanitizer.qoderPolicy,
|
||||
AgentLaunchSanitizer.kiroPolicy,
|
||||
AgentLaunchSanitizer.rovoDevPolicy,
|
||||
AgentLaunchSanitizer.hermesAgentPolicy,
|
||||
]
|
||||
return policies.count
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads immutable policy metadata on the caller's executor.
|
||||
///
|
||||
/// - Returns: The number of registered launch policies that were loaded.
|
||||
@discardableResult
|
||||
public func prewarmPolicies() -> Int {
|
||||
loadPolicyCount()
|
||||
}
|
||||
}
|
||||
+16
@@ -641,4 +641,20 @@ struct AgentLaunchSanitizerTests {
|
||||
) == ["qoder", "--model", "best"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Prewarms every sanitizer policy away from UI work")
|
||||
func prewarmsEverySanitizerPolicyAwayFromUIWork() async {
|
||||
let preparedPolicyCount = await Task.detached(priority: .utility) {
|
||||
AgentLaunchPolicyPrewarmer().prewarmPolicies()
|
||||
}.value
|
||||
|
||||
#expect(preparedPolicyCount == 17)
|
||||
#expect(
|
||||
AgentLaunchSanitizer.sanitizedLaunchArguments(
|
||||
["claude", "--model", "sonnet", "--resume", "old-session"],
|
||||
launcher: "claude",
|
||||
fallbackKind: "claude"
|
||||
) == ["claude", "--model", "sonnet"]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+51
-10
@@ -7,25 +7,66 @@ struct XcodeProjectAdapterTests {
|
||||
private let workspaceURL: URL
|
||||
private let projectURL: URL
|
||||
|
||||
init() {
|
||||
/// How many directories the search below looks at, counting the one holding
|
||||
/// this file. This file sits five levels below the worktree root today, so
|
||||
/// six candidates cover the current layout and eight leaves a little room.
|
||||
private static let searchedDirectoryCount = 8
|
||||
|
||||
init() throws {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
if let override = env["CMUX_PROJECT_FIXTURE"] {
|
||||
let base = URL(fileURLWithPath: override)
|
||||
self.workspaceURL = base.pathExtension.lowercased() == "xcworkspace" ? base : base.appendingPathComponent("cmux.xcworkspace")
|
||||
self.projectURL = base.pathExtension.lowercased() == "xcodeproj" ? base : base.appendingPathComponent("cmux.xcodeproj")
|
||||
// The override may name the directory, the workspace bundle, or the project
|
||||
// bundle. Siblings are derived from the containing directory, or a workspace
|
||||
// override would nest projectURL inside the .xcworkspace bundle (and the
|
||||
// symmetric bug for a .xcodeproj override).
|
||||
let ext = base.pathExtension.lowercased()
|
||||
let root = ["xcworkspace", "xcodeproj"].contains(ext) ? base.deletingLastPathComponent() : base
|
||||
self.workspaceURL = ext == "xcworkspace" ? base : root.appendingPathComponent("cmux.xcworkspace")
|
||||
self.projectURL = ext == "xcodeproj" ? base : root.appendingPathComponent("cmux.xcodeproj")
|
||||
} else {
|
||||
let here = URL(fileURLWithPath: #filePath)
|
||||
let worktreeRoot = here
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let start = URL(fileURLWithPath: #filePath).resolvingSymlinksInPath().deletingLastPathComponent()
|
||||
guard let worktreeRoot = Self.worktreeRootContainingProject(startingAt: start) else {
|
||||
throw ProjectNotFoundError(searchStart: start, searchedDirectoryCount: Self.searchedDirectoryCount)
|
||||
}
|
||||
self.workspaceURL = worktreeRoot.appendingPathComponent("cmux.xcworkspace")
|
||||
self.projectURL = worktreeRoot.appendingPathComponent("cmux.xcodeproj")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the nearest directory at or above `start` that holds cmux.xcodeproj,
|
||||
/// or nil when none of the `searchedDirectoryCount` directories it looks at has one.
|
||||
///
|
||||
/// Packages move between the Packages/Shared, Packages/iOS and Packages/macOS
|
||||
/// group folders, which changes how deep this file sits, and counting parent
|
||||
/// directories silently points at the wrong root when that happens. The search
|
||||
/// stops after a fixed number of levels so that a checkout nested inside another
|
||||
/// checkout cannot bind these tests to the outer checkout's project.
|
||||
private static func worktreeRootContainingProject(startingAt start: URL) -> URL? {
|
||||
var directory = start
|
||||
for _ in 0..<searchedDirectoryCount {
|
||||
if FileManager.default.fileExists(atPath: directory.appendingPathComponent("cmux.xcodeproj").path) {
|
||||
return directory
|
||||
}
|
||||
directory.deleteLastPathComponent()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private struct ProjectNotFoundError: Error, CustomStringConvertible {
|
||||
let searchStart: URL
|
||||
let searchedDirectoryCount: Int
|
||||
|
||||
var description: String {
|
||||
"""
|
||||
cmux.xcodeproj is not in \(searchStart.path) or in any of the \
|
||||
\(searchedDirectoryCount - 1) directories above it, so these tests have no \
|
||||
project to load. Run them from a cmux checkout, or point \
|
||||
CMUX_PROJECT_FIXTURE at a directory that contains cmux.xcodeproj.
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func loadsCmuxXcodeprojIntoOneModule() throws {
|
||||
let adapter = XcodeProjectAdapter()
|
||||
|
||||
+19
-85
@@ -4,14 +4,20 @@ public import AppKit
|
||||
@MainActor
|
||||
public final class CmuxResolvedIconImageView: NSView {
|
||||
private let imageView = NSImageView(frame: .zero)
|
||||
private let renderer = CmuxResolvedIconRenderer()
|
||||
private let renderContext: CmuxResolvedIconRenderContext
|
||||
private var request: CmuxResolvedIconRequest?
|
||||
private var renderKey: RenderKey?
|
||||
private var lastVisibleRenderKey: RenderKey?
|
||||
private var blankRenderKey: RenderKey?
|
||||
private var renderKey: CmuxResolvedIconRenderKey?
|
||||
private var lastVisibleRenderKey: CmuxResolvedIconRenderKey?
|
||||
private var blankRenderKey: CmuxResolvedIconRenderKey?
|
||||
|
||||
/// Creates the resolved icon view.
|
||||
public override init(frame frameRect: NSRect) {
|
||||
public override convenience init(frame frameRect: NSRect) {
|
||||
self.init(frame: frameRect, renderContext: CmuxResolvedIconRenderContext())
|
||||
}
|
||||
|
||||
/// Creates a resolved icon view backed by an explicit render owner.
|
||||
public init(frame frameRect: NSRect, renderContext: CmuxResolvedIconRenderContext) {
|
||||
self.renderContext = renderContext
|
||||
super.init(frame: frameRect)
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
imageView.imageScaling = .scaleProportionallyDown
|
||||
@@ -57,10 +63,15 @@ public final class CmuxResolvedIconImageView: NSView {
|
||||
imageView.image = nil
|
||||
return
|
||||
}
|
||||
let nextKey = RenderKey(request: request, appearance: effectiveAppearance)
|
||||
guard force || renderKey != nextKey else { return }
|
||||
let nextKey = CmuxResolvedIconRenderKey(request: request, appearance: effectiveAppearance)
|
||||
guard force || renderKey?.shouldSkipRender(for: nextKey) != true else { return }
|
||||
guard force || blankRenderKey?.shouldSkipBlankRetry(for: nextKey) != true else { return }
|
||||
switch renderer.render(for: request, appearance: effectiveAppearance) {
|
||||
switch renderContext.render(
|
||||
for: request,
|
||||
appearance: effectiveAppearance,
|
||||
renderKey: nextKey,
|
||||
bypassCache: force
|
||||
) {
|
||||
case .success(let image):
|
||||
renderKey = nextKey
|
||||
lastVisibleRenderKey = nextKey
|
||||
@@ -93,81 +104,4 @@ public final class CmuxResolvedIconImageView: NSView {
|
||||
imageView.setAccessibilityRole(.image)
|
||||
imageView.setAccessibilityLabel(description)
|
||||
}
|
||||
|
||||
private struct RenderKey: Equatable {
|
||||
private let source: SourceKey
|
||||
private let canReuseRenderedImage: Bool
|
||||
private let width: CGFloat
|
||||
private let height: CGFloat
|
||||
private let tint: NSColor?
|
||||
private let symbolWeight: CGFloat
|
||||
private let appearanceName: NSAppearance.Name
|
||||
private let appearanceIdentity: ObjectIdentifier
|
||||
|
||||
init(request: CmuxResolvedIconRequest, appearance: NSAppearance) {
|
||||
self.source = SourceKey(request.source)
|
||||
self.canReuseRenderedImage = source.canReuseRenderedImage
|
||||
self.width = request.size.width
|
||||
self.height = request.size.height
|
||||
self.tint = request.tintColor
|
||||
self.symbolWeight = request.symbolWeight.rawValue
|
||||
self.appearanceName = appearance.name
|
||||
self.appearanceIdentity = ObjectIdentifier(appearance)
|
||||
}
|
||||
|
||||
static func == (lhs: RenderKey, rhs: RenderKey) -> Bool {
|
||||
lhs.canReuseRenderedImage && rhs.canReuseRenderedImage && lhs.matchesRequestAndAppearance(rhs)
|
||||
}
|
||||
|
||||
func matchesRequestAndAppearance(_ other: RenderKey) -> Bool {
|
||||
source == other.source &&
|
||||
width == other.width &&
|
||||
height == other.height &&
|
||||
symbolWeight == other.symbolWeight &&
|
||||
appearanceName == other.appearanceName &&
|
||||
appearanceIdentity == other.appearanceIdentity &&
|
||||
Self.colorsEqual(tint, other.tint)
|
||||
}
|
||||
|
||||
func shouldSkipBlankRetry(for other: RenderKey) -> Bool {
|
||||
canReuseRenderedImage && other.canReuseRenderedImage && matchesRequestAndAppearance(other)
|
||||
}
|
||||
|
||||
private static func colorsEqual(_ lhs: NSColor?, _ rhs: NSColor?) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.none, .none):
|
||||
return true
|
||||
case let (lhs?, rhs?):
|
||||
return lhs.isEqual(rhs)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private enum SourceKey: Equatable {
|
||||
case systemSymbol(name: String, accessibilityDescription: String?)
|
||||
case asset(name: String, bundle: ObjectIdentifier)
|
||||
case image(ObjectIdentifier)
|
||||
|
||||
init(_ source: CmuxResolvedIconSource) {
|
||||
switch source {
|
||||
case .systemSymbol(let name, let accessibilityDescription):
|
||||
self = .systemSymbol(name: name, accessibilityDescription: accessibilityDescription)
|
||||
case .asset(let name, let bundle):
|
||||
self = .asset(name: name, bundle: ObjectIdentifier(bundle))
|
||||
case .image(let image):
|
||||
self = .image(ObjectIdentifier(image))
|
||||
}
|
||||
}
|
||||
|
||||
var canReuseRenderedImage: Bool {
|
||||
switch self {
|
||||
case .systemSymbol, .asset:
|
||||
return true
|
||||
case .image:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import AppKit
|
||||
|
||||
/// Owns a renderer and a bounded raster cache for one icon-view hierarchy.
|
||||
@MainActor
|
||||
public final class CmuxResolvedIconRenderContext {
|
||||
private typealias Entry = (
|
||||
image: NSImage,
|
||||
appearance: NSAppearance,
|
||||
assetBundle: Bundle?
|
||||
)
|
||||
|
||||
private let renderer = CmuxResolvedIconRenderer()
|
||||
private let limit: Int
|
||||
private var entries: [CmuxResolvedIconReusableRenderKey: Entry] = [:]
|
||||
private var insertionOrder: [CmuxResolvedIconReusableRenderKey] = []
|
||||
|
||||
/// Creates an isolated render owner with a bounded reusable-image cache.
|
||||
///
|
||||
/// Nonpositive limits disable cross-view caching while preserving rendering.
|
||||
public init(cacheLimit: Int = 128) {
|
||||
self.limit = max(0, cacheLimit)
|
||||
}
|
||||
|
||||
func render(
|
||||
for request: CmuxResolvedIconRequest,
|
||||
appearance: NSAppearance,
|
||||
renderKey: CmuxResolvedIconRenderKey,
|
||||
bypassCache: Bool = false
|
||||
) -> Result<NSImage, CmuxResolvedIconRenderFailure> {
|
||||
guard limit > 0 else {
|
||||
return renderer.render(for: request, appearance: appearance)
|
||||
}
|
||||
|
||||
if !bypassCache,
|
||||
let reusableKey = renderKey.reusableKey,
|
||||
let cachedImage = image(for: reusableKey, matching: renderKey) {
|
||||
return .success(cachedImage)
|
||||
}
|
||||
|
||||
let result = renderer.render(for: request, appearance: appearance)
|
||||
if case .success(let image) = result, let reusableKey = renderKey.reusableKey {
|
||||
insert(
|
||||
image,
|
||||
for: reusableKey,
|
||||
renderKey: renderKey,
|
||||
replacingExisting: bypassCache
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func image(
|
||||
for key: CmuxResolvedIconReusableRenderKey,
|
||||
matching renderKey: CmuxResolvedIconRenderKey
|
||||
) -> NSImage? {
|
||||
guard let entry = entries[key],
|
||||
entry.appearance === renderKey.appearance,
|
||||
renderKey.matchesAssetBundle(entry.assetBundle) else {
|
||||
return nil
|
||||
}
|
||||
return entry.image
|
||||
}
|
||||
|
||||
private func insert(
|
||||
_ image: NSImage,
|
||||
for key: CmuxResolvedIconReusableRenderKey,
|
||||
renderKey: CmuxResolvedIconRenderKey,
|
||||
replacingExisting: Bool
|
||||
) {
|
||||
let entry: Entry = (
|
||||
image: image,
|
||||
appearance: renderKey.appearance,
|
||||
assetBundle: renderKey.assetBundle
|
||||
)
|
||||
if entries[key] != nil {
|
||||
if replacingExisting {
|
||||
entries[key] = entry
|
||||
}
|
||||
return
|
||||
}
|
||||
if entries.count >= limit, let oldestKey = insertionOrder.first {
|
||||
insertionOrder.removeFirst()
|
||||
entries.removeValue(forKey: oldestKey)
|
||||
}
|
||||
entries[key] = entry
|
||||
insertionOrder.append(key)
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import AppKit
|
||||
|
||||
/// Identity of one icon render request under one effective appearance.
|
||||
struct CmuxResolvedIconRenderKey {
|
||||
private let source: CmuxResolvedIconSourceKey
|
||||
private let width: CGFloat
|
||||
private let height: CGFloat
|
||||
private let tint: NSColor?
|
||||
private let symbolWeight: CGFloat
|
||||
private let appearanceName: NSAppearance.Name
|
||||
private let appearanceIdentity: ObjectIdentifier
|
||||
let appearance: NSAppearance
|
||||
let assetBundle: Bundle?
|
||||
|
||||
private func tintMatches(_ other: NSColor?) -> Bool {
|
||||
switch (tint, other) {
|
||||
case (.none, .none):
|
||||
return true
|
||||
case let (tint?, other?):
|
||||
return tint.isEqual(other)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func matchesAssetBundle(_ other: Bundle?) -> Bool {
|
||||
switch (assetBundle, other) {
|
||||
case (.none, .none):
|
||||
return true
|
||||
case let (assetBundle?, other?):
|
||||
return assetBundle === other
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
init(request: CmuxResolvedIconRequest, appearance: NSAppearance) {
|
||||
source = CmuxResolvedIconSourceKey(request.source)
|
||||
width = request.size.width
|
||||
height = request.size.height
|
||||
tint = request.tintColor
|
||||
symbolWeight = request.symbolWeight.rawValue
|
||||
appearanceName = appearance.name
|
||||
appearanceIdentity = ObjectIdentifier(appearance)
|
||||
self.appearance = appearance
|
||||
if case .asset(_, let bundle) = request.source {
|
||||
assetBundle = bundle
|
||||
} else {
|
||||
assetBundle = nil
|
||||
}
|
||||
}
|
||||
|
||||
func shouldSkipRender(for other: CmuxResolvedIconRenderKey) -> Bool {
|
||||
source.canReuseRenderedImage &&
|
||||
other.source.canReuseRenderedImage &&
|
||||
matchesRequestAndAppearance(other)
|
||||
}
|
||||
|
||||
func matchesRequestAndAppearance(_ other: CmuxResolvedIconRenderKey) -> Bool {
|
||||
source == other.source &&
|
||||
width == other.width &&
|
||||
height == other.height &&
|
||||
symbolWeight == other.symbolWeight &&
|
||||
appearanceName == other.appearanceName &&
|
||||
appearanceIdentity == other.appearanceIdentity &&
|
||||
tintMatches(other.tint)
|
||||
}
|
||||
|
||||
func shouldSkipBlankRetry(for other: CmuxResolvedIconRenderKey) -> Bool {
|
||||
shouldSkipRender(for: other)
|
||||
}
|
||||
|
||||
var reusableKey: CmuxResolvedIconReusableRenderKey? {
|
||||
guard source.canReuseRenderedImage else { return nil }
|
||||
return CmuxResolvedIconReusableRenderKey(
|
||||
source: source,
|
||||
width: width,
|
||||
height: height,
|
||||
tint: tint,
|
||||
symbolWeight: symbolWeight,
|
||||
appearanceName: appearanceName,
|
||||
appearanceIdentity: appearanceIdentity
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import AppKit
|
||||
|
||||
/// Hashable subset of a render key that can safely share a cached raster.
|
||||
struct CmuxResolvedIconReusableRenderKey: Hashable {
|
||||
let source: CmuxResolvedIconSourceKey
|
||||
let width: CGFloat
|
||||
let height: CGFloat
|
||||
let tint: NSColor?
|
||||
let symbolWeight: CGFloat
|
||||
let appearanceName: NSAppearance.Name
|
||||
let appearanceIdentity: ObjectIdentifier
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import AppKit
|
||||
|
||||
/// Stable source identity used to compare icon render requests.
|
||||
enum CmuxResolvedIconSourceKey: Hashable {
|
||||
case systemSymbol(name: String, accessibilityDescription: String?)
|
||||
case asset(name: String, bundle: ObjectIdentifier)
|
||||
case image(ObjectIdentifier)
|
||||
|
||||
init(_ source: CmuxResolvedIconSource) {
|
||||
switch source {
|
||||
case .systemSymbol(let name, let accessibilityDescription):
|
||||
self = .systemSymbol(name: name, accessibilityDescription: accessibilityDescription)
|
||||
case .asset(let name, let bundle):
|
||||
self = .asset(name: name, bundle: ObjectIdentifier(bundle))
|
||||
case .image(let image):
|
||||
self = .image(ObjectIdentifier(image))
|
||||
}
|
||||
}
|
||||
|
||||
var canReuseRenderedImage: Bool {
|
||||
switch self {
|
||||
case .systemSymbol, .asset:
|
||||
return true
|
||||
case .image:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -79,6 +79,87 @@ import Testing
|
||||
#expect(renderedImage(in: view) === firstImage)
|
||||
}
|
||||
|
||||
@Test func reusableIconsShareRenderedImageAcrossViews() throws {
|
||||
let appearance = try #require(NSAppearance(named: .aqua))
|
||||
let renderContext = CmuxResolvedIconRenderContext()
|
||||
let request = CmuxResolvedIconRequest(
|
||||
source: .systemSymbol(name: "folder.fill", accessibilityDescription: nil),
|
||||
size: NSSize(width: 16, height: 16),
|
||||
tintColor: .secondaryLabelColor,
|
||||
symbolWeight: .regular
|
||||
)
|
||||
let frame = NSRect(x: 0, y: 0, width: 16, height: 16)
|
||||
let firstView = CmuxResolvedIconImageView(frame: frame, renderContext: renderContext)
|
||||
firstView.appearance = appearance
|
||||
firstView.apply(request)
|
||||
let firstImage = try #require(renderedImage(in: firstView))
|
||||
|
||||
let secondView = CmuxResolvedIconImageView(frame: frame, renderContext: renderContext)
|
||||
secondView.appearance = appearance
|
||||
secondView.apply(request)
|
||||
let secondImage = try #require(renderedImage(in: secondView))
|
||||
|
||||
#expect(secondImage === firstImage)
|
||||
|
||||
let isolatedView = CmuxResolvedIconImageView(
|
||||
frame: frame,
|
||||
renderContext: CmuxResolvedIconRenderContext()
|
||||
)
|
||||
isolatedView.appearance = appearance
|
||||
isolatedView.apply(request)
|
||||
#expect(renderedImage(in: isolatedView) !== firstImage)
|
||||
}
|
||||
|
||||
@Test func forcedRefreshReplacesTheSharedCachedRaster() throws {
|
||||
let appearance = try #require(NSAppearance(named: .aqua))
|
||||
let renderContext = CmuxResolvedIconRenderContext()
|
||||
let request = CmuxResolvedIconRequest(
|
||||
source: .systemSymbol(name: "folder.fill", accessibilityDescription: nil),
|
||||
size: NSSize(width: 16, height: 16),
|
||||
tintColor: .secondaryLabelColor,
|
||||
symbolWeight: .regular
|
||||
)
|
||||
let frame = NSRect(x: 0, y: 0, width: 16, height: 16)
|
||||
let firstView = CmuxResolvedIconImageView(frame: frame, renderContext: renderContext)
|
||||
firstView.appearance = appearance
|
||||
firstView.apply(request)
|
||||
let firstImage = try #require(renderedImage(in: firstView))
|
||||
|
||||
firstView.viewDidMoveToWindow()
|
||||
let refreshedImage = try #require(renderedImage(in: firstView))
|
||||
#expect(refreshedImage !== firstImage)
|
||||
|
||||
let secondView = CmuxResolvedIconImageView(frame: frame, renderContext: renderContext)
|
||||
secondView.appearance = appearance
|
||||
secondView.apply(request)
|
||||
#expect(renderedImage(in: secondView) === refreshedImage)
|
||||
}
|
||||
|
||||
@Test func zeroCacheLimitDisablesCrossViewReuseWithoutDisablingRendering() throws {
|
||||
let appearance = try #require(NSAppearance(named: .aqua))
|
||||
let renderContext = CmuxResolvedIconRenderContext(cacheLimit: 0)
|
||||
let request = CmuxResolvedIconRequest(
|
||||
source: .systemSymbol(name: "folder.fill", accessibilityDescription: nil),
|
||||
size: NSSize(width: 16, height: 16),
|
||||
tintColor: .secondaryLabelColor,
|
||||
symbolWeight: .regular
|
||||
)
|
||||
let frame = NSRect(x: 0, y: 0, width: 16, height: 16)
|
||||
let firstView = CmuxResolvedIconImageView(frame: frame, renderContext: renderContext)
|
||||
firstView.appearance = appearance
|
||||
firstView.apply(request)
|
||||
let firstImage = try #require(renderedImage(in: firstView))
|
||||
|
||||
let secondView = CmuxResolvedIconImageView(frame: frame, renderContext: renderContext)
|
||||
secondView.appearance = appearance
|
||||
secondView.apply(request)
|
||||
let secondImage = try #require(renderedImage(in: secondView))
|
||||
|
||||
#expect(secondImage !== firstImage)
|
||||
#expect(visiblePixelCount(in: firstImage) > 0)
|
||||
#expect(visiblePixelCount(in: secondImage) > 0)
|
||||
}
|
||||
|
||||
@Test func imageViewRerendersWhenImagePixelsChangeInPlace() throws {
|
||||
let view = CmuxResolvedIconImageView(frame: NSRect(x: 0, y: 0, width: 16, height: 16))
|
||||
view.appearance = NSAppearance(named: .aqua)
|
||||
|
||||
@@ -57,7 +57,7 @@ public struct BrowserUserAgentPolicy: Sendable {
|
||||
|
||||
/// Resolves the browser identity policy for a top-level destination.
|
||||
///
|
||||
/// Google Sheets intentionally uses WebKit's default embedded identity.
|
||||
/// Every web destination receives the current Safari-compatible identity.
|
||||
/// Non-web destinations have no applicable user-agent policy.
|
||||
///
|
||||
/// - Parameter url: The destination of the top-level navigation.
|
||||
@@ -68,16 +68,7 @@ public struct BrowserUserAgentPolicy: Sendable {
|
||||
scheme == "http" || scheme == "https" else {
|
||||
return .notApplicable
|
||||
}
|
||||
guard let host = url.host?.lowercased() else {
|
||||
return .custom(safariCompatibleUserAgent)
|
||||
}
|
||||
let isSheetsHost = host == "sheets.google.com" || host == "spreadsheets.google.com"
|
||||
let isSheetsPath = host == "docs.google.com"
|
||||
&& url.path.split(separator: "/", omittingEmptySubsequences: true).first?
|
||||
.lowercased() == "spreadsheets"
|
||||
return isSheetsHost || isSheetsPath
|
||||
? .webKitDefault
|
||||
: .custom(safariCompatibleUserAgent)
|
||||
return .custom(safariCompatibleUserAgent)
|
||||
}
|
||||
|
||||
/// Parses a numeric dot-separated browser version.
|
||||
|
||||
@@ -3,9 +3,6 @@ public enum BrowserUserAgentPolicyResolution: Equatable, Sendable {
|
||||
/// Use the supplied custom user-agent identity for an HTTP or HTTPS destination.
|
||||
case custom(String)
|
||||
|
||||
/// Use WebKit's default embedded identity for an HTTP or HTTPS destination.
|
||||
case webKitDefault
|
||||
|
||||
/// User-agent identity does not apply to this non-web destination.
|
||||
case notApplicable
|
||||
}
|
||||
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
import Foundation
|
||||
|
||||
/// JavaScript evaluation wrapping and bridge-safe result handling.
|
||||
extension BrowserControlService {
|
||||
/// Builds the complete function body used to evaluate browser JavaScript.
|
||||
///
|
||||
/// The wrapper awaits promise-like results, converts DOM rectangles and
|
||||
/// JSON containers before they cross WebKit, duplicates repeated aliases,
|
||||
/// and emits deterministic error envelopes for cycles or results whose
|
||||
/// normalization would exceed bounded work.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - script: User or automation JavaScript to execute.
|
||||
/// - useEval: Whether `script` is data passed to `eval`; when false it is
|
||||
/// inserted as a trusted expression built by cmux.
|
||||
/// - frameSelector: Optional selector for a same-origin frame execution realm.
|
||||
/// - Returns: A JavaScript async-function body for WebKit evaluation.
|
||||
public func evaluationScript(
|
||||
script: String,
|
||||
useEval: Bool,
|
||||
frameSelector: String?
|
||||
) -> String {
|
||||
let typeKey = jsonLiteral(evalEnvelope.typeKey)
|
||||
let valueKey = jsonLiteral(evalEnvelope.valueKey)
|
||||
let typeUndefined = jsonLiteral(evalEnvelope.typeUndefined)
|
||||
let typeValue = jsonLiteral(evalEnvelope.typeValue)
|
||||
let typeError = jsonLiteral(evalEnvelope.typeError)
|
||||
let errorCodeKey = jsonLiteral(evalEnvelope.errorCodeKey)
|
||||
let errorMessageKey = jsonLiteral(evalEnvelope.errorMessageKey)
|
||||
let frameUnavailableCode = jsonLiteral("frame_unavailable")
|
||||
let operationFailedMessage = jsonLiteral(String(
|
||||
localized: "cli.browser.error.operationFailed",
|
||||
defaultValue: "Browser operation failed"
|
||||
))
|
||||
|
||||
let framePrelude: String
|
||||
if let frameSelector {
|
||||
let selectorLiteral = jsonLiteral(frameSelector)
|
||||
framePrelude = """
|
||||
let __cmuxDoc;
|
||||
let __cmuxWindow;
|
||||
const __cmuxFrameUnavailable = () => ({
|
||||
[\(typeKey)]: \(typeError),
|
||||
[\(errorCodeKey)]: \(frameUnavailableCode),
|
||||
[\(errorMessageKey)]: \(operationFailedMessage)
|
||||
});
|
||||
try {
|
||||
const __cmuxFrame = document.querySelector(\(selectorLiteral));
|
||||
if (!__cmuxFrame || !('contentDocument' in __cmuxFrame)) {
|
||||
return __cmuxFrameUnavailable();
|
||||
}
|
||||
const __cmuxFrameDocument = __cmuxFrame.contentDocument;
|
||||
const __cmuxFrameWindow = __cmuxFrame.contentWindow;
|
||||
if (!__cmuxFrameDocument || !__cmuxFrameWindow) {
|
||||
return __cmuxFrameUnavailable();
|
||||
}
|
||||
__cmuxDoc = __cmuxFrameDocument;
|
||||
__cmuxWindow = __cmuxFrameWindow;
|
||||
} catch (_) {
|
||||
return __cmuxFrameUnavailable();
|
||||
}
|
||||
"""
|
||||
} else {
|
||||
framePrelude = "const __cmuxDoc = document;"
|
||||
}
|
||||
|
||||
let executionBlock: String
|
||||
if frameSelector != nil {
|
||||
// Member-call eval is indirect. The eval function belongs to the
|
||||
// selected frame, so every global lookup uses that frame's realm.
|
||||
executionBlock = "const __r = __cmuxWindow.eval(\(jsonLiteral(script)));"
|
||||
} else {
|
||||
executionBlock = useEval
|
||||
? "const __r = eval(\(jsonLiteral(script)));"
|
||||
: "const __r = \(script);"
|
||||
}
|
||||
let circularReferenceCode = jsonLiteral(evalEnvelope.circularReferenceCode)
|
||||
let circularReferenceMessage = jsonLiteral(evalEnvelope.circularReferenceMessage)
|
||||
let resultTooComplexCode = jsonLiteral("result_too_complex")
|
||||
|
||||
return """
|
||||
\(framePrelude)
|
||||
|
||||
const __cmuxMaybeAwait = async (__r) => {
|
||||
if (__r !== null && (typeof __r === 'object' || typeof __r === 'function') && typeof __r.then === 'function') {
|
||||
return await __r;
|
||||
}
|
||||
return __r;
|
||||
};
|
||||
|
||||
const __cmuxCircularReference = Symbol('cmux.circularReference');
|
||||
const __cmuxResultTooComplex = Symbol('cmux.resultTooComplex');
|
||||
const __cmuxChargeText = (__text, __budget) => {
|
||||
if (__text.length > __budget.remainingTextCodeUnits) {
|
||||
throw __cmuxResultTooComplex;
|
||||
}
|
||||
__budget.remainingTextCodeUnits -= __text.length;
|
||||
};
|
||||
const __cmuxBridgeSafeValue = (
|
||||
__value,
|
||||
__ancestors = new WeakSet(),
|
||||
__budget = {remainingNodes: 10000, remainingTextCodeUnits: 1000000},
|
||||
__depth = 0
|
||||
) => {
|
||||
if (__budget.remainingNodes <= 0 || __depth > 100) {
|
||||
throw __cmuxResultTooComplex;
|
||||
}
|
||||
__budget.remainingNodes -= 1;
|
||||
|
||||
if (__value === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof __value === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const __valueType = typeof __value;
|
||||
if (__valueType === 'number') {
|
||||
return Number.isFinite(__value) ? __value : null;
|
||||
}
|
||||
if (__valueType === 'string') {
|
||||
__cmuxChargeText(__value, __budget);
|
||||
return __value;
|
||||
}
|
||||
if (__valueType === 'bigint' || __valueType === 'symbol') {
|
||||
const __stringValue = String(__value);
|
||||
__cmuxChargeText(__stringValue, __budget);
|
||||
return __stringValue;
|
||||
}
|
||||
if (__valueType !== 'object' && __valueType !== 'function') {
|
||||
return __value;
|
||||
}
|
||||
|
||||
if (__ancestors.has(__value)) {
|
||||
throw __cmuxCircularReference;
|
||||
}
|
||||
|
||||
let __objectTag = '';
|
||||
try {
|
||||
__objectTag = Object.prototype.toString.call(__value);
|
||||
} catch (_) {}
|
||||
|
||||
// iframe values have different constructors, so instanceof alone does
|
||||
// not recognize their DOMRect brand.
|
||||
const __isDOMRect =
|
||||
(typeof DOMRectReadOnly !== 'undefined' && __value instanceof DOMRectReadOnly) ||
|
||||
__objectTag === '[object DOMRect]' ||
|
||||
__objectTag === '[object DOMRectReadOnly]';
|
||||
if (__isDOMRect) {
|
||||
__ancestors.add(__value);
|
||||
try {
|
||||
return {
|
||||
x: __cmuxBridgeSafeValue(__value.x, __ancestors, __budget, __depth + 1),
|
||||
y: __cmuxBridgeSafeValue(__value.y, __ancestors, __budget, __depth + 1),
|
||||
width: __cmuxBridgeSafeValue(__value.width, __ancestors, __budget, __depth + 1),
|
||||
height: __cmuxBridgeSafeValue(__value.height, __ancestors, __budget, __depth + 1),
|
||||
top: __cmuxBridgeSafeValue(__value.top, __ancestors, __budget, __depth + 1),
|
||||
right: __cmuxBridgeSafeValue(__value.right, __ancestors, __budget, __depth + 1),
|
||||
bottom: __cmuxBridgeSafeValue(__value.bottom, __ancestors, __budget, __depth + 1),
|
||||
left: __cmuxBridgeSafeValue(__value.left, __ancestors, __budget, __depth + 1)
|
||||
};
|
||||
} finally {
|
||||
__ancestors.delete(__value);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(__value)) {
|
||||
__ancestors.add(__value);
|
||||
try {
|
||||
const __copy = [];
|
||||
for (let __index = 0; __index < __value.length; __index += 1) {
|
||||
if (__budget.remainingNodes <= 0) {
|
||||
throw __cmuxResultTooComplex;
|
||||
}
|
||||
__copy.push(__cmuxBridgeSafeValue(
|
||||
__value[__index],
|
||||
__ancestors,
|
||||
__budget,
|
||||
__depth + 1
|
||||
));
|
||||
}
|
||||
return __copy;
|
||||
} finally {
|
||||
__ancestors.delete(__value);
|
||||
}
|
||||
}
|
||||
|
||||
__ancestors.add(__value);
|
||||
try {
|
||||
const __copy = {};
|
||||
let __enumeratedKeys = 0;
|
||||
for (const __key in __value) {
|
||||
__enumeratedKeys += 1;
|
||||
if (__enumeratedKeys > 10000 || __budget.remainingNodes <= 0) {
|
||||
throw __cmuxResultTooComplex;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(__value, __key)) {
|
||||
continue;
|
||||
}
|
||||
__cmuxChargeText(__key, __budget);
|
||||
Object.defineProperty(__copy, __key, {
|
||||
value: __cmuxBridgeSafeValue(
|
||||
__value[__key],
|
||||
__ancestors,
|
||||
__budget,
|
||||
__depth + 1
|
||||
),
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true
|
||||
});
|
||||
}
|
||||
return __copy;
|
||||
} finally {
|
||||
__ancestors.delete(__value);
|
||||
}
|
||||
};
|
||||
|
||||
const __cmuxEvalInFrame = async function() {
|
||||
const document = __cmuxDoc;
|
||||
\(executionBlock)
|
||||
const __value = await __cmuxMaybeAwait(__r);
|
||||
if (typeof __value === 'undefined') {
|
||||
return { [\(typeKey)]: \(typeUndefined) };
|
||||
}
|
||||
let __cmuxSafeValue;
|
||||
try {
|
||||
__cmuxSafeValue = __cmuxBridgeSafeValue(__value);
|
||||
} catch (__error) {
|
||||
if (__error === __cmuxCircularReference) {
|
||||
return {
|
||||
[\(typeKey)]: \(typeError),
|
||||
[\(errorCodeKey)]: \(circularReferenceCode),
|
||||
[\(errorMessageKey)]: \(circularReferenceMessage)
|
||||
};
|
||||
}
|
||||
if (__error === __cmuxResultTooComplex) {
|
||||
return {
|
||||
[\(typeKey)]: \(typeError),
|
||||
[\(errorCodeKey)]: \(resultTooComplexCode),
|
||||
[\(errorMessageKey)]: \(operationFailedMessage)
|
||||
};
|
||||
}
|
||||
throw __error;
|
||||
}
|
||||
return {
|
||||
[\(typeKey)]: \(typeValue),
|
||||
[\(valueKey)]: __cmuxSafeValue
|
||||
};
|
||||
};
|
||||
|
||||
return await __cmuxEvalInFrame();
|
||||
"""
|
||||
}
|
||||
|
||||
/// Resolves a raw WebKit value into its browser-eval envelope meaning.
|
||||
/// - Parameter rawValue: Value returned by WebKit.
|
||||
/// - Returns: The unwrapped value, `undefined`, or stable bridge error.
|
||||
public func resolveEvaluationEnvelope(_ rawValue: Any?) -> BrowserEvalEnvelopeResolution {
|
||||
guard let dictionary = rawValue as? [String: Any],
|
||||
let type = dictionary[evalEnvelope.typeKey] as? String else {
|
||||
return .unwrapped(rawValue)
|
||||
}
|
||||
|
||||
switch type {
|
||||
case evalEnvelope.typeUndefined:
|
||||
return .undefined
|
||||
case evalEnvelope.typeValue:
|
||||
return .value(dictionary[evalEnvelope.valueKey])
|
||||
case evalEnvelope.typeError:
|
||||
guard let code = dictionary[evalEnvelope.errorCodeKey] as? String,
|
||||
let message = dictionary[evalEnvelope.errorMessageKey] as? String else {
|
||||
return .unwrapped(rawValue)
|
||||
}
|
||||
return .error(code: code, message: message)
|
||||
default:
|
||||
return .unwrapped(rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import Foundation
|
||||
/// lifecycle nor any AppKit/WebKit object. Specifically it builds the JavaScript
|
||||
/// strings for the semantic element locators (`find.role`, `find.text`, and the
|
||||
/// other `find.*` actions), canonical keyboard events, the not-found diagnostics
|
||||
/// probe, and the `find.first`/`find.last`/`find.nth` selector scripts; it
|
||||
/// normalizes raw JavaScript results into JSON-serializable values; it classifies
|
||||
/// JavaScript failures; and it composes the human-readable element-not-found
|
||||
/// message.
|
||||
/// probe, the browser-eval bridge wrapper, and the
|
||||
/// `find.first`/`find.last`/`find.nth` selector scripts; it normalizes raw
|
||||
/// JavaScript results into JSON-serializable values; it classifies JavaScript
|
||||
/// failures; and it composes the human-readable element-not-found message.
|
||||
///
|
||||
/// The owning `@MainActor` controller keeps the per-surface mutable state
|
||||
/// (element-ref table, dialog queue, init scripts) and the WebKit evaluation seam;
|
||||
|
||||
@@ -5,15 +5,17 @@ import Foundation
|
||||
/// When a page-world script returns `undefined`, WebKit collapses it to `nil`,
|
||||
/// which is indistinguishable from a script that returned JSON `null`. The
|
||||
/// browser eval path therefore wraps every result in a small JSON object whose
|
||||
/// `typeKey` is either `typeUndefined` or `typeValue`, with the real payload (for
|
||||
/// the value case) under `valueKey`. ``BrowserControlService/normalizeJSValue(_:isUndefinedSentinel:)``
|
||||
/// `typeKey` is `typeUndefined`, `typeValue`, or `typeError`, with the real
|
||||
/// payload (for the value case) under `valueKey`. Error envelopes carry a stable
|
||||
/// code and message so values that cannot cross WebKit never reach response
|
||||
/// encoding. ``BrowserControlService/normalizeJSValue(_:isUndefinedSentinel:)``
|
||||
/// re-materializes the `undefined` sentinel back into this envelope shape so the
|
||||
/// RPC reply distinguishes the two.
|
||||
/// RPC reply preserves the distinction.
|
||||
///
|
||||
/// The default values are the exact strings the cmux v2 browser RPC wire format
|
||||
/// has always used; do not change them without a coordinated protocol bump.
|
||||
public struct BrowserEvalEnvelope: Sendable, Equatable {
|
||||
/// JSON key carrying the envelope discriminator (`typeUndefined` or `typeValue`).
|
||||
/// JSON key carrying the envelope discriminator.
|
||||
public let typeKey: String
|
||||
/// JSON key carrying the real value when the discriminator is `typeValue`.
|
||||
public let valueKey: String
|
||||
@@ -21,6 +23,16 @@ public struct BrowserEvalEnvelope: Sendable, Equatable {
|
||||
public let typeUndefined: String
|
||||
/// Discriminator value indicating the script produced a concrete value.
|
||||
public let typeValue: String
|
||||
/// Discriminator value indicating result sanitization failed.
|
||||
public let typeError: String
|
||||
/// JSON key carrying a stable error code when the discriminator is `typeError`.
|
||||
public let errorCodeKey: String
|
||||
/// JSON key carrying an error message when the discriminator is `typeError`.
|
||||
public let errorMessageKey: String
|
||||
/// Error code returned when the evaluated result contains a true cycle.
|
||||
public let circularReferenceCode: String
|
||||
/// Error message returned when the evaluated result contains a true cycle.
|
||||
public let circularReferenceMessage: String
|
||||
|
||||
/// Creates an envelope descriptor.
|
||||
/// - Parameters:
|
||||
@@ -28,15 +40,34 @@ public struct BrowserEvalEnvelope: Sendable, Equatable {
|
||||
/// - valueKey: JSON key for the payload. Defaults to the wire value `"__cmux_v"`.
|
||||
/// - typeUndefined: discriminator for `undefined`. Defaults to `"undefined"`.
|
||||
/// - typeValue: discriminator for a concrete value. Defaults to `"value"`.
|
||||
/// - typeError: discriminator for a sanitization failure. Defaults to `"error"`.
|
||||
/// - errorCodeKey: JSON key for an error code. Defaults to `"__cmux_error_code"`.
|
||||
/// - errorMessageKey: JSON key for an error message. Defaults to `"__cmux_error_message"`.
|
||||
/// - circularReferenceCode: stable circular-reference error code.
|
||||
/// - circularReferenceMessage: stable circular-reference error message;
|
||||
/// `nil` uses the localized default.
|
||||
public init(
|
||||
typeKey: String = "__cmux_t",
|
||||
valueKey: String = "__cmux_v",
|
||||
typeUndefined: String = "undefined",
|
||||
typeValue: String = "value"
|
||||
typeValue: String = "value",
|
||||
typeError: String = "error",
|
||||
errorCodeKey: String = "__cmux_error_code",
|
||||
errorMessageKey: String = "__cmux_error_message",
|
||||
circularReferenceCode: String = "circular_reference",
|
||||
circularReferenceMessage: String? = nil
|
||||
) {
|
||||
self.typeKey = typeKey
|
||||
self.valueKey = valueKey
|
||||
self.typeUndefined = typeUndefined
|
||||
self.typeValue = typeValue
|
||||
self.typeError = typeError
|
||||
self.errorCodeKey = errorCodeKey
|
||||
self.errorMessageKey = errorMessageKey
|
||||
self.circularReferenceCode = circularReferenceCode
|
||||
self.circularReferenceMessage = circularReferenceMessage ?? String(
|
||||
localized: "cli.browser.error.circularReference",
|
||||
defaultValue: "browser.eval result contains a circular reference"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
/// Meaning of a raw WebKit value after inspecting the browser-eval envelope.
|
||||
public enum BrowserEvalEnvelopeResolution {
|
||||
/// The value did not use the browser-eval envelope and should pass through.
|
||||
case unwrapped(Any?)
|
||||
/// The evaluated script returned JavaScript `undefined`.
|
||||
case undefined
|
||||
/// The evaluated script returned the associated concrete value.
|
||||
case value(Any?)
|
||||
/// Sanitizing the evaluated value failed with a stable protocol error.
|
||||
case error(code: String, message: String)
|
||||
}
|
||||
@@ -27,14 +27,17 @@ struct BrowserUserAgentPolicyTests {
|
||||
#expect(newerPolicy.safariCompatibleUserAgent.contains("Version/27.0 Safari/605.1.15"))
|
||||
}
|
||||
|
||||
@Test func googleSheetsKeepsEmbeddedWebKitIdentity() {
|
||||
@Test func googleSheetsReceivesCurrentSafariCompatibleIdentity() {
|
||||
let sheetURL = URL(string: "https://docs.google.com/spreadsheets/d/example/edit")!
|
||||
let sheetsRedirectURL = URL(string: "https://sheets.google.com/")!
|
||||
let legacyRedirectURL = URL(string: "https://spreadsheets.google.com/")!
|
||||
let expectedResolution = BrowserUserAgentPolicyResolution.custom(
|
||||
policy.safariCompatibleUserAgent
|
||||
)
|
||||
|
||||
#expect(policy.resolution(for: sheetURL) == .webKitDefault)
|
||||
#expect(policy.resolution(for: sheetsRedirectURL) == .webKitDefault)
|
||||
#expect(policy.resolution(for: legacyRedirectURL) == .webKitDefault)
|
||||
#expect(policy.resolution(for: sheetURL) == expectedResolution)
|
||||
#expect(policy.resolution(for: sheetsRedirectURL) == expectedResolution)
|
||||
#expect(policy.resolution(for: legacyRedirectURL) == expectedResolution)
|
||||
}
|
||||
|
||||
@Test func otherGoogleWorkspaceEditorsRemainSafariCompatible() {
|
||||
|
||||
+440
@@ -0,0 +1,440 @@
|
||||
import Foundation
|
||||
import JavaScriptCore
|
||||
import Testing
|
||||
import WebKit
|
||||
@testable import CmuxBrowser
|
||||
|
||||
@Suite("BrowserControlService evaluation script")
|
||||
struct BrowserControlServiceEvaluationScriptTests {
|
||||
private let service = BrowserControlService()
|
||||
|
||||
@Test("repeated aliases become independent JSON-safe values")
|
||||
func repeatedAliasesAreJSONSafe() throws {
|
||||
let envelope = try evaluate(
|
||||
"""
|
||||
(() => {
|
||||
const shared = {answer: 42};
|
||||
return {first: shared, second: shared, items: [shared, shared]};
|
||||
})()
|
||||
"""
|
||||
)
|
||||
|
||||
let value = try #require(envelope[service.evalEnvelope.valueKey] as? [String: Any])
|
||||
let first = try #require(value["first"] as? [String: Any])
|
||||
let second = try #require(value["second"] as? [String: Any])
|
||||
let items = try #require(value["items"] as? [[String: Any]])
|
||||
#expect(envelope[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeValue)
|
||||
#expect(first["answer"] as? Int == 42)
|
||||
#expect(second["answer"] as? Int == 42)
|
||||
#expect(items.count == 2)
|
||||
#expect(items.allSatisfy { $0["answer"] as? Int == 42 })
|
||||
}
|
||||
|
||||
@Test("true cycles produce a deterministic error envelope")
|
||||
func cycleProducesExplicitError() throws {
|
||||
let envelope = try evaluate(
|
||||
"""
|
||||
(() => {
|
||||
const value = {};
|
||||
value.self = value;
|
||||
return value;
|
||||
})()
|
||||
"""
|
||||
)
|
||||
|
||||
#expect(envelope[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeError)
|
||||
#expect(
|
||||
envelope[service.evalEnvelope.errorCodeKey] as? String
|
||||
== service.evalEnvelope.circularReferenceCode
|
||||
)
|
||||
#expect(
|
||||
envelope[service.evalEnvelope.errorMessageKey] as? String
|
||||
== service.evalEnvelope.circularReferenceMessage
|
||||
)
|
||||
|
||||
switch service.resolveEvaluationEnvelope(envelope) {
|
||||
case .error(let code, let message):
|
||||
#expect(code == "circular_reference")
|
||||
#expect(message == service.evalEnvelope.circularReferenceMessage)
|
||||
default:
|
||||
Issue.record("Expected an explicit browser-eval error resolution")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("shared alias expansion is rejected before exponential growth")
|
||||
func sharedAliasExpansionProducesExplicitError() throws {
|
||||
let envelope = try evaluate(
|
||||
"""
|
||||
(() => {
|
||||
var value = {answer: 42};
|
||||
for (let depth = 0; depth < 14; depth += 1) {
|
||||
value = {left: value, right: value};
|
||||
}
|
||||
return value;
|
||||
})()
|
||||
"""
|
||||
)
|
||||
|
||||
#expect(envelope[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeError)
|
||||
#expect(envelope[service.evalEnvelope.errorCodeKey] as? String == "result_too_complex")
|
||||
|
||||
switch service.resolveEvaluationEnvelope(envelope) {
|
||||
case .error(let code, let message):
|
||||
#expect(code == "result_too_complex")
|
||||
#expect(!message.isEmpty)
|
||||
default:
|
||||
Issue.record("Expected a bounded browser-eval error resolution")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("an unavailable selected frame never falls back to the top document")
|
||||
func unavailableFrameProducesExplicitError() throws {
|
||||
let documentSetups = [
|
||||
"var document = {secret: 'top-secret', querySelector: () => null};",
|
||||
"var document = {secret: 'top-secret', querySelector: () => ({})};",
|
||||
"""
|
||||
var document = {
|
||||
secret: 'top-secret',
|
||||
querySelector: () => ({
|
||||
get contentDocument() { throw new Error('cross-origin'); }
|
||||
})
|
||||
};
|
||||
""",
|
||||
]
|
||||
|
||||
for documentSetup in documentSetups {
|
||||
let envelope = try evaluate(
|
||||
"document.secret",
|
||||
frameSelector: "#selected-frame",
|
||||
documentSetup: documentSetup
|
||||
)
|
||||
#expect(envelope[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeError)
|
||||
#expect(envelope[service.evalEnvelope.errorCodeKey] as? String == "frame_unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("result budget rejects before reading a property beyond the node cap")
|
||||
func nodeBudgetStopsBeforeOverflowGetter() throws {
|
||||
let envelope = try evaluate(
|
||||
"""
|
||||
(() => {
|
||||
const value = {};
|
||||
for (let index = 0; index < 9999; index += 1) {
|
||||
value[`p${index}`] = index;
|
||||
}
|
||||
Object.defineProperty(value, 'overflow', {
|
||||
enumerable: true,
|
||||
get() { throw new Error('overflow getter must not run'); }
|
||||
});
|
||||
return value;
|
||||
})()
|
||||
"""
|
||||
)
|
||||
|
||||
#expect(envelope[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeError)
|
||||
#expect(envelope[service.evalEnvelope.errorCodeKey] as? String == "result_too_complex")
|
||||
}
|
||||
|
||||
@Test("aggregate string and key payloads are bounded")
|
||||
func oversizedTextProducesExplicitError() throws {
|
||||
let oversizedString = try evaluate("'x'.repeat(1000001)")
|
||||
#expect(oversizedString[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeError)
|
||||
#expect(oversizedString[service.evalEnvelope.errorCodeKey] as? String == "result_too_complex")
|
||||
|
||||
let oversizedKey = try evaluate("({['k'.repeat(1000001)]: 1})")
|
||||
#expect(oversizedKey[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeError)
|
||||
#expect(oversizedKey[service.evalEnvelope.errorCodeKey] as? String == "result_too_complex")
|
||||
|
||||
let aggregateStrings = try evaluate(
|
||||
"({first: 'a'.repeat(400000), second: 'b'.repeat(400000), third: 'c'.repeat(400000)})"
|
||||
)
|
||||
#expect(aggregateStrings[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeError)
|
||||
#expect(aggregateStrings[service.evalEnvelope.errorCodeKey] as? String == "result_too_complex")
|
||||
}
|
||||
|
||||
@Test("DOMRect-branded values are flattened before bridging")
|
||||
func domRectIsFlattened() throws {
|
||||
let envelope = try evaluate(
|
||||
"""
|
||||
(() => {
|
||||
const rect = {};
|
||||
const fields = {x: 1, y: 2, width: 30, height: 40, top: 2, right: 31, bottom: 42, left: 1};
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
Object.defineProperty(rect, key, {value, enumerable: false});
|
||||
}
|
||||
Object.defineProperty(rect, Symbol.toStringTag, {value: 'DOMRect'});
|
||||
return rect;
|
||||
})()
|
||||
"""
|
||||
)
|
||||
|
||||
let value = try #require(envelope[service.evalEnvelope.valueKey] as? [String: Any])
|
||||
#expect(value["width"] as? Int == 30)
|
||||
#expect(value["height"] as? Int == 40)
|
||||
#expect(value["right"] as? Int == 31)
|
||||
#expect(value["bottom"] as? Int == 42)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test("top-level undefined omits the value property before WebKit bridging")
|
||||
func topLevelUndefinedEnvelopeOmitsValueProperty() throws {
|
||||
let (context, envelope) = try evaluateRaw("undefined")
|
||||
context.setObject(envelope, forKeyedSubscript: "__cmuxTestResult" as NSString)
|
||||
context.setObject(
|
||||
service.evalEnvelope.valueKey,
|
||||
forKeyedSubscript: "__cmuxTestValueKey" as NSString
|
||||
)
|
||||
|
||||
let hasValueProperty = context.evaluateScript(
|
||||
"Object.prototype.hasOwnProperty.call(__cmuxTestResult, __cmuxTestValueKey)"
|
||||
)
|
||||
#expect(hasValueProperty?.toBool() == false)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test("top-level undefined crosses the real WebKit result bridge without a value")
|
||||
func topLevelUndefinedIsBridgeSafe() async throws {
|
||||
let webView = WKWebView()
|
||||
let (loaded, loadedContinuation) = AsyncStream<Void>.makeStream()
|
||||
let navigationDelegate = BrowserEvaluationScriptNavigationDelegate {
|
||||
loadedContinuation.yield()
|
||||
loadedContinuation.finish()
|
||||
}
|
||||
webView.navigationDelegate = navigationDelegate
|
||||
webView.loadHTMLString("<!doctype html><title>bridge test</title>", baseURL: nil)
|
||||
var loadedIterator = loaded.makeAsyncIterator()
|
||||
_ = await loadedIterator.next()
|
||||
|
||||
let body = service.evaluationScript(
|
||||
script: "undefined",
|
||||
useEval: true,
|
||||
frameSelector: nil
|
||||
)
|
||||
let rawValue = try await webView.callAsyncJavaScript(
|
||||
body,
|
||||
arguments: [:],
|
||||
in: nil,
|
||||
contentWorld: .page
|
||||
)
|
||||
let envelope = try #require(rawValue as? [String: Any])
|
||||
|
||||
#expect(envelope[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeUndefined)
|
||||
#expect(envelope[service.evalEnvelope.valueKey] == nil)
|
||||
switch service.resolveEvaluationEnvelope(envelope) {
|
||||
case .undefined:
|
||||
break
|
||||
default:
|
||||
Issue.record("Expected a top-level undefined browser-eval resolution")
|
||||
}
|
||||
_ = navigationDelegate
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test("nested undefined values cross the real WebKit result bridge as null")
|
||||
func nestedUndefinedValuesAreBridgeSafe() async throws {
|
||||
let webView = WKWebView()
|
||||
let (loaded, loadedContinuation) = AsyncStream<Void>.makeStream()
|
||||
let navigationDelegate = BrowserEvaluationScriptNavigationDelegate {
|
||||
loadedContinuation.yield()
|
||||
loadedContinuation.finish()
|
||||
}
|
||||
webView.navigationDelegate = navigationDelegate
|
||||
webView.loadHTMLString("<!doctype html><title>bridge test</title>", baseURL: nil)
|
||||
var loadedIterator = loaded.makeAsyncIterator()
|
||||
_ = await loadedIterator.next()
|
||||
|
||||
let body = service.evaluationScript(
|
||||
script: "({objectValue: undefined, items: [1, undefined, , 4]})",
|
||||
useEval: true,
|
||||
frameSelector: nil
|
||||
)
|
||||
let rawValue = try await webView.callAsyncJavaScript(
|
||||
body,
|
||||
arguments: [:],
|
||||
in: nil,
|
||||
contentWorld: .page
|
||||
)
|
||||
let envelope = try #require(rawValue as? [String: Any])
|
||||
let value = try #require(envelope[service.evalEnvelope.valueKey] as? [String: Any])
|
||||
let items = try #require(value["items"] as? [Any])
|
||||
|
||||
#expect(envelope[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeValue)
|
||||
#expect(value["objectValue"] is NSNull)
|
||||
#expect(items.count == 4)
|
||||
#expect(items[1] is NSNull)
|
||||
#expect(items[2] is NSNull)
|
||||
_ = navigationDelegate
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test("a selected frame owns the complete JavaScript global realm")
|
||||
func selectedFrameOwnsCompleteGlobalRealm() async throws {
|
||||
let webView = WKWebView()
|
||||
let (loaded, loadedContinuation) = AsyncStream<Void>.makeStream()
|
||||
let navigationDelegate = BrowserEvaluationScriptNavigationDelegate {
|
||||
loadedContinuation.yield()
|
||||
loadedContinuation.finish()
|
||||
}
|
||||
webView.navigationDelegate = navigationDelegate
|
||||
webView.loadHTMLString("<!doctype html><title>top-title</title>", baseURL: nil)
|
||||
var loadedIterator = loaded.makeAsyncIterator()
|
||||
_ = await loadedIterator.next()
|
||||
|
||||
_ = try await webView.callAsyncJavaScript(
|
||||
"""
|
||||
window.__cmuxRealmMarker = 'top';
|
||||
window.__cmuxFrameOnly = 'top-global';
|
||||
const frame = document.createElement('iframe');
|
||||
frame.id = 'selected-frame';
|
||||
document.body.appendChild(frame);
|
||||
frame.contentDocument.title = 'frame-title';
|
||||
frame.contentWindow.__cmuxRealmMarker = 'frame';
|
||||
frame.contentWindow.__cmuxFrameOnly = 'frame-global';
|
||||
frame.contentWindow.location.hash = 'frame';
|
||||
return true;
|
||||
""",
|
||||
arguments: [:],
|
||||
in: nil,
|
||||
contentWorld: .page
|
||||
)
|
||||
|
||||
let body = service.evaluationScript(
|
||||
script: "({marker: window.__cmuxRealmMarker, frameOnly: __cmuxFrameOnly, href: location.href, title: document.title})",
|
||||
useEval: true,
|
||||
frameSelector: "#selected-frame"
|
||||
)
|
||||
let rawValue = try await webView.callAsyncJavaScript(
|
||||
body,
|
||||
arguments: [:],
|
||||
in: nil,
|
||||
contentWorld: .page
|
||||
)
|
||||
let envelope = try #require(rawValue as? [String: Any])
|
||||
let value = try #require(envelope[service.evalEnvelope.valueKey] as? [String: Any])
|
||||
|
||||
#expect(value["marker"] as? String == "frame")
|
||||
#expect(value["frameOnly"] as? String == "frame-global")
|
||||
#expect((value["href"] as? String)?.hasSuffix("#frame") == true)
|
||||
#expect(value["title"] as? String == "frame-title")
|
||||
_ = navigationDelegate
|
||||
}
|
||||
|
||||
@Test("custom prototypes are copied without invoking serialization hooks")
|
||||
func customPrototypeIsCopiedAsPlainObject() throws {
|
||||
let envelope = try evaluate(
|
||||
"""
|
||||
(() => {
|
||||
function Payload() {
|
||||
this.answer = 42;
|
||||
}
|
||||
Payload.prototype.toJSON = function() { return 'prototype-hook'; };
|
||||
return new Payload();
|
||||
})()
|
||||
"""
|
||||
)
|
||||
|
||||
let value = try #require(envelope[service.evalEnvelope.valueKey] as? [String: Any])
|
||||
#expect(value["answer"] as? Int == 42)
|
||||
}
|
||||
|
||||
@Test("Date and BigInt values have stable JSON-safe representations")
|
||||
func nonJSONBuiltinsAreNormalized() throws {
|
||||
let envelope = try evaluate(
|
||||
"""
|
||||
({date: new Date('2026-01-02T03:04:05Z'), integer: 9007199254740993n})
|
||||
"""
|
||||
)
|
||||
|
||||
let value = try #require(envelope[service.evalEnvelope.valueKey] as? [String: Any])
|
||||
#expect((value["date"] as? [String: Any])?.isEmpty == true)
|
||||
#expect(value["integer"] as? String == "9007199254740993")
|
||||
}
|
||||
|
||||
@Test("cycles on exotic prototypes produce the stable error envelope")
|
||||
func exoticPrototypeCycleProducesExplicitError() throws {
|
||||
let envelope = try evaluate(
|
||||
"""
|
||||
(() => {
|
||||
const value = new Map();
|
||||
Object.defineProperty(value, 'self', {value, enumerable: true});
|
||||
return value;
|
||||
})()
|
||||
"""
|
||||
)
|
||||
|
||||
#expect(envelope[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeError)
|
||||
#expect(
|
||||
envelope[service.evalEnvelope.errorCodeKey] as? String
|
||||
== service.evalEnvelope.circularReferenceCode
|
||||
)
|
||||
}
|
||||
|
||||
@Test("an own proto property remains ordinary serialized data")
|
||||
func ownProtoPropertyIsPreserved() throws {
|
||||
let envelope = try evaluate(
|
||||
"""
|
||||
(() => {
|
||||
const value = Object.create(null);
|
||||
Object.defineProperty(value, '__proto__', {
|
||||
value: 'ordinary-value',
|
||||
enumerable: true
|
||||
});
|
||||
return value;
|
||||
})()
|
||||
"""
|
||||
)
|
||||
|
||||
let value = try #require(envelope[service.evalEnvelope.valueKey] as? [String: Any])
|
||||
#expect(value["__proto__"] as? String == "ordinary-value")
|
||||
}
|
||||
|
||||
private func evaluate(
|
||||
_ script: String,
|
||||
frameSelector: String? = nil,
|
||||
documentSetup: String = "var document = {};"
|
||||
) throws -> [String: Any] {
|
||||
let (context, result) = try evaluateRaw(
|
||||
script,
|
||||
frameSelector: frameSelector,
|
||||
documentSetup: documentSetup
|
||||
)
|
||||
context.setObject(result, forKeyedSubscript: "__cmuxTestResult" as NSString)
|
||||
let json = try #require(context.evaluateScript("JSON.stringify(__cmuxTestResult)")?.toString())
|
||||
let data = try #require(json.data(using: .utf8))
|
||||
return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
}
|
||||
|
||||
private func evaluateRaw(
|
||||
_ script: String,
|
||||
frameSelector: String? = nil,
|
||||
documentSetup: String = "var document = {};"
|
||||
) throws -> (JSContext, JSValue) {
|
||||
let context = try #require(JSContext())
|
||||
var exceptionMessage: String?
|
||||
context.exceptionHandler = { _, exception in
|
||||
exceptionMessage = exception?.toString()
|
||||
}
|
||||
context.evaluateScript(documentSetup)
|
||||
|
||||
let body = service.evaluationScript(
|
||||
script: script,
|
||||
useEval: true,
|
||||
frameSelector: frameSelector
|
||||
)
|
||||
let promise = try #require(context.evaluateScript("(async () => {\n\(body)\n})()"))
|
||||
var resolved: JSValue?
|
||||
var rejectionMessage: String?
|
||||
let fulfilled: @convention(block) (JSValue) -> Void = { value in
|
||||
resolved = value
|
||||
}
|
||||
let rejected: @convention(block) (JSValue) -> Void = { value in
|
||||
rejectionMessage = value.toString()
|
||||
}
|
||||
promise.invokeMethod("then", withArguments: [fulfilled, rejected])
|
||||
|
||||
#expect(exceptionMessage == nil)
|
||||
#expect(rejectionMessage == nil)
|
||||
let result = try #require(resolved)
|
||||
return (context, result)
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
@MainActor
|
||||
final class BrowserEvaluationScriptNavigationDelegate: NSObject, WKNavigationDelegate {
|
||||
private let onFinish: () -> Void
|
||||
|
||||
init(onFinish: @escaping () -> Void) {
|
||||
self.onFinish = onFinish
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation?) {
|
||||
onFinish()
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,14 @@ let package = Package(
|
||||
),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../CmuxFoundation"),
|
||||
.package(path: "../CmuxSettings"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "CmuxControlSocket",
|
||||
dependencies: [
|
||||
.product(name: "CmuxFoundation", package: "CmuxFoundation"),
|
||||
.product(name: "CmuxSettings", package: "CmuxSettings"),
|
||||
],
|
||||
swiftSettings: [
|
||||
|
||||
+5
-8
@@ -39,14 +39,11 @@ public protocol ControlCommandContext:
|
||||
/// of the coordinator's worker-lane resolution bodies (`surface.list`,
|
||||
/// `system.tree`, `surface.send_text`, …).
|
||||
///
|
||||
/// The conformer MUST refresh its known `kind:N` refs before running the
|
||||
/// closure (the app forwards to `v2MainSync { v2RefreshKnownRefs(); … }`),
|
||||
/// mirroring the main-lane dispatch preamble byte-for-byte so
|
||||
/// caller-supplied refs resolve through the registry. The refresh covers
|
||||
/// only main-window workspace topology — dock-hosted surfaces are
|
||||
/// first-minted by each body's in-hop mint pass, so mint passes MUST
|
||||
/// preserve their payload's literal mint order; that ordering (not the
|
||||
/// refresh) is what keeps ordinals identical across lanes.
|
||||
/// Handle refs are minted when a response first exposes an object. A
|
||||
/// caller-supplied `kind:N` ref therefore already exists in the registry;
|
||||
/// the conformer must not walk unrelated app topology before this closure.
|
||||
/// Each body's in-hop mint pass preserves its payload's literal order so
|
||||
/// main-actor and worker-lane responses assign identical ordinals.
|
||||
/// Like `controlSidebarOnMain`, the hop collapses to an inline call
|
||||
/// when the caller is already on the main thread (mainThreadCallable
|
||||
/// in-process dispatch), and the closure receives the seam back as its
|
||||
|
||||
+3
-3
@@ -103,9 +103,9 @@ public final class ControlCommandCoordinator {
|
||||
///
|
||||
/// Each body is `nonisolated`: pure parse and the JSON payload build/
|
||||
/// encode run on the calling thread, and every main-actor touch —
|
||||
/// known-ref refresh, routing resolution through the handle registry,
|
||||
/// the context snapshot witness, and ref minting in payload order — is
|
||||
/// one `controlResolveOnMain` hop. The same bodies serve the main-actor
|
||||
/// routing resolution through the handle registry, the context snapshot
|
||||
/// witness, and ref minting in payload order — is one
|
||||
/// `controlResolveOnMain` hop. The same bodies serve the main-actor
|
||||
/// `handle(_:)` dispatch, where the hop collapses inline, so both lanes
|
||||
/// run identical code.
|
||||
///
|
||||
|
||||
+2
-2
@@ -277,8 +277,8 @@ public protocol ControlSidebarContext: AnyObject {
|
||||
/// Focuses a surface by panel UUID (`focus_surface_by_panel`).
|
||||
func controlSidebarFocusSurfaceByPanel(panelID: UUID) -> Bool
|
||||
|
||||
/// Refreshes the `kind:N` handle registry from live app state (the legacy
|
||||
/// `v2RefreshKnownRefs` pre-pass of `drag_surface_to_split`).
|
||||
/// Refreshes the `kind:N` handle registry from live app state for the
|
||||
/// legacy `drag_surface_to_split` command.
|
||||
func controlSidebarRefreshKnownRefs()
|
||||
|
||||
/// Forwards a stable-ref `drag_surface_to_split` to the shared app-side
|
||||
|
||||
+1
-2
@@ -106,8 +106,7 @@ extension ControlCommandCoordinator {
|
||||
// context (the socket worker lane always passes its live seam),
|
||||
// and the legacy body ran this flow inline on main — params still
|
||||
// parse, the routing still validates, and the walk resolves an
|
||||
// empty world. No known-ref refresh, exactly as before (the
|
||||
// main lane's refresh lives app-side, ahead of the dispatch).
|
||||
// empty world, matching the main lane.
|
||||
outcome = MainActor.assumeIsolated {
|
||||
self.systemTreeHopBody(params, seam: nil)
|
||||
}
|
||||
|
||||
+94
-1
@@ -24,6 +24,12 @@ public struct SocketListenerPolicy: Sendable {
|
||||
public let acceptFailureMinimumRearmDelayMs: Int
|
||||
/// Consecutive-failure count at which the listener rearms instead of resuming.
|
||||
public let acceptFailureRearmThreshold: Int
|
||||
/// First listener-start retry delay in milliseconds.
|
||||
public let startupFailureBaseBackoffMs: Int
|
||||
/// Upper bound for listener-start retry delays in milliseconds.
|
||||
public let startupFailureMaxBackoffMs: Int
|
||||
/// Maximum transient listener-start failures retried before escalation.
|
||||
public let startupFailureRetryLimit: Int
|
||||
|
||||
/// Creates a policy.
|
||||
///
|
||||
@@ -32,16 +38,32 @@ public struct SocketListenerPolicy: Sendable {
|
||||
/// - acceptFailureMaxBackoffMs: Backoff cap (default 5s).
|
||||
/// - acceptFailureMinimumRearmDelayMs: Rearm-delay floor (default 100ms).
|
||||
/// - acceptFailureRearmThreshold: Failure streak that forces a rearm (default 50).
|
||||
/// - startupFailureBaseBackoffMs: First startup retry delay (default 100ms);
|
||||
/// negative values normalize to zero.
|
||||
/// - startupFailureMaxBackoffMs: Startup retry-delay cap (default 2s);
|
||||
/// values below the normalized base are raised to the base.
|
||||
/// - startupFailureRetryLimit: Transient failures retried before reporting
|
||||
/// (default 6); negative values normalize to zero.
|
||||
public init(
|
||||
acceptFailureBaseBackoffMs: Int = 10,
|
||||
acceptFailureMaxBackoffMs: Int = 5_000,
|
||||
acceptFailureMinimumRearmDelayMs: Int = 100,
|
||||
acceptFailureRearmThreshold: Int = 50
|
||||
acceptFailureRearmThreshold: Int = 50,
|
||||
startupFailureBaseBackoffMs: Int = 100,
|
||||
startupFailureMaxBackoffMs: Int = 2_000,
|
||||
startupFailureRetryLimit: Int = 6
|
||||
) {
|
||||
self.acceptFailureBaseBackoffMs = acceptFailureBaseBackoffMs
|
||||
self.acceptFailureMaxBackoffMs = acceptFailureMaxBackoffMs
|
||||
self.acceptFailureMinimumRearmDelayMs = acceptFailureMinimumRearmDelayMs
|
||||
self.acceptFailureRearmThreshold = acceptFailureRearmThreshold
|
||||
let normalizedStartupBaseBackoffMs = max(startupFailureBaseBackoffMs, 0)
|
||||
self.startupFailureBaseBackoffMs = normalizedStartupBaseBackoffMs
|
||||
self.startupFailureMaxBackoffMs = max(
|
||||
startupFailureMaxBackoffMs,
|
||||
normalizedStartupBaseBackoffMs
|
||||
)
|
||||
self.startupFailureRetryLimit = max(startupFailureRetryLimit, 0)
|
||||
}
|
||||
|
||||
/// Classifies an `accept(2)` `errno` into a recovery class.
|
||||
@@ -160,6 +182,74 @@ public struct SocketListenerPolicy: Sendable {
|
||||
return (consecutiveFailures & (consecutiveFailures - 1)) == 0
|
||||
}
|
||||
|
||||
/// Whether a listener-start failure should retry before it is reported.
|
||||
///
|
||||
/// Retries are limited to transport occupancy, interrupted setup, temporary
|
||||
/// filesystem I/O failure (including post-bind identity capture), and
|
||||
/// resource pressure. Permission, path-shape, and inconclusive bound-path
|
||||
/// ownership proofs remain immediately actionable.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - stage: Stable listener-start stage identifier.
|
||||
/// - errnoCode: The failing `errno`.
|
||||
/// - consecutiveFailures: Failure count including the current attempt.
|
||||
/// - Returns: `true` when another bounded retry should be scheduled.
|
||||
public func shouldRetryStartupFailure(
|
||||
stage: String,
|
||||
errnoCode: Int32,
|
||||
consecutiveFailures: Int
|
||||
) -> Bool {
|
||||
guard consecutiveFailures > 0,
|
||||
consecutiveFailures <= startupFailureRetryLimit else {
|
||||
return false
|
||||
}
|
||||
if stage == "verify_bound_path_pending" {
|
||||
return isPendingSocketConnectErrno(errnoCode)
|
||||
}
|
||||
if stage == "verify_bound_path_drain" {
|
||||
return errnoCode == EAGAIN || errnoCode == EINTR
|
||||
}
|
||||
// Once the retained descriptor is listening for an ownership proof,
|
||||
// any inconclusive result must close it instead of leaving an
|
||||
// unverified listener reachable during another retry delay.
|
||||
guard stage != "verify_bound_path" else { return false }
|
||||
|
||||
switch errnoCode {
|
||||
case EADDRINUSE:
|
||||
return stage == "bind"
|
||||
case EAGAIN:
|
||||
return stage == "lock" || stage == "open_lock"
|
||||
case EINTR, EIO, EMFILE, ENFILE, ENOBUFS, ENOMEM:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Exponential listener-start retry delay, capped by
|
||||
/// ``startupFailureMaxBackoffMs``.
|
||||
///
|
||||
/// - Parameter consecutiveFailures: Failure count including the current attempt.
|
||||
/// - Returns: Retry delay in milliseconds, or zero for no failures.
|
||||
public func startupFailureRetryDelayMilliseconds(consecutiveFailures: Int) -> Int {
|
||||
guard consecutiveFailures > 0 else { return 0 }
|
||||
guard startupFailureBaseBackoffMs > 0 else { return 0 }
|
||||
var delay = startupFailureBaseBackoffMs
|
||||
var remaining = consecutiveFailures - 1
|
||||
while remaining > 0 {
|
||||
if delay >= startupFailureMaxBackoffMs {
|
||||
return startupFailureMaxBackoffMs
|
||||
}
|
||||
let (doubled, overflow) = delay.multipliedReportingOverflow(by: 2)
|
||||
if overflow || doubled >= startupFailureMaxBackoffMs {
|
||||
return startupFailureMaxBackoffMs
|
||||
}
|
||||
delay = doubled
|
||||
remaining -= 1
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
/// Whether accept-loop cleanup may unlink the socket path: only when the
|
||||
/// path still belongs to this listener, nothing is running or starting, and
|
||||
/// no newer accept-loop generation exists.
|
||||
@@ -214,6 +304,9 @@ public struct SocketListenerPolicy: Sendable {
|
||||
errnoCode: Int32,
|
||||
currentUserID: uid_t = getuid()
|
||||
) -> String? {
|
||||
// Only the stable path detours. Once an attempt is on the user-scoped
|
||||
// fallback, transient occupancy is retried in place by
|
||||
// `shouldRetryStartupFailure` instead of selecting another path.
|
||||
guard requestedPath == SocketControlSettings.stableDefaultSocketPath else {
|
||||
return nil
|
||||
}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/// Ownership proof for a pathname created by `bind(2)`.
|
||||
enum BoundSocketPathOwnership: Equatable, Sendable {
|
||||
case none
|
||||
/// `bind(2)` succeeded while the immediate `lstat(2)` failed. The server
|
||||
/// retains both the bound descriptor and path lock until a later identity
|
||||
/// capture or safe teardown.
|
||||
case identityPending
|
||||
case identified(SocketPathIdentity)
|
||||
|
||||
var identity: SocketPathIdentity? {
|
||||
guard case .identified(let identity) = self else { return nil }
|
||||
return identity
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
internal import CmuxSettings
|
||||
|
||||
/// Immutable request retained across a bounded listener-start retry.
|
||||
struct ListenerStartRequest: Equatable, Sendable {
|
||||
let socketPath: String
|
||||
let accessMode: SocketControlMode
|
||||
let preserveAcceptFailureStreak: Bool
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/// The authoritative listener-start lifecycle.
|
||||
///
|
||||
/// Generation checks make delayed wakes harmless after stop or an explicit
|
||||
/// restart. The waiting case owns the request and failure count, so the timer
|
||||
/// task carries no lifecycle state and can only request a wakeup.
|
||||
enum ListenerStartupState: Equatable, Sendable {
|
||||
/// No startup operation is active. A listener may already be running.
|
||||
case idle(generation: UInt64)
|
||||
/// One synchronous startup attempt owns the request on the main actor.
|
||||
case starting(generation: UInt64, request: ListenerStartRequest, failureCount: Int)
|
||||
/// A bounded delay is pending before the same request may retry.
|
||||
case waiting(generation: UInt64, request: ListenerStartRequest, failureCount: Int)
|
||||
|
||||
var generation: UInt64 {
|
||||
switch self {
|
||||
case .idle(let generation),
|
||||
.starting(let generation, _, _),
|
||||
.waiting(let generation, _, _):
|
||||
return generation
|
||||
}
|
||||
}
|
||||
|
||||
var isStarting: Bool {
|
||||
if case .starting = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var isWaiting: Bool {
|
||||
if case .waiting = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
internal import Dispatch
|
||||
internal import CmuxSettings
|
||||
|
||||
/// Main-actor transport resources and listener state.
|
||||
struct ListenerState {
|
||||
var socketPath: String
|
||||
var boundSocketPathOwnership: BoundSocketPathOwnership = .none
|
||||
var serverSocket: Int32 = -1
|
||||
var isRunning = false
|
||||
var acceptLoopAlive = false
|
||||
var activeAcceptLoopGeneration: UInt64 = 0
|
||||
var nextAcceptLoopGeneration: UInt64 = 0
|
||||
var pendingAcceptLoopRearmGeneration: UInt64?
|
||||
var reservedStartupSocketPath: String?
|
||||
var reservedStartupSocketPathCanReplaceRefusedSocket = false
|
||||
var listenerState: ListenerStartupState = .idle(generation: 0)
|
||||
var socketPathLockFD: Int32 = -1
|
||||
var listenerReadSource: (any DispatchSourceRead)?
|
||||
var listenerReadSourceSuspended = false
|
||||
var socketPathMonitorSource: (any DispatchSourceFileSystemObject)?
|
||||
var accessMode: SocketControlMode = .cmuxOnly
|
||||
var configuredPreferredSocketPath: String?
|
||||
}
|
||||
+11
-7
@@ -1,7 +1,14 @@
|
||||
internal import CmuxFoundation
|
||||
|
||||
/// Bounds concurrent socket clients waiting to prove authorization.
|
||||
public actor SocketClientPreauthorizationLimiter {
|
||||
///
|
||||
/// Admission is synchronous because it runs on the listener's Dispatch queue
|
||||
/// before a dedicated client thread is started. An atomic counter keeps this
|
||||
/// ingress path independent of Swift's cooperative executor, including when
|
||||
/// background filesystem or process scans occupy every cooperative worker.
|
||||
public final class SocketClientPreauthorizationLimiter: Sendable {
|
||||
private let maximumConcurrentClaims: Int
|
||||
private var activeClaims = 0
|
||||
private let activeClaims = AtomicUInt64Value()
|
||||
|
||||
/// Creates a limiter with a fixed concurrent claim budget.
|
||||
///
|
||||
@@ -14,14 +21,11 @@ public actor SocketClientPreauthorizationLimiter {
|
||||
///
|
||||
/// - Returns: `true` when a slot was reserved; otherwise `false`.
|
||||
public func claim() -> Bool {
|
||||
guard activeClaims < maximumConcurrentClaims else { return false }
|
||||
activeClaims += 1
|
||||
return true
|
||||
activeClaims.incrementIfBelow(UInt64(maximumConcurrentClaims))
|
||||
}
|
||||
|
||||
/// Releases one previously claimed reader slot.
|
||||
public func release() {
|
||||
guard activeClaims > 0 else { return }
|
||||
activeClaims -= 1
|
||||
_ = activeClaims.decrementIfPositive()
|
||||
}
|
||||
}
|
||||
|
||||
+30
-17
@@ -118,27 +118,40 @@ extension SocketControlServer {
|
||||
return
|
||||
}
|
||||
let authorization = acceptedConnectionAuthorization()
|
||||
let yielded = connectionsContinuation.yield(
|
||||
ControlConnection(
|
||||
socket: clientSocket,
|
||||
peerProcessID: peerPid,
|
||||
authorizationGeneration: authorization.generation,
|
||||
authorizationRevocationSignal: authorization.revocationSignal
|
||||
)
|
||||
let connection = ControlConnection(
|
||||
socket: clientSocket,
|
||||
peerProcessID: peerPid,
|
||||
authorizationGeneration: authorization.generation,
|
||||
authorizationRevocationSignal: authorization.revocationSignal
|
||||
)
|
||||
switch yielded {
|
||||
case .enqueued:
|
||||
break
|
||||
case let .dropped(connection):
|
||||
close(connection.socket)
|
||||
case .terminated:
|
||||
close(clientSocket)
|
||||
@unknown default:
|
||||
close(clientSocket)
|
||||
}
|
||||
deliverAcceptedConnection(connection)
|
||||
}
|
||||
}
|
||||
|
||||
/// Transfers an accepted descriptor through the configured ingress path.
|
||||
/// Tests use this same entry point for synthetic socket pairs, so switching
|
||||
/// between direct delivery and the AsyncStream fallback cannot strand them
|
||||
/// on an unconsumed transport.
|
||||
@discardableResult
|
||||
nonisolated func deliverAcceptedConnection(_ connection: ControlConnection) -> Bool {
|
||||
if let acceptedConnectionHandler {
|
||||
acceptedConnectionHandler(connection)
|
||||
return true
|
||||
}
|
||||
let yielded = connectionsContinuation.yield(connection)
|
||||
switch yielded {
|
||||
case .enqueued:
|
||||
return true
|
||||
case let .dropped(connection):
|
||||
close(connection.socket)
|
||||
case .terminated:
|
||||
close(connection.socket)
|
||||
@unknown default:
|
||||
close(connection.socket)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private nonisolated func shouldContinueAcceptLoop(listenerSocket: Int32, generation: UInt64) -> Bool {
|
||||
let snapshot = listenerStateSnapshot()
|
||||
return snapshot.isRunning
|
||||
|
||||
+7
-1
@@ -42,8 +42,14 @@ extension SocketControlServer {
|
||||
|
||||
if accessMode == .off {
|
||||
stop()
|
||||
} else if isRunning, !applySocketPermissions() {
|
||||
} else if isRunning, let errnoCode = applySocketPermissions() {
|
||||
let recoveryPath = currentSocketPath
|
||||
stop()
|
||||
schedulePermissionRecovery(
|
||||
socketPath: recoveryPath,
|
||||
accessMode: accessMode,
|
||||
errnoCode: errnoCode
|
||||
)
|
||||
events.breadcrumb(
|
||||
"socket.listener.configuration.failed_closed",
|
||||
socketListenerEventData(
|
||||
|
||||
+468
-155
@@ -1,6 +1,12 @@
|
||||
public import CmuxSettings
|
||||
internal import Darwin
|
||||
internal import Foundation
|
||||
internal import os
|
||||
|
||||
nonisolated private let socketControlServerLogger = Logger(
|
||||
subsystem: "com.cmux.socket",
|
||||
category: "Listener"
|
||||
)
|
||||
|
||||
extension SocketControlServer {
|
||||
/// Reserves `path` (or its policy fallback) before the listener starts, so
|
||||
@@ -14,7 +20,7 @@ extension SocketControlServer {
|
||||
/// unchanged when no reservation was possible.
|
||||
@discardableResult
|
||||
public func reserveStartupSocketPath(_ path: String) -> String {
|
||||
guard withListenerState({ Self.canReserveStartupSocketPath(state: $0) }) else {
|
||||
guard withListenerState({ canReserveStartupSocketPath(state: $0) }) else {
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -46,7 +52,7 @@ extension SocketControlServer {
|
||||
|
||||
var didReserve = false
|
||||
withListenerState { state in
|
||||
guard Self.canReserveStartupSocketPath(state: state) else {
|
||||
guard canReserveStartupSocketPath(state: state) else {
|
||||
return
|
||||
}
|
||||
state.socketPath = reservationPath
|
||||
@@ -62,17 +68,6 @@ extension SocketControlServer {
|
||||
return path
|
||||
}
|
||||
|
||||
private static func canReserveStartupSocketPath(state: ListenerState) -> Bool {
|
||||
!state.isRunning &&
|
||||
!state.acceptLoopAlive &&
|
||||
!state.listenerStartInProgress &&
|
||||
state.pendingAcceptLoopRearmGeneration == nil &&
|
||||
state.socketPathLockFD < 0 &&
|
||||
state.listenerReadSource == nil &&
|
||||
state.socketPathMonitorSource == nil &&
|
||||
state.serverSocket < 0
|
||||
}
|
||||
|
||||
/// Starts (or restarts) the listener on `socketPath`.
|
||||
///
|
||||
/// Faithful lift of the legacy `TerminalController.start`: idempotent when
|
||||
@@ -81,21 +76,39 @@ extension SocketControlServer {
|
||||
/// retained inactive listener state, binds with stale/refused replacement
|
||||
/// rules and a one-shot policy fallback path, then commits the running
|
||||
/// state under a fresh accept-loop generation and arms the path monitor
|
||||
/// and accept source. Failures are reported through the events seam.
|
||||
/// and accept source. Transient startup failures schedule bounded recovery;
|
||||
/// permanent or exhausted failures are reported through the events seam.
|
||||
/// - Parameters:
|
||||
/// - socketPath: The path to bind.
|
||||
/// - accessMode: Socket access mode; drives file permissions, client
|
||||
/// ancestry checks, and password auth.
|
||||
/// - preserveAcceptFailureStreak: Keeps the consecutive accept-failure
|
||||
/// counter across a rearm restart so backoff continues to escalate.
|
||||
/// - Returns: `true` when the listener activated.
|
||||
/// - Returns: `true` when the listener activated synchronously. `false`
|
||||
/// may mean bounded transient-failure recovery is pending.
|
||||
@discardableResult
|
||||
public func start(
|
||||
socketPath: String,
|
||||
accessMode: SocketControlMode,
|
||||
preserveAcceptFailureStreak: Bool = false
|
||||
) -> Bool {
|
||||
configureConnectionAuthorization(accessMode: accessMode)
|
||||
let request = ListenerStartRequest(
|
||||
socketPath: socketPath,
|
||||
accessMode: accessMode,
|
||||
preserveAcceptFailureStreak: preserveAcceptFailureStreak
|
||||
)
|
||||
startupWakeTask?.cancel()
|
||||
startupWakeTask = nil
|
||||
|
||||
if accessMode == .off {
|
||||
withListenerState { state in
|
||||
state.accessMode = .off
|
||||
}
|
||||
configureConnectionAuthorization(accessMode: .off)
|
||||
stop()
|
||||
return false
|
||||
}
|
||||
|
||||
let existing = withListenerState { state in
|
||||
if state.accessMode != accessMode {
|
||||
state.accessMode = accessMode
|
||||
@@ -106,7 +119,7 @@ extension SocketControlServer {
|
||||
reservedStartupSocketPath: state.reservedStartupSocketPath,
|
||||
socketPathLockHeld: state.socketPathLockFD >= 0,
|
||||
hasRetainedInactiveListenerState: !state.isRunning && (
|
||||
state.pendingAcceptLoopRearmGeneration != nil ||
|
||||
state.boundSocketPathOwnership != .none ||
|
||||
state.socketPathLockFD >= 0 ||
|
||||
state.acceptLoopAlive ||
|
||||
state.serverSocket >= 0 ||
|
||||
@@ -116,16 +129,24 @@ extension SocketControlServer {
|
||||
)
|
||||
}
|
||||
|
||||
if accessMode == .off {
|
||||
stop()
|
||||
return false
|
||||
}
|
||||
|
||||
if existing.isRunning && SocketControlSettings.pathsMatch(existing.socketPath, socketPath) {
|
||||
guard applySocketPermissions() else {
|
||||
configureConnectionAuthorization(accessMode: accessMode)
|
||||
if let errnoCode = applySocketPermissions() {
|
||||
stop()
|
||||
let generation = beginStart(request)
|
||||
_ = handleStartupFailure(
|
||||
message: "socket.listener.start.failed",
|
||||
stage: "chmod",
|
||||
errnoCode: errnoCode,
|
||||
request: request,
|
||||
generation: generation
|
||||
)
|
||||
return false
|
||||
}
|
||||
withListenerState { state in
|
||||
let generation = state.listenerState.generation &+ 1
|
||||
state.listenerState = .idle(generation: generation)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -136,12 +157,78 @@ extension SocketControlServer {
|
||||
stop()
|
||||
}
|
||||
|
||||
var activeSocketPath = socketPath
|
||||
let generation = beginStart(request)
|
||||
return startAttempt(generation: generation)
|
||||
}
|
||||
|
||||
private func beginStart(_ request: ListenerStartRequest) -> UInt64 {
|
||||
withListenerState { state in
|
||||
let generation = state.listenerState.generation &+ 1
|
||||
state.listenerState = .starting(
|
||||
generation: generation,
|
||||
request: request,
|
||||
failureCount: 0
|
||||
)
|
||||
return generation
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the startup retry policy after a live listener was stopped
|
||||
/// because a permission update failed closed.
|
||||
func schedulePermissionRecovery(
|
||||
socketPath: String,
|
||||
accessMode: SocketControlMode,
|
||||
errnoCode: Int32
|
||||
) {
|
||||
let request = ListenerStartRequest(
|
||||
socketPath: socketPath,
|
||||
accessMode: accessMode,
|
||||
preserveAcceptFailureStreak: false
|
||||
)
|
||||
let generation = beginStart(request)
|
||||
_ = handleStartupFailure(
|
||||
message: "socket.listener.start.failed",
|
||||
stage: "chmod",
|
||||
errnoCode: errnoCode,
|
||||
request: request,
|
||||
generation: generation
|
||||
)
|
||||
}
|
||||
|
||||
private func startAttempt(generation: UInt64) -> Bool {
|
||||
guard let attempt = withListenerState({ state -> (ListenerStartRequest, Int)? in
|
||||
guard case .starting(let currentGeneration, let request, let failureCount) = state.listenerState,
|
||||
currentGeneration == generation else {
|
||||
return nil
|
||||
}
|
||||
if state.accessMode != request.accessMode {
|
||||
state.accessMode = request.accessMode
|
||||
}
|
||||
return (request, failureCount)
|
||||
}) else { return false }
|
||||
let (request, _) = attempt
|
||||
configureConnectionAuthorization(accessMode: request.accessMode)
|
||||
|
||||
var activeSocketPath = request.socketPath
|
||||
var activeSocketPathLockFD: Int32 = -1
|
||||
var activeSocketPathCanReplaceRefusedSocket = false
|
||||
var activeBoundSocketPathIdentity: SocketPathIdentity?
|
||||
var activeServerSocket: Int32 = -1
|
||||
var activeBoundSocketPathOwnership = BoundSocketPathOwnership.none
|
||||
var resumedIdentityPendingBind = false
|
||||
withListenerState { state in
|
||||
if state.socketPathLockFD >= 0,
|
||||
if state.boundSocketPathOwnership == .identityPending,
|
||||
state.serverSocket >= 0,
|
||||
state.socketPathLockFD >= 0,
|
||||
SocketControlSettings.pathsMatch(state.socketPath, activeSocketPath) {
|
||||
activeSocketPath = state.socketPath
|
||||
activeServerSocket = state.serverSocket
|
||||
activeSocketPathLockFD = state.socketPathLockFD
|
||||
activeBoundSocketPathOwnership = .identityPending
|
||||
state.serverSocket = -1
|
||||
state.socketPathLockFD = -1
|
||||
state.boundSocketPathOwnership = .none
|
||||
resumedIdentityPendingBind = true
|
||||
} else if state.socketPathLockFD >= 0,
|
||||
state.reservedStartupSocketPath.map({ SocketControlSettings.pathsMatch($0, activeSocketPath) }) == true,
|
||||
!state.isRunning,
|
||||
!state.acceptLoopAlive,
|
||||
@@ -151,154 +238,243 @@ extension SocketControlServer {
|
||||
state.socketPathLockFD = -1
|
||||
}
|
||||
state.socketPath = activeSocketPath
|
||||
state.boundSocketPathIdentity = nil
|
||||
if !resumedIdentityPendingBind {
|
||||
state.boundSocketPathOwnership = .none
|
||||
}
|
||||
state.reservedStartupSocketPath = nil
|
||||
state.reservedStartupSocketPathCanReplaceRefusedSocket = false
|
||||
state.listenerStartInProgress = true
|
||||
}
|
||||
var listenerActivated = false
|
||||
defer {
|
||||
if !listenerActivated {
|
||||
if let activeBoundSocketPathIdentity,
|
||||
listenerPolicy.shouldUnlinkSocketPathAfterListenerStop(
|
||||
currentIdentity: transport.pathIdentity(at: activeSocketPath),
|
||||
boundIdentity: activeBoundSocketPathIdentity
|
||||
) {
|
||||
unlink(activeSocketPath)
|
||||
unlinkOwnedSocketPath(
|
||||
activeSocketPath,
|
||||
ownership: activeBoundSocketPathOwnership
|
||||
)
|
||||
if activeServerSocket >= 0 {
|
||||
close(activeServerSocket)
|
||||
activeServerSocket = -1
|
||||
}
|
||||
transport.releaseSocketPathLock(activeSocketPathLockFD)
|
||||
activeSocketPathLockFD = -1
|
||||
withListenerState { state in
|
||||
if state.boundSocketPathIdentity == activeBoundSocketPathIdentity {
|
||||
state.boundSocketPathIdentity = nil
|
||||
if state.serverSocket < 0, state.socketPathLockFD < 0 {
|
||||
state.boundSocketPathOwnership = .none
|
||||
}
|
||||
state.listenerStartInProgress = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create socket
|
||||
let (newServerSocket, createSocketErrno) = transport.makeListenerSocket()
|
||||
guard newServerSocket >= 0 else {
|
||||
let errnoCode = createSocketErrno ?? EIO
|
||||
print("SocketControlServer: Failed to create socket")
|
||||
reportSocketListenerFailure(
|
||||
message: "socket.listener.start.failed",
|
||||
stage: "create_socket",
|
||||
errnoCode: errnoCode
|
||||
)
|
||||
return false
|
||||
}
|
||||
if resumedIdentityPendingBind {
|
||||
switch transport.verifyRetainedBoundPath(
|
||||
at: activeSocketPath,
|
||||
listenerSocket: activeServerSocket
|
||||
) {
|
||||
case .verified(let identity):
|
||||
activeBoundSocketPathOwnership = .identified(identity)
|
||||
case .pending(let failure), .failed(let failure):
|
||||
let disposition = handleStartupFailure(
|
||||
message: "socket.listener.start.failed",
|
||||
stage: failure.stage,
|
||||
errnoCode: failure.errnoCode,
|
||||
request: ListenerStartRequest(
|
||||
socketPath: activeSocketPath,
|
||||
accessMode: request.accessMode,
|
||||
preserveAcceptFailureStreak: request.preserveAcceptFailureStreak
|
||||
),
|
||||
generation: generation,
|
||||
retainedSocket: activeServerSocket,
|
||||
retainedPathLockFD: activeSocketPathLockFD,
|
||||
retainedOwnership: .identityPending
|
||||
)
|
||||
if disposition == .retryScheduled {
|
||||
activeServerSocket = -1
|
||||
activeSocketPathLockFD = -1
|
||||
activeBoundSocketPathOwnership = .none
|
||||
}
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
let (newServerSocket, createSocketErrno) = transport.makeListenerSocket()
|
||||
guard newServerSocket >= 0 else {
|
||||
let errnoCode = createSocketErrno ?? EIO
|
||||
socketControlServerLogger.error("Failed to create listener socket")
|
||||
_ = handleStartupFailure(
|
||||
message: "socket.listener.start.failed",
|
||||
stage: "create_socket",
|
||||
errnoCode: errnoCode,
|
||||
request: request,
|
||||
generation: generation
|
||||
)
|
||||
return false
|
||||
}
|
||||
activeServerSocket = newServerSocket
|
||||
|
||||
func acquireActiveSocketPathLock() -> SocketBindAttemptResult? {
|
||||
if activeSocketPathLockFD >= 0 {
|
||||
return nil
|
||||
func acquireActiveSocketPathLock() -> SocketBindAttemptResult? {
|
||||
if activeSocketPathLockFD >= 0 {
|
||||
return nil
|
||||
}
|
||||
switch transport.acquireSocketPathLock(for: activeSocketPath) {
|
||||
case .acquired(let fd, let canReplaceRefusedSocket):
|
||||
activeSocketPathLockFD = fd
|
||||
activeSocketPathCanReplaceRefusedSocket = canReplaceRefusedSocket
|
||||
return nil
|
||||
case .failed(let failure):
|
||||
return .failure(path: activeSocketPath, failure: failure)
|
||||
}
|
||||
}
|
||||
switch transport.acquireSocketPathLock(for: activeSocketPath) {
|
||||
case .acquired(let fd, let canReplaceRefusedSocket):
|
||||
activeSocketPathLockFD = fd
|
||||
activeSocketPathCanReplaceRefusedSocket = canReplaceRefusedSocket
|
||||
return nil
|
||||
case .failed(let failure):
|
||||
return .failure(path: activeSocketPath, failure: failure)
|
||||
}
|
||||
}
|
||||
|
||||
var bindAttempt = acquireActiveSocketPathLock()
|
||||
?? transport.bindListenerSocket(
|
||||
newServerSocket,
|
||||
path: activeSocketPath,
|
||||
canReplaceRefusedSocket: activeSocketPathCanReplaceRefusedSocket
|
||||
)
|
||||
if case .failure(let failedPath, let bindFailure) = bindAttempt,
|
||||
let fallbackPath = listenerPolicy.fallbackSocketPathAfterBindFailure(
|
||||
requestedPath: failedPath,
|
||||
stage: bindFailure.stage,
|
||||
errnoCode: bindFailure.errnoCode
|
||||
),
|
||||
fallbackPath != failedPath {
|
||||
events.breadcrumb(
|
||||
"socket.listener.path.fallback",
|
||||
[
|
||||
"requestedPath": failedPath,
|
||||
"fallbackPath": fallbackPath,
|
||||
"stage": bindFailure.stage,
|
||||
"errno": Int(bindFailure.errnoCode),
|
||||
]
|
||||
)
|
||||
transport.releaseSocketPathLock(activeSocketPathLockFD)
|
||||
activeSocketPathLockFD = -1
|
||||
activeSocketPathCanReplaceRefusedSocket = false
|
||||
activeSocketPath = fallbackPath
|
||||
withListenerState { state in
|
||||
state.socketPath = activeSocketPath
|
||||
}
|
||||
bindAttempt = acquireActiveSocketPathLock()
|
||||
var bindAttempt = acquireActiveSocketPathLock()
|
||||
?? transport.bindListenerSocket(
|
||||
newServerSocket,
|
||||
activeServerSocket,
|
||||
path: activeSocketPath,
|
||||
canReplaceRefusedSocket: activeSocketPathCanReplaceRefusedSocket
|
||||
)
|
||||
}
|
||||
if case .failure(let failedPath, let bindFailure) = bindAttempt,
|
||||
bindFailure.stage != "stat_bound_path",
|
||||
let fallbackPath = listenerPolicy.fallbackSocketPathAfterBindFailure(
|
||||
requestedPath: failedPath,
|
||||
stage: bindFailure.stage,
|
||||
errnoCode: bindFailure.errnoCode
|
||||
),
|
||||
fallbackPath != failedPath {
|
||||
events.breadcrumb(
|
||||
"socket.listener.path.fallback",
|
||||
[
|
||||
"requestedPath": failedPath,
|
||||
"fallbackPath": fallbackPath,
|
||||
"stage": bindFailure.stage,
|
||||
"errno": Int(bindFailure.errnoCode),
|
||||
]
|
||||
)
|
||||
transport.releaseSocketPathLock(activeSocketPathLockFD)
|
||||
activeSocketPathLockFD = -1
|
||||
activeSocketPathCanReplaceRefusedSocket = false
|
||||
activeSocketPath = fallbackPath
|
||||
withListenerState { state in
|
||||
state.socketPath = activeSocketPath
|
||||
}
|
||||
bindAttempt = acquireActiveSocketPathLock()
|
||||
?? transport.bindListenerSocket(
|
||||
activeServerSocket,
|
||||
path: activeSocketPath,
|
||||
canReplaceRefusedSocket: activeSocketPathCanReplaceRefusedSocket
|
||||
)
|
||||
}
|
||||
|
||||
switch bindAttempt {
|
||||
case .success(let boundPath, let identity):
|
||||
activeSocketPath = boundPath
|
||||
activeBoundSocketPathOwnership = .identified(identity)
|
||||
case .pathTooLong(let failedPath):
|
||||
_ = handleStartupFailure(
|
||||
message: "socket.listener.start.failed",
|
||||
stage: "bind_path_too_long",
|
||||
errnoCode: ENAMETOOLONG,
|
||||
extra: [
|
||||
"path": failedPath,
|
||||
"pathLength": failedPath.utf8.count,
|
||||
"maxPathLength": SocketTransport.unixSocketPathMaxLength,
|
||||
],
|
||||
request: ListenerStartRequest(
|
||||
socketPath: activeSocketPath,
|
||||
accessMode: request.accessMode,
|
||||
preserveAcceptFailureStreak: request.preserveAcceptFailureStreak
|
||||
),
|
||||
generation: generation
|
||||
)
|
||||
return false
|
||||
case .failure(let failedPath, let bindFailure) where bindFailure.stage == "stat_bound_path":
|
||||
activeSocketPath = failedPath
|
||||
activeBoundSocketPathOwnership = .identityPending
|
||||
let disposition = handleStartupFailure(
|
||||
message: "socket.listener.start.failed",
|
||||
stage: bindFailure.stage,
|
||||
errnoCode: bindFailure.errnoCode,
|
||||
extra: ["path": failedPath],
|
||||
request: ListenerStartRequest(
|
||||
socketPath: activeSocketPath,
|
||||
accessMode: request.accessMode,
|
||||
preserveAcceptFailureStreak: request.preserveAcceptFailureStreak
|
||||
),
|
||||
generation: generation,
|
||||
retainedSocket: activeServerSocket,
|
||||
retainedPathLockFD: activeSocketPathLockFD,
|
||||
retainedOwnership: .identityPending
|
||||
)
|
||||
if disposition == .retryScheduled {
|
||||
activeServerSocket = -1
|
||||
activeSocketPathLockFD = -1
|
||||
activeBoundSocketPathOwnership = .none
|
||||
}
|
||||
return false
|
||||
case .failure(let failedPath, let bindFailure):
|
||||
socketControlServerLogger.error("Failed to bind listener socket")
|
||||
_ = handleStartupFailure(
|
||||
message: "socket.listener.start.failed",
|
||||
stage: bindFailure.stage,
|
||||
errnoCode: bindFailure.errnoCode,
|
||||
extra: ["path": failedPath],
|
||||
request: ListenerStartRequest(
|
||||
socketPath: activeSocketPath,
|
||||
accessMode: request.accessMode,
|
||||
preserveAcceptFailureStreak: request.preserveAcceptFailureStreak
|
||||
),
|
||||
generation: generation
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
switch bindAttempt {
|
||||
case .success(let boundPath, let identity):
|
||||
activeSocketPath = boundPath
|
||||
activeBoundSocketPathIdentity = identity
|
||||
withListenerState { state in
|
||||
state.socketPath = activeSocketPath
|
||||
state.boundSocketPathIdentity = identity
|
||||
}
|
||||
case .pathTooLong(let failedPath):
|
||||
close(newServerSocket)
|
||||
reportSocketListenerFailure(
|
||||
}
|
||||
|
||||
if let errnoCode = applySocketPermissions() {
|
||||
_ = handleStartupFailure(
|
||||
message: "socket.listener.start.failed",
|
||||
stage: "bind_path_too_long",
|
||||
errnoCode: ENAMETOOLONG,
|
||||
extra: [
|
||||
"path": failedPath,
|
||||
"pathLength": failedPath.utf8.count,
|
||||
"maxPathLength": SocketTransport.unixSocketPathMaxLength,
|
||||
]
|
||||
)
|
||||
return false
|
||||
case .failure(let failedPath, let bindFailure):
|
||||
print("SocketControlServer: Failed to bind socket")
|
||||
close(newServerSocket)
|
||||
reportSocketListenerFailure(
|
||||
message: "socket.listener.start.failed",
|
||||
stage: bindFailure.stage,
|
||||
errnoCode: bindFailure.errnoCode,
|
||||
extra: ["path": failedPath]
|
||||
stage: "chmod",
|
||||
errnoCode: errnoCode,
|
||||
request: ListenerStartRequest(
|
||||
socketPath: activeSocketPath,
|
||||
accessMode: request.accessMode,
|
||||
preserveAcceptFailureStreak: request.preserveAcceptFailureStreak
|
||||
),
|
||||
generation: generation
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
guard applySocketPermissions() else {
|
||||
close(newServerSocket)
|
||||
return false
|
||||
}
|
||||
|
||||
if let errnoCode = transport.configureNonBlocking(newServerSocket) {
|
||||
print("SocketControlServer: Failed to configure socket")
|
||||
close(newServerSocket)
|
||||
reportSocketListenerFailure(
|
||||
if let errnoCode = transport.configureNonBlocking(activeServerSocket) {
|
||||
socketControlServerLogger.error("Failed to configure listener socket")
|
||||
_ = handleStartupFailure(
|
||||
message: "socket.listener.start.failed",
|
||||
stage: "configure_nonblocking",
|
||||
errnoCode: errnoCode
|
||||
errnoCode: errnoCode,
|
||||
request: ListenerStartRequest(
|
||||
socketPath: activeSocketPath,
|
||||
accessMode: request.accessMode,
|
||||
preserveAcceptFailureStreak: request.preserveAcceptFailureStreak
|
||||
),
|
||||
generation: generation
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// Listen
|
||||
guard listen(newServerSocket, transport.listenBacklog) >= 0 else {
|
||||
guard listen(activeServerSocket, transport.listenBacklog) >= 0 else {
|
||||
let errnoCode = errno
|
||||
print("SocketControlServer: Failed to listen on socket")
|
||||
close(newServerSocket)
|
||||
reportSocketListenerFailure(
|
||||
socketControlServerLogger.error("Failed to listen on socket")
|
||||
_ = handleStartupFailure(
|
||||
message: "socket.listener.start.failed",
|
||||
stage: "listen",
|
||||
errnoCode: errnoCode
|
||||
errnoCode: errnoCode,
|
||||
request: ListenerStartRequest(
|
||||
socketPath: activeSocketPath,
|
||||
accessMode: request.accessMode,
|
||||
preserveAcceptFailureStreak: request.preserveAcceptFailureStreak
|
||||
),
|
||||
generation: generation
|
||||
)
|
||||
return false
|
||||
}
|
||||
@@ -308,58 +484,183 @@ extension SocketControlServer {
|
||||
|
||||
var displacedSocketPathLockFD: Int32 = -1
|
||||
let transferredSocketPathLockFD = activeSocketPathLockFD
|
||||
let generation = withListenerState { state in
|
||||
let acceptGeneration = withListenerState { state in
|
||||
state.isRunning = true
|
||||
state.pendingAcceptLoopRearmGeneration = nil
|
||||
state.nextAcceptLoopGeneration &+= 1
|
||||
let generation = state.nextAcceptLoopGeneration
|
||||
state.activeAcceptLoopGeneration = generation
|
||||
state.serverSocket = newServerSocket
|
||||
let acceptGeneration = state.nextAcceptLoopGeneration
|
||||
state.activeAcceptLoopGeneration = acceptGeneration
|
||||
state.serverSocket = activeServerSocket
|
||||
displacedSocketPathLockFD = state.socketPathLockFD
|
||||
state.socketPathLockFD = activeSocketPathLockFD
|
||||
state.listenerStartInProgress = false
|
||||
return generation
|
||||
state.boundSocketPathOwnership = activeBoundSocketPathOwnership
|
||||
state.listenerState = .idle(generation: generation)
|
||||
return acceptGeneration
|
||||
}
|
||||
activateConnectionAuthorizations()
|
||||
acceptRecovery.withLock { recovery in
|
||||
recovery = AcceptRecoveryState(
|
||||
generation: generation,
|
||||
consecutiveFailures: preserveAcceptFailureStreak ? recovery.consecutiveFailures : 0,
|
||||
generation: acceptGeneration,
|
||||
consecutiveFailures: request.preserveAcceptFailureStreak ? recovery.consecutiveFailures : 0,
|
||||
recoveryHopInFlight: false
|
||||
)
|
||||
}
|
||||
if displacedSocketPathLockFD >= 0, displacedSocketPathLockFD != transferredSocketPathLockFD {
|
||||
transport.releaseSocketPathLock(displacedSocketPathLockFD)
|
||||
}
|
||||
activeServerSocket = -1
|
||||
activeSocketPathLockFD = -1
|
||||
activeBoundSocketPathOwnership = .none
|
||||
listenerActivated = true
|
||||
let listenerSocket = newServerSocket
|
||||
print("SocketControlServer: Listening on \(activeSocketPath)")
|
||||
let listenerSocket = withListenerState { $0.serverSocket }
|
||||
socketControlServerLogger.info("Listening on \(activeSocketPath, privacy: .private)")
|
||||
events.breadcrumb(
|
||||
"socket.listener.listening",
|
||||
[
|
||||
"path": activeSocketPath,
|
||||
"mode": accessMode.rawValue,
|
||||
"generation": generation,
|
||||
"mode": request.accessMode.rawValue,
|
||||
"generation": acceptGeneration,
|
||||
"backlog": transport.listenBacklog,
|
||||
]
|
||||
)
|
||||
events.listenerDidStart(activeSocketPath, generation)
|
||||
events.listenerDidStart(activeSocketPath, acceptGeneration)
|
||||
|
||||
startSocketPathMonitor(path: activeSocketPath, generation: generation)
|
||||
startAcceptSource(listenerSocket: listenerSocket, generation: generation)
|
||||
startSocketPathMonitor(path: activeSocketPath, generation: acceptGeneration)
|
||||
startAcceptSource(listenerSocket: listenerSocket, generation: acceptGeneration)
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func handleStartupFailure(
|
||||
message: String,
|
||||
stage: String,
|
||||
errnoCode: Int32,
|
||||
extra: [String: any Sendable] = [:],
|
||||
request: ListenerStartRequest,
|
||||
generation: UInt64,
|
||||
retainedSocket: Int32 = -1,
|
||||
retainedPathLockFD: Int32 = -1,
|
||||
retainedOwnership: BoundSocketPathOwnership = .none
|
||||
) -> StartupFailureDisposition {
|
||||
guard let failureCount = withListenerState({ state -> Int? in
|
||||
guard case .starting(let currentGeneration, _, let currentFailureCount) = state.listenerState,
|
||||
currentGeneration == generation else { return nil }
|
||||
return currentFailureCount + 1
|
||||
}) else { return .terminal }
|
||||
guard listenerPolicy.shouldRetryStartupFailure(
|
||||
stage: stage,
|
||||
errnoCode: errnoCode,
|
||||
consecutiveFailures: failureCount
|
||||
) else {
|
||||
var reportExtra = extra
|
||||
reportExtra["startupFailureCount"] = failureCount
|
||||
let shouldReport = withListenerState { state -> Bool in
|
||||
guard case .starting(let currentGeneration, _, _) = state.listenerState,
|
||||
currentGeneration == generation else { return false }
|
||||
state.listenerState = .idle(generation: generation)
|
||||
return true
|
||||
}
|
||||
guard shouldReport else { return .terminal }
|
||||
reportSocketListenerFailure(
|
||||
message: message,
|
||||
stage: stage,
|
||||
errnoCode: errnoCode,
|
||||
extra: reportExtra
|
||||
)
|
||||
return .terminal
|
||||
}
|
||||
|
||||
let delayMs = listenerPolicy.startupFailureRetryDelayMilliseconds(
|
||||
consecutiveFailures: failureCount
|
||||
)
|
||||
var retryExtra = extra
|
||||
retryExtra["startupFailureCount"] = failureCount
|
||||
retryExtra["retryDelayMs"] = delayMs
|
||||
events.breadcrumb(
|
||||
"socket.listener.start.retry_scheduled",
|
||||
socketListenerEventData(
|
||||
stage: stage,
|
||||
errnoCode: errnoCode,
|
||||
extra: retryExtra
|
||||
)
|
||||
)
|
||||
|
||||
let didSchedule = withListenerState { state -> Bool in
|
||||
guard case .starting(let currentGeneration, _, _) = state.listenerState,
|
||||
currentGeneration == generation else { return false }
|
||||
state.socketPath = request.socketPath
|
||||
state.listenerState = .waiting(
|
||||
generation: generation,
|
||||
request: request,
|
||||
failureCount: failureCount
|
||||
)
|
||||
if retainedSocket >= 0,
|
||||
retainedPathLockFD >= 0,
|
||||
retainedOwnership == .identityPending {
|
||||
state.serverSocket = retainedSocket
|
||||
state.socketPathLockFD = retainedPathLockFD
|
||||
state.boundSocketPathOwnership = retainedOwnership
|
||||
}
|
||||
return true
|
||||
}
|
||||
guard didSchedule else { return .terminal }
|
||||
startupWakeTask?.cancel()
|
||||
// The task owns only the bounded delay. ListenerState owns every
|
||||
// lifecycle value and wakeStartupRetry atomically claims it.
|
||||
startupWakeTask = Task { [weak self, recoveryClock] in
|
||||
do {
|
||||
try await recoveryClock.sleep(forMilliseconds: delayMs)
|
||||
try Task.checkCancellation()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
self?.wakeStartupRetry(generation: generation)
|
||||
}
|
||||
return .retryScheduled
|
||||
}
|
||||
|
||||
/// Atomically claims a matching delayed retry before starting any syscall work.
|
||||
private func wakeStartupRetry(generation: UInt64) {
|
||||
let didClaim = withListenerState { state -> Bool in
|
||||
guard case .waiting(let currentGeneration, let request, let failureCount) = state.listenerState,
|
||||
currentGeneration == generation else { return false }
|
||||
state.listenerState = .starting(
|
||||
generation: generation,
|
||||
request: request,
|
||||
failureCount: failureCount
|
||||
)
|
||||
return true
|
||||
}
|
||||
guard didClaim else { return }
|
||||
startupWakeTask = nil
|
||||
_ = startAttempt(generation: generation)
|
||||
}
|
||||
|
||||
/// Removes a bound path only with the identity captured after a proven bind.
|
||||
/// Identity-pending paths are always preserved during teardown.
|
||||
func unlinkOwnedSocketPath(
|
||||
_ path: String,
|
||||
ownership: BoundSocketPathOwnership
|
||||
) {
|
||||
// Never promote during teardown. A later identity read cannot prove
|
||||
// that another process did not replace the original directory entry.
|
||||
guard case .identified(let identity) = ownership else { return }
|
||||
guard listenerPolicy.shouldUnlinkSocketPathAfterListenerStop(
|
||||
currentIdentity: transport.pathIdentity(at: path),
|
||||
boundIdentity: identity
|
||||
) else { return }
|
||||
unlink(path)
|
||||
}
|
||||
|
||||
/// Applies the access mode's file permissions to the current socket path.
|
||||
@discardableResult
|
||||
func applySocketPermissions() -> Bool {
|
||||
func applySocketPermissions() -> Int32? {
|
||||
let (currentSocketPath, mode) = withListenerState { ($0.socketPath, $0.accessMode) }
|
||||
let permissions = mode_t(mode.socketFilePermissions)
|
||||
if chmod(currentSocketPath, permissions) != 0 {
|
||||
let errnoCode = errno
|
||||
print(
|
||||
"TerminalController: Failed to set socket permissions to \(String(permissions, radix: 8)) for \(currentSocketPath)"
|
||||
if let errnoCode = transport.applySocketPermissions(permissions, at: currentSocketPath) {
|
||||
let permissionsDescription = String(permissions, radix: 8)
|
||||
socketControlServerLogger.error(
|
||||
"Failed to set socket permissions to \(permissionsDescription, privacy: .public) for \(currentSocketPath, privacy: .private)"
|
||||
)
|
||||
events.breadcrumb(
|
||||
"socket.listener.permissions.failed",
|
||||
@@ -369,9 +670,21 @@ extension SocketControlServer {
|
||||
extra: ["permissions": String(permissions, radix: 8)]
|
||||
)
|
||||
)
|
||||
return false
|
||||
return errnoCode
|
||||
}
|
||||
return true
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func canReserveStartupSocketPath(state: ListenerState) -> Bool {
|
||||
!state.isRunning &&
|
||||
!state.acceptLoopAlive &&
|
||||
!state.listenerState.isStarting &&
|
||||
!state.listenerState.isWaiting &&
|
||||
state.pendingAcceptLoopRearmGeneration == nil &&
|
||||
state.socketPathLockFD < 0 &&
|
||||
state.listenerReadSource == nil &&
|
||||
state.socketPathMonitorSource == nil &&
|
||||
state.serverSocket < 0
|
||||
}
|
||||
|
||||
+15
-14
@@ -3,9 +3,9 @@ internal import Foundation
|
||||
|
||||
extension SocketControlServer {
|
||||
/// Stops the listener: tears down the accept and path-monitor sources,
|
||||
/// cancels any pending accept-source resume, shuts down and closes the
|
||||
/// server socket, unlinks the socket path when the listener still owns
|
||||
/// it, and releases the path lock.
|
||||
/// cancels any pending startup retry or accept-source resume, shuts down
|
||||
/// and closes the server socket, unlinks the socket path when the listener
|
||||
/// still owns it, and releases the path lock.
|
||||
///
|
||||
/// Synchronous on the main actor, where every caller already lives — the
|
||||
/// app's termination and updater-relaunch paths call it directly, so the
|
||||
@@ -14,6 +14,8 @@ extension SocketControlServer {
|
||||
deactivateConnectionAuthorizations()
|
||||
acceptResumeTask?.cancel()
|
||||
acceptResumeTask = nil
|
||||
startupWakeTask?.cancel()
|
||||
startupWakeTask = nil
|
||||
let (
|
||||
sourceToCancel,
|
||||
sourceWasSuspended,
|
||||
@@ -21,7 +23,7 @@ extension SocketControlServer {
|
||||
socketToShutdown,
|
||||
socketToClose,
|
||||
socketPathToUnlink,
|
||||
boundSocketPathIdentityToUnlink,
|
||||
boundSocketPathOwnershipToUnlink,
|
||||
socketPathLockFDToClose
|
||||
) = withListenerState { state in
|
||||
state.isRunning = false
|
||||
@@ -29,7 +31,8 @@ extension SocketControlServer {
|
||||
state.pendingAcceptLoopRearmGeneration = nil
|
||||
state.reservedStartupSocketPath = nil
|
||||
state.reservedStartupSocketPathCanReplaceRefusedSocket = false
|
||||
state.listenerStartInProgress = false
|
||||
let startupGeneration = state.listenerState.generation &+ 1
|
||||
state.listenerState = .idle(generation: startupGeneration)
|
||||
state.nextAcceptLoopGeneration &+= 1
|
||||
state.activeAcceptLoopGeneration = 0
|
||||
let sourceToCancel = state.listenerReadSource
|
||||
@@ -40,8 +43,8 @@ extension SocketControlServer {
|
||||
state.socketPathMonitorSource = nil
|
||||
let socketToClose = state.serverSocket
|
||||
state.serverSocket = -1
|
||||
let identity = state.boundSocketPathIdentity
|
||||
state.boundSocketPathIdentity = nil
|
||||
let ownership = state.boundSocketPathOwnership
|
||||
state.boundSocketPathOwnership = .none
|
||||
let lockFD = state.socketPathLockFD
|
||||
state.socketPathLockFD = -1
|
||||
return (
|
||||
@@ -51,7 +54,7 @@ extension SocketControlServer {
|
||||
socketToClose,
|
||||
sourceToCancel == nil ? socketToClose : Int32(-1),
|
||||
state.socketPath,
|
||||
identity,
|
||||
ownership,
|
||||
lockFD
|
||||
)
|
||||
}
|
||||
@@ -63,15 +66,13 @@ extension SocketControlServer {
|
||||
}
|
||||
sourceToCancel?.cancel()
|
||||
monitorToCancel?.cancel()
|
||||
unlinkOwnedSocketPath(
|
||||
socketPathToUnlink,
|
||||
ownership: boundSocketPathOwnershipToUnlink
|
||||
)
|
||||
if socketToClose >= 0 {
|
||||
close(socketToClose)
|
||||
}
|
||||
if listenerPolicy.shouldUnlinkSocketPathAfterListenerStop(
|
||||
currentIdentity: transport.pathIdentity(at: socketPathToUnlink),
|
||||
boundIdentity: boundSocketPathIdentityToUnlink
|
||||
) {
|
||||
unlink(socketPathToUnlink)
|
||||
}
|
||||
transport.releaseSocketPathLock(socketPathLockFDToClose)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user