| author | |
| committer | |
| log | 3dff91e5b8938209f2a7b38e8323227ba4a12a71 |
| tree | 9a786b5f53250d5fbe2d52b51897ac41e3457e1f |
| parent | 02acde3ba6bf8683a349deb02c298332a7c4f4ed |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Live Share's dialog gains Copy Link beside Copy: https://snowbound.paperclover.net/
followed by the code. That address is a new small service, snowbound-site (a
second binary in crates/relay): it serves the hosted web build's folder, and
for a path that is a code, read as loosely as the app reads one, a page with
Open in Snowbound (snowbound://join/<code>), and Open in Web once --web names
where the web build joins. It listens on 127.0.0.1:23593 behind Caddy, with a
systemd unit and pm2 notes in crates/relay/README.md; release_relay.py builds
it beside the relay. It takes over from tools/web/serve.py, with the same cache
rules (b/<hash>/ kept for good, index.html checked every load), and
release_web.py --deploy now also builds it for the VPS and restarts pm2's
snowbound-site only when its binary changed. The relay's unit now listens on
23592.
snowbound:// opens the share: macOS's bundle declares the scheme and the app
takes application:openURLs:, Windows registers it for this user beside the
OneNote file types, and Linux's desktop entry handles x-scheme-handler/
snowbound with %U (files arrive as file:// URLs, which the app now reads).
A link pasted into Open Shared Notebook works as its code does. The code
module moves to the relay crate, which the site and the app both read it
from.
Assisted-by: claude-opus-5.527 files changed, 725 insertions(+), 222 deletions(-)
Cargo.lock+1| ... | @@ -3147,6 +3147,7 @@ dependencies = [ | ... | @@ -3147,6 +3147,7 @@ dependencies = [ |
| 3147 | "base64", | 3147 | "base64", |
| 3148 | "getrandom 0.4.3", | 3148 | "getrandom 0.4.3", |
| 3149 | "sha1", | 3149 | "sha1", |
| 3150 | "tempfile", | ||
| 3150 | ] | 3151 | ] |
| 3151 | 3152 | ||
| 3152 | [[package]] | 3153 | [[package]] |
crates/notebook/src/live.rs+1-1| ... | @@ -7,7 +7,7 @@ | ... | @@ -7,7 +7,7 @@ |
| 7 | //! and one writing. A connection whose frames arrive out of order is dropped and met again | 7 | //! and one writing. A connection whose frames arrive out of order is dropped and met again |
| 8 | //! from scratch. | 8 | //! from scratch. |
| 9 | 9 | ||
| 10 | pub mod code; | 10 | pub use ::relay::code; |
| 11 | mod relay; | 11 | mod relay; |
| 12 | pub mod share; | 12 | pub mod share; |
| 13 | pub mod wire; | 13 | pub mod wire; |
crates/notebook/src/live/code.rs deleted-148| ... | @@ -1,148 +0,0 @@ | ||
| 1 | //! Live Share's codes in Crockford's base32 (0-9 and A-Z without I, L, O and U), shown | ||
| 2 | //! `7KQ-4MZ-9XR`: two symbols naming the code's room, its number on a relay; six of secret | ||
| 3 | //! (30 bits), which SPAKE2 meets through; and a check symbol, so a mistyped code is refused | ||
| 4 | //! here before it spends one of the relay's few tries. Reading ignores case, hyphens and | ||
| 5 | //! spaces, and takes I and L for 1 and O for 0, as Crockford's decoding does. | ||
| 6 | //! | ||
| 7 | //! The check is the symbols' values weighted 1 to 8, summed modulo 31, the prime under 32, so | ||
| 8 | //! it stays one of the code's own symbols: it catches any symbol mistyped and any two | ||
| 9 | //! neighbours swapped, but for 0 and Z, whose values differ by 31. Crockford's own check | ||
| 10 | //! symbol, modulo 37, adds `*~$=U`, which read badly aloud. | ||
| 11 | |||
| 12 | use std::io; | ||
| 13 | |||
| 14 | const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; | ||
| 15 | /// The symbols naming a code's room, and how many rooms they name. | ||
| 16 | const NAMEPLATE: usize = 2; | ||
| 17 | pub const NAMEPLATES: u32 = 1 << (5 * NAMEPLATE); | ||
| 18 | /// The symbols of a code's secret. | ||
| 19 | pub const SECRET: usize = 6; | ||
| 20 | |||
| 21 | /// A new secret, `SECRET` random symbols. | ||
| 22 | pub fn secret() -> io::Result<String> { | ||
| 23 | let mut bytes = [0; 4]; | ||
| 24 | getrandom::fill(&mut bytes).map_err(|_| io::Error::other("System random source failed"))?; | ||
| 25 | let bits = u32::from_le_bytes(bytes); | ||
| 26 | Ok((0..SECRET) | ||
| 27 | .map(|at| symbol((bits >> (5 * at)) as u8)) | ||
| 28 | .collect()) | ||
| 29 | } | ||
| 30 | |||
| 31 | fn symbol(value: u8) -> char { | ||
| 32 | char::from(ALPHABET[usize::from(value & 31)]) | ||
| 33 | } | ||
| 34 | |||
| 35 | /// A symbol's value as typed, reading I and L as 1 and O as 0. | ||
| 36 | fn value(typed: char) -> Option<u8> { | ||
| 37 | let typed = match typed.to_ascii_uppercase() { | ||
| 38 | 'I' | 'L' => '1', | ||
| 39 | 'O' => '0', | ||
| 40 | typed => typed, | ||
| 41 | }; | ||
| 42 | ALPHABET | ||
| 43 | .iter() | ||
| 44 | .position(|symbol| char::from(*symbol) == typed) | ||
| 45 | .map(|at| at as u8) | ||
| 46 | } | ||
| 47 | |||
| 48 | fn check(values: &[u8]) -> u8 { | ||
| 49 | let sum: u32 = (values.iter().enumerate()) | ||
| 50 | .map(|(at, value)| (at as u32 + 1) * u32::from(*value)) | ||
| 51 | .sum(); | ||
| 52 | (sum % 31) as u8 | ||
| 53 | } | ||
| 54 | |||
| 55 | /// The code for room `nameplate` and `secret`, as shown: `7KQ-4MZ-9XR`. A secret that isn't | ||
| 56 | /// `SECRET` symbols has no code. | ||
| 57 | pub fn format(nameplate: u32, secret: &str) -> Option<String> { | ||
| 58 | let secret: Vec<u8> = secret.chars().map(value).collect::<Option<_>>()?; | ||
| 59 | if nameplate >= NAMEPLATES || secret.len() != SECRET { | ||
| 60 | return None; | ||
| 61 | } | ||
| 62 | let mut values = vec![(nameplate >> 5) as u8, (nameplate & 31) as u8]; | ||
| 63 | values.extend(secret); | ||
| 64 | values.push(check(&values)); | ||
| 65 | let symbols: Vec<char> = values.into_iter().map(symbol).collect(); | ||
| 66 | Some( | ||
| 67 | symbols | ||
| 68 | .chunks(3) | ||
| 69 | .map(|group| group.iter().collect::<String>()) | ||
| 70 | .collect::<Vec<_>>() | ||
| 71 | .join("-"), | ||
| 72 | ) | ||
| 73 | } | ||
| 74 | |||
| 75 | /// A code as typed: its room's number and its secret, where it is a code whose check holds. | ||
| 76 | pub fn parse(typed: &str) -> Option<(u32, String)> { | ||
| 77 | let values: Vec<u8> = typed | ||
| 78 | .chars() | ||
| 79 | .filter(|c| *c != '-' && !c.is_whitespace()) | ||
| 80 | .map(value) | ||
| 81 | .collect::<Option<_>>()?; | ||
| 82 | let (check_value, values) = values.split_last()?; | ||
| 83 | if values.len() != NAMEPLATE + SECRET || check(values) != *check_value { | ||
| 84 | return None; | ||
| 85 | } | ||
| 86 | let nameplate = (u32::from(values[0]) << 5) | u32::from(values[1]); | ||
| 87 | let secret = values[NAMEPLATE..].iter().copied().map(symbol).collect(); | ||
| 88 | Some((nameplate, secret)) | ||
| 89 | } | ||
| 90 | |||
| 91 | #[cfg(test)] | ||
| 92 | mod tests { | ||
| 93 | use super::*; | ||
| 94 | |||
| 95 | #[test] | ||
| 96 | fn codes_read_back_however_they_are_typed() { | ||
| 97 | let code = format(412, "4MZ9XR").unwrap(); | ||
| 98 | assert_eq!(code.len(), 11); | ||
| 99 | assert_eq!(parse(&code), Some((412, "4MZ9XR".into()))); | ||
| 100 | let typed = code | ||
| 101 | .to_lowercase() | ||
| 102 | .replace('-', " ") | ||
| 103 | .replace('1', "l") | ||
| 104 | .replace('0', "o"); | ||
| 105 | assert_eq!(parse(&typed), Some((412, "4MZ9XR".into()))); | ||
| 106 | assert_eq!(parse(&format!(" {code} ")), Some((412, "4MZ9XR".into()))); | ||
| 107 | assert!(format(NAMEPLATES, "4MZ9XR").is_none() && format(1, "4MZ9X").is_none()); | ||
| 108 | assert!(parse("412-violet-otter").is_none() && parse("").is_none()); | ||
| 109 | for _ in 0..100 { | ||
| 110 | let secret = secret().unwrap(); | ||
| 111 | assert_eq!(secret.len(), SECRET); | ||
| 112 | assert_eq!(parse(&format(7, &secret).unwrap()), Some((7, secret))); | ||
| 113 | } | ||
| 114 | } | ||
| 115 | |||
| 116 | /// The check refuses any one symbol mistyped, and any two neighbours swapped, but for 0 | ||
| 117 | /// and Z. | ||
| 118 | #[test] | ||
| 119 | fn the_check_catches_a_symbol_mistyped_or_two_swapped() { | ||
| 120 | let code: Vec<char> = format(999, "Q4MZ9X") | ||
| 121 | .unwrap() | ||
| 122 | .replace('-', "") | ||
| 123 | .chars() | ||
| 124 | .collect(); | ||
| 125 | for at in 0..code.len() { | ||
| 126 | for symbol in ALPHABET.iter().map(|byte| char::from(*byte)) { | ||
| 127 | let mut typed = code.clone(); | ||
| 128 | if typed[at] != symbol && !matches!((typed[at], symbol), ('0', 'Z') | ('Z', '0')) { | ||
| 129 | typed[at] = symbol; | ||
| 130 | assert!( | ||
| 131 | parse(&typed.iter().collect::<String>()).is_none(), | ||
| 132 | "{typed:?}" | ||
| 133 | ); | ||
| 134 | } | ||
| 135 | } | ||
| 136 | } | ||
| 137 | for at in 0..code.len() - 1 { | ||
| 138 | let mut typed = code.clone(); | ||
| 139 | typed.swap(at, at + 1); | ||
| 140 | if typed != code { | ||
| 141 | assert!( | ||
| 142 | parse(&typed.iter().collect::<String>()).is_none(), | ||
| 143 | "{typed:?}" | ||
| 144 | ); | ||
| 145 | } | ||
| 146 | } | ||
| 147 | } | ||
| 148 | } | ||
crates/relay/Cargo.toml+8| ... | @@ -10,7 +10,15 @@ publish = false | ... | @@ -10,7 +10,15 @@ publish = false |
| 10 | name = "snowbound-relay" | 10 | name = "snowbound-relay" |
| 11 | path = "src/main.rs" | 11 | path = "src/main.rs" |
| 12 | 12 | ||
| 13 | # snowbound.paperclover.net: the hosted web build, and a page for each Live Share code. | ||
| 14 | [[bin]] | ||
| 15 | name = "snowbound-site" | ||
| 16 | path = "src/site_main.rs" | ||
| 17 | |||
| 13 | [dependencies] | 18 | [dependencies] |
| 14 | base64 = { version = "0.23.1", default-features = false, features = ["std"] } | 19 | base64 = { version = "0.23.1", default-features = false, features = ["std"] } |
| 15 | getrandom = "0.4.3" | 20 | getrandom = "0.4.3" |
| 16 | sha1 = "0.11.0" | 21 | sha1 = "0.11.0" |
| 22 | |||
| 23 | [dev-dependencies] | ||
| 24 | tempfile = "3" |
crates/relay/README.md+43-7| ... | @@ -12,11 +12,11 @@ HTTP and WebSocket; a proxy in front of it terminates TLS. | ... | @@ -12,11 +12,11 @@ HTTP and WebSocket; a proxy in front of it terminates TLS. |
| 12 | ## Build | 12 | ## Build |
| 13 | 13 | ||
| 14 | ```sh | 14 | ```sh |
| 15 | python3 tools/release_relay.py # target/relay/snowbound-relay-linux-{x86_64,aarch64} | 15 | python3 tools/release_relay.py # target/relay/snowbound-{relay,site}-linux-{x86_64,aarch64} |
| 16 | ``` | 16 | ``` |
| 17 | 17 | ||
| 18 | It links with Rust's own lld against Rust's own musl, so it needs only `rustup`. The folder | 18 | It links with Rust's own lld against Rust's own musl, so it needs only `rustup`. The folder |
| 19 | it makes holds both executables, `SHA256SUMS`, this README and `snowbound-relay.service`. | 19 | it makes holds the executables, `SHA256SUMS`, this README and both systemd units. |
| 20 | 20 | ||
| 21 | ## Deploy | 21 | ## Deploy |
| 22 | 22 | ||
| ... | @@ -28,7 +28,7 @@ sudo install -m 755 /tmp/snowbound-relay /usr/local/bin/snowbound-relay | ... | @@ -28,7 +28,7 @@ sudo install -m 755 /tmp/snowbound-relay /usr/local/bin/snowbound-relay |
| 28 | sudo install -m 644 /tmp/snowbound-relay.service /etc/systemd/system/ | 28 | sudo install -m 644 /tmp/snowbound-relay.service /etc/systemd/system/ |
| 29 | sudo systemctl daemon-reload | 29 | sudo systemctl daemon-reload |
| 30 | sudo systemctl enable --now snowbound-relay | 30 | sudo systemctl enable --now snowbound-relay |
| 31 | curl -s http://127.0.0.1:7650/health # {"rooms":0,"peers":0,"connections":1,"seconds":3} | 31 | curl -s http://127.0.0.1:23592/health # {"rooms":0,"peers":0,"connections":1,"seconds":3} |
| 32 | ``` | 32 | ``` |
| 33 | 33 | ||
| 34 | Then point a name at the server and put a TLS proxy in front. Caddy fetches its own | 34 | Then point a name at the server and put a TLS proxy in front. Caddy fetches its own |
| ... | @@ -36,7 +36,7 @@ certificate and passes WebSocket upgrades through as they are: | ... | @@ -36,7 +36,7 @@ certificate and passes WebSocket upgrades through as they are: |
| 36 | 36 | ||
| 37 | ```text | 37 | ```text |
| 38 | live.example.net { | 38 | live.example.net { |
| 39 | reverse_proxy 127.0.0.1:7650 | 39 | reverse_proxy 127.0.0.1:23592 |
| 40 | } | 40 | } |
| 41 | ``` | 41 | ``` |
| 42 | 42 | ||
| ... | @@ -49,7 +49,7 @@ server { | ... | @@ -49,7 +49,7 @@ server { |
| 49 | ssl_certificate /etc/letsencrypt/live/live.example.net/fullchain.pem; | 49 | ssl_certificate /etc/letsencrypt/live/live.example.net/fullchain.pem; |
| 50 | ssl_certificate_key /etc/letsencrypt/live/live.example.net/privkey.pem; | 50 | ssl_certificate_key /etc/letsencrypt/live/live.example.net/privkey.pem; |
| 51 | location / { | 51 | location / { |
| 52 | proxy_pass http://127.0.0.1:7650; | 52 | proxy_pass http://127.0.0.1:23592; |
| 53 | proxy_http_version 1.1; | 53 | proxy_http_version 1.1; |
| 54 | proxy_set_header Upgrade $http_upgrade; | 54 | proxy_set_header Upgrade $http_upgrade; |
| 55 | proxy_set_header Connection "upgrade"; | 55 | proxy_set_header Connection "upgrade"; |
| ... | @@ -60,14 +60,50 @@ server { | ... | @@ -60,14 +60,50 @@ server { |
| 60 | } | 60 | } |
| 61 | ``` | 61 | ``` |
| 62 | 62 | ||
| 63 | The app then uses `wss://live.example.net` (for now, `SNOWBOUND_LIVE_RELAY=wss://live.example.net` | 63 | The app uses `wss://relay.snowbound.paperclover.net` unless Options ▸ Sync & Storage ▸ Live |
| 64 | with a build that has the `live` feature). | 64 | Share names another relay. |
| 65 | 65 | ||
| 66 | `--trust-forwarded true`, as the unit sets it, counts each peer by the last | 66 | `--trust-forwarded true`, as the unit sets it, counts each peer by the last |
| 67 | `X-Forwarded-For` entry, the one the proxy added. Without a proxy, leave it off: a client | 67 | `X-Forwarded-For` entry, the one the proxy added. Without a proxy, leave it off: a client |
| 68 | could otherwise claim any address. Listening on a public address without TLS works but lets | 68 | could otherwise claim any address. Listening on a public address without TLS works but lets |
| 69 | anyone on the path see room tags and nameplates. | 69 | anyone on the path see room tags and nameplates. |
| 70 | 70 | ||
| 71 | ## The site: snowbound.paperclover.net | ||
| 72 | |||
| 73 | `snowbound-site` serves the hosted web build's folder, and for a path that is a Live Share | ||
| 74 | code (`/7KQ-4MZ-9XR`, read as loosely as the app reads one) a page that opens it with | ||
| 75 | `snowbound://join/<code>`, and in the web build where `--web` names where (once the web build | ||
| 76 | joins shares). Anything else under the folder is a file; `/` is its `index.html`. A build's | ||
| 77 | module and JavaScript sit in `b/<hash>/` and are cached for good; `index.html` and the | ||
| 78 | codes' pages are checked on every load, and the rest (fonts, dictionaries) for a day. It | ||
| 79 | holds no state. | ||
| 80 | |||
| 81 | `python3 tools/release_web.py --deploy` builds the web app and the site for the VPS's | ||
| 82 | architecture, copies the build into `~/snowbound-web/site/` (keeping the last three builds' | ||
| 83 | folders for pages still running them) and the binary to `~/snowbound-web/snowbound-site`, | ||
| 84 | and restarts pm2's `snowbound-site` only when the binary changed. The first time: | ||
| 85 | |||
| 86 | ```sh | ||
| 87 | ssh vps | ||
| 88 | mkdir -p ~/snowbound-web/site | ||
| 89 | # after the first `release_web.py --deploy` has put the binary there: | ||
| 90 | pm2 start ~/snowbound-web/snowbound-site --name snowbound-site -- \ | ||
| 91 | --listen 127.0.0.1:23593 --root "$HOME/snowbound-web/site" | ||
| 92 | pm2 save | ||
| 93 | ``` | ||
| 94 | |||
| 95 | Caddy in front: | ||
| 96 | |||
| 97 | ```text | ||
| 98 | snowbound.paperclover.net { | ||
| 99 | encode gzip | ||
| 100 | reverse_proxy 127.0.0.1:23593 | ||
| 101 | } | ||
| 102 | ``` | ||
| 103 | |||
| 104 | `snowbound-site.service` runs it under systemd instead (`--root /srv/snowbound/web`). | ||
| 105 | `snowbound-site --help` lists the options, each also `SNOWBOUND_SITE_<OPTION>`. | ||
| 106 | |||
| 71 | ## Options | 107 | ## Options |
| 72 | 108 | ||
| 73 | `snowbound-relay --help` lists every option and its default. Each can also be set in the | 109 | `snowbound-relay --help` lists every option and its default. Each can also be set in the |
crates/relay/deploy/snowbound-relay.service+1-1| ... | @@ -6,7 +6,7 @@ After=network-online.target | ... | @@ -6,7 +6,7 @@ After=network-online.target |
| 6 | Wants=network-online.target | 6 | Wants=network-online.target |
| 7 | 7 | ||
| 8 | [Service] | 8 | [Service] |
| 9 | ExecStart=/usr/local/bin/snowbound-relay --listen 127.0.0.1:7650 --trust-forwarded true | 9 | ExecStart=/usr/local/bin/snowbound-relay --listen 127.0.0.1:23592 --trust-forwarded true |
| 10 | Restart=always | 10 | Restart=always |
| 11 | RestartSec=2 | 11 | RestartSec=2 |
| 12 | DynamicUser=yes | 12 | DynamicUser=yes |
crates/relay/deploy/snowbound-site.service created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | # /etc/systemd/system/snowbound-site.service: snowbound.paperclover.net, the hosted web build | ||
| 2 | # and the codes' pages, behind a TLS proxy on this machine (README.md). Options go on | ||
| 3 | # ExecStart, or as SNOWBOUND_SITE_* in Environment. | ||
| 4 | [Unit] | ||
| 5 | Description=Snowbound web build and Live Share links | ||
| 6 | After=network-online.target | ||
| 7 | Wants=network-online.target | ||
| 8 | |||
| 9 | [Service] | ||
| 10 | ExecStart=/usr/local/bin/snowbound-site --listen 127.0.0.1:23593 --root /srv/snowbound/web | ||
| 11 | Restart=always | ||
| 12 | RestartSec=2 | ||
| 13 | DynamicUser=yes | ||
| 14 | TasksMax=200 | ||
| 15 | MemoryMax=256M | ||
| 16 | NoNewPrivileges=yes | ||
| 17 | ProtectSystem=strict | ||
| 18 | ReadOnlyPaths=/srv/snowbound/web | ||
| 19 | ProtectHome=yes | ||
| 20 | PrivateTmp=yes | ||
| 21 | PrivateDevices=yes | ||
| 22 | ProtectKernelTunables=yes | ||
| 23 | ProtectKernelModules=yes | ||
| 24 | ProtectControlGroups=yes | ||
| 25 | RestrictAddressFamilies=AF_INET AF_INET6 | ||
| 26 | RestrictNamespaces=yes | ||
| 27 | LockPersonality=yes | ||
| 28 | MemoryDenyWriteExecute=yes | ||
| 29 | SystemCallArchitectures=native | ||
| 30 | CapabilityBoundingSet= | ||
| 31 | |||
| 32 | [Install] | ||
| 33 | WantedBy=multi-user.target | ||
crates/relay/src/code.rs created+148| ... | @@ -0,0 +1,148 @@ | ||
| 1 | //! Live Share's codes in Crockford's base32 (0-9 and A-Z without I, L, O and U), shown | ||
| 2 | //! `7KQ-4MZ-9XR`: two symbols naming the code's room, its number on a relay; six of secret | ||
| 3 | //! (30 bits), which SPAKE2 meets through; and a check symbol, so a mistyped code is refused | ||
| 4 | //! here before it spends one of the relay's few tries. Reading ignores case, hyphens and | ||
| 5 | //! spaces, and takes I and L for 1 and O for 0, as Crockford's decoding does. | ||
| 6 | //! | ||
| 7 | //! The check is the symbols' values weighted 1 to 8, summed modulo 31, the prime under 32, so | ||
| 8 | //! it stays one of the code's own symbols: it catches any symbol mistyped and any two | ||
| 9 | //! neighbours swapped, but for 0 and Z, whose values differ by 31. Crockford's own check | ||
| 10 | //! symbol, modulo 37, adds `*~$=U`, which read badly aloud. | ||
| 11 | |||
| 12 | use std::io; | ||
| 13 | |||
| 14 | const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; | ||
| 15 | /// The symbols naming a code's room, and how many rooms they name. | ||
| 16 | const NAMEPLATE: usize = 2; | ||
| 17 | pub const NAMEPLATES: u32 = 1 << (5 * NAMEPLATE); | ||
| 18 | /// The symbols of a code's secret. | ||
| 19 | pub const SECRET: usize = 6; | ||
| 20 | |||
| 21 | /// A new secret, `SECRET` random symbols. | ||
| 22 | pub fn secret() -> io::Result<String> { | ||
| 23 | let mut bytes = [0; 4]; | ||
| 24 | getrandom::fill(&mut bytes).map_err(|_| io::Error::other("System random source failed"))?; | ||
| 25 | let bits = u32::from_le_bytes(bytes); | ||
| 26 | Ok((0..SECRET) | ||
| 27 | .map(|at| symbol((bits >> (5 * at)) as u8)) | ||
| 28 | .collect()) | ||
| 29 | } | ||
| 30 | |||
| 31 | fn symbol(value: u8) -> char { | ||
| 32 | char::from(ALPHABET[usize::from(value & 31)]) | ||
| 33 | } | ||
| 34 | |||
| 35 | /// A symbol's value as typed, reading I and L as 1 and O as 0. | ||
| 36 | fn value(typed: char) -> Option<u8> { | ||
| 37 | let typed = match typed.to_ascii_uppercase() { | ||
| 38 | 'I' | 'L' => '1', | ||
| 39 | 'O' => '0', | ||
| 40 | typed => typed, | ||
| 41 | }; | ||
| 42 | ALPHABET | ||
| 43 | .iter() | ||
| 44 | .position(|symbol| char::from(*symbol) == typed) | ||
| 45 | .map(|at| at as u8) | ||
| 46 | } | ||
| 47 | |||
| 48 | fn check(values: &[u8]) -> u8 { | ||
| 49 | let sum: u32 = (values.iter().enumerate()) | ||
| 50 | .map(|(at, value)| (at as u32 + 1) * u32::from(*value)) | ||
| 51 | .sum(); | ||
| 52 | (sum % 31) as u8 | ||
| 53 | } | ||
| 54 | |||
| 55 | /// The code for room `nameplate` and `secret`, as shown: `7KQ-4MZ-9XR`. A secret that isn't | ||
| 56 | /// `SECRET` symbols has no code. | ||
| 57 | pub fn format(nameplate: u32, secret: &str) -> Option<String> { | ||
| 58 | let secret: Vec<u8> = secret.chars().map(value).collect::<Option<_>>()?; | ||
| 59 | if nameplate >= NAMEPLATES || secret.len() != SECRET { | ||
| 60 | return None; | ||
| 61 | } | ||
| 62 | let mut values = vec![(nameplate >> 5) as u8, (nameplate & 31) as u8]; | ||
| 63 | values.extend(secret); | ||
| 64 | values.push(check(&values)); | ||
| 65 | let symbols: Vec<char> = values.into_iter().map(symbol).collect(); | ||
| 66 | Some( | ||
| 67 | symbols | ||
| 68 | .chunks(3) | ||
| 69 | .map(|group| group.iter().collect::<String>()) | ||
| 70 | .collect::<Vec<_>>() | ||
| 71 | .join("-"), | ||
| 72 | ) | ||
| 73 | } | ||
| 74 | |||
| 75 | /// A code as typed: its room's number and its secret, where it is a code whose check holds. | ||
| 76 | pub fn parse(typed: &str) -> Option<(u32, String)> { | ||
| 77 | let values: Vec<u8> = typed | ||
| 78 | .chars() | ||
| 79 | .filter(|c| *c != '-' && !c.is_whitespace()) | ||
| 80 | .map(value) | ||
| 81 | .collect::<Option<_>>()?; | ||
| 82 | let (check_value, values) = values.split_last()?; | ||
| 83 | if values.len() != NAMEPLATE + SECRET || check(values) != *check_value { | ||
| 84 | return None; | ||
| 85 | } | ||
| 86 | let nameplate = (u32::from(values[0]) << 5) | u32::from(values[1]); | ||
| 87 | let secret = values[NAMEPLATE..].iter().copied().map(symbol).collect(); | ||
| 88 | Some((nameplate, secret)) | ||
| 89 | } | ||
| 90 | |||
| 91 | #[cfg(test)] | ||
| 92 | mod tests { | ||
| 93 | use super::*; | ||
| 94 | |||
| 95 | #[test] | ||
| 96 | fn codes_read_back_however_they_are_typed() { | ||
| 97 | let code = format(412, "4MZ9XR").unwrap(); | ||
| 98 | assert_eq!(code.len(), 11); | ||
| 99 | assert_eq!(parse(&code), Some((412, "4MZ9XR".into()))); | ||
| 100 | let typed = code | ||
| 101 | .to_lowercase() | ||
| 102 | .replace('-', " ") | ||
| 103 | .replace('1', "l") | ||
| 104 | .replace('0', "o"); | ||
| 105 | assert_eq!(parse(&typed), Some((412, "4MZ9XR".into()))); | ||
| 106 | assert_eq!(parse(&format!(" {code} ")), Some((412, "4MZ9XR".into()))); | ||
| 107 | assert!(format(NAMEPLATES, "4MZ9XR").is_none() && format(1, "4MZ9X").is_none()); | ||
| 108 | assert!(parse("412-violet-otter").is_none() && parse("").is_none()); | ||
| 109 | for _ in 0..100 { | ||
| 110 | let secret = secret().unwrap(); | ||
| 111 | assert_eq!(secret.len(), SECRET); | ||
| 112 | assert_eq!(parse(&format(7, &secret).unwrap()), Some((7, secret))); | ||
| 113 | } | ||
| 114 | } | ||
| 115 | |||
| 116 | /// The check refuses any one symbol mistyped, and any two neighbours swapped, but for 0 | ||
| 117 | /// and Z. | ||
| 118 | #[test] | ||
| 119 | fn the_check_catches_a_symbol_mistyped_or_two_swapped() { | ||
| 120 | let code: Vec<char> = format(999, "Q4MZ9X") | ||
| 121 | .unwrap() | ||
| 122 | .replace('-', "") | ||
| 123 | .chars() | ||
| 124 | .collect(); | ||
| 125 | for at in 0..code.len() { | ||
| 126 | for symbol in ALPHABET.iter().map(|byte| char::from(*byte)) { | ||
| 127 | let mut typed = code.clone(); | ||
| 128 | if typed[at] != symbol && !matches!((typed[at], symbol), ('0', 'Z') | ('Z', '0')) { | ||
| 129 | typed[at] = symbol; | ||
| 130 | assert!( | ||
| 131 | parse(&typed.iter().collect::<String>()).is_none(), | ||
| 132 | "{typed:?}" | ||
| 133 | ); | ||
| 134 | } | ||
| 135 | } | ||
| 136 | } | ||
| 137 | for at in 0..code.len() - 1 { | ||
| 138 | let mut typed = code.clone(); | ||
| 139 | typed.swap(at, at + 1); | ||
| 140 | if typed != code { | ||
| 141 | assert!( | ||
| 142 | parse(&typed.iter().collect::<String>()).is_none(), | ||
| 143 | "{typed:?}" | ||
| 144 | ); | ||
| 145 | } | ||
| 146 | } | ||
| 147 | } | ||
| 148 | } | ||
crates/relay/src/lib.rs+2| ... | @@ -12,7 +12,9 @@ | ... | @@ -12,7 +12,9 @@ |
| 12 | //! its opening `met`; a peer that `failed`, left first, or stayed silent too long counts a | 12 | //! its opening `met`; a peer that `failed`, left first, or stayed silent too long counts a |
| 13 | //! wrong code against its address and the code. See `resources/live-share.md`. | 13 | //! wrong code against its address and the code. See `resources/live-share.md`. |
| 14 | 14 | ||
| 15 | pub mod code; | ||
| 15 | pub mod server; | 16 | pub mod server; |
| 17 | pub mod site; | ||
| 16 | pub mod ws; | 18 | pub mod ws; |
| 17 | 19 | ||
| 18 | use std::{fmt, str::FromStr}; | 20 | use std::{fmt, str::FromStr}; |
crates/relay/src/main.rs+3-3| ... | @@ -7,9 +7,9 @@ const USAGE: &str = "\ | ... | @@ -7,9 +7,9 @@ const USAGE: &str = "\ |
| 7 | usage: snowbound-relay [OPTION VALUE]... | 7 | usage: snowbound-relay [OPTION VALUE]... |
| 8 | 8 | ||
| 9 | Each option may also come from the environment as SNOWBOUND_RELAY_<OPTION>, upper case with | 9 | Each option may also come from the environment as SNOWBOUND_RELAY_<OPTION>, upper case with |
| 10 | underscores: SNOWBOUND_RELAY_LISTEN=127.0.0.1:7650. Flags win. | 10 | underscores: SNOWBOUND_RELAY_LISTEN=127.0.0.1:23592. Flags win. |
| 11 | 11 | ||
| 12 | --listen ADDRESS where to listen (127.0.0.1:7650) | 12 | --listen ADDRESS where to listen (127.0.0.1:23592) |
| 13 | --trust-forwarded true|false count peers by X-Forwarded-For, behind a proxy (false) | 13 | --trust-forwarded true|false count peers by X-Forwarded-For, behind a proxy (false) |
| 14 | --max-connections N (256) | 14 | --max-connections N (256) |
| 15 | --max-connections-per-address N per IPv4 address or IPv6 /64 (16) | 15 | --max-connections-per-address N per IPv4 address or IPv6 /64 (16) |
| ... | @@ -27,7 +27,7 @@ underscores: SNOWBOUND_RELAY_LISTEN=127.0.0.1:7650. Flags win. | ... | @@ -27,7 +27,7 @@ underscores: SNOWBOUND_RELAY_LISTEN=127.0.0.1:7650. Flags win. |
| 27 | "; | 27 | "; |
| 28 | 28 | ||
| 29 | fn main() -> ExitCode { | 29 | fn main() -> ExitCode { |
| 30 | let mut listen = String::from("127.0.0.1:7650"); | 30 | let mut listen = String::from("127.0.0.1:23592"); |
| 31 | let mut config = Config::default(); | 31 | let mut config = Config::default(); |
| 32 | let from_environment = std::env::vars().filter_map(|(key, value)| { | 32 | let from_environment = std::env::vars().filter_map(|(key, value)| { |
| 33 | let option = key.strip_prefix("SNOWBOUND_RELAY_")?; | 33 | let option = key.strip_prefix("SNOWBOUND_RELAY_")?; |
crates/relay/src/site.rs created+161| ... | @@ -0,0 +1,161 @@ | ||
| 1 | //! `snowbound-site`, snowbound.paperclover.net: the hosted web build's files from a folder, | ||
| 2 | //! and for a path that is a Live Share code (`/7KQ-4MZ-9XR`) a page that opens it in | ||
| 3 | //! Snowbound, or in the web build where `Site::web` says it joins. A build's module and | ||
| 4 | //! JavaScript sit in `b/<hash>/`, named by their contents, so they are kept for good; | ||
| 5 | //! `index.html`, which names them, is checked on every load. GET and HEAD only; a proxy in | ||
| 6 | //! front terminates TLS. | ||
| 7 | |||
| 8 | use crate::{code, ws}; | ||
| 9 | use std::{ | ||
| 10 | io::{self, BufReader, Write}, | ||
| 11 | net::{TcpListener, TcpStream}, | ||
| 12 | path::{Component, Path, PathBuf}, | ||
| 13 | sync::Arc, | ||
| 14 | thread, | ||
| 15 | time::Duration, | ||
| 16 | }; | ||
| 17 | |||
| 18 | /// What the site serves. | ||
| 19 | #[derive(Clone, Debug)] | ||
| 20 | pub struct Site { | ||
| 21 | /// The web build's folder; `/` is its `index.html`. | ||
| 22 | pub root: PathBuf, | ||
| 23 | /// Where Open in Web goes, the code appended (`https://snowbound.paperclover.net/?join=`); | ||
| 24 | /// none offers only Snowbound. | ||
| 25 | pub web: Option<String>, | ||
| 26 | } | ||
| 27 | |||
| 28 | /// Serves `site` on `listener` until it fails. | ||
| 29 | pub fn serve(listener: TcpListener, site: Site) -> io::Result<()> { | ||
| 30 | let site = Arc::new(site); | ||
| 31 | for stream in listener.incoming() { | ||
| 32 | let Ok(stream) = stream else { continue }; | ||
| 33 | let site = Arc::clone(&site); | ||
| 34 | thread::spawn(move || { | ||
| 35 | let _ = answer(&site, stream); | ||
| 36 | }); | ||
| 37 | } | ||
| 38 | Ok(()) | ||
| 39 | } | ||
| 40 | |||
| 41 | fn answer(site: &Site, stream: TcpStream) -> io::Result<()> { | ||
| 42 | stream.set_read_timeout(Some(Duration::from_secs(10)))?; | ||
| 43 | stream.set_write_timeout(Some(Duration::from_secs(30)))?; | ||
| 44 | let head = ws::head(&mut BufReader::new(&stream))?; | ||
| 45 | let mut words = head.split(' '); | ||
| 46 | let (method, target) = ( | ||
| 47 | words.next().unwrap_or_default(), | ||
| 48 | words.next().unwrap_or("/"), | ||
| 49 | ); | ||
| 50 | let path = target.split(['?', '#']).next().unwrap_or("/"); | ||
| 51 | let (status, kind, body) = if !matches!(method, "GET" | "HEAD") { | ||
| 52 | ( | ||
| 53 | "405 Method Not Allowed", | ||
| 54 | "text/plain", | ||
| 55 | b"GET only\n".to_vec(), | ||
| 56 | ) | ||
| 57 | } else if let Some((number, secret)) = code::parse(path.trim_matches('/')) { | ||
| 58 | let shown = code::format(number, &secret).unwrap_or_default(); | ||
| 59 | ( | ||
| 60 | "200 OK", | ||
| 61 | "text/html; charset=utf-8", | ||
| 62 | landing(&shown, site).into_bytes(), | ||
| 63 | ) | ||
| 64 | } else { | ||
| 65 | match file(&site.root, path) { | ||
| 66 | Some(file) => match std::fs::read(&file) { | ||
| 67 | Ok(bytes) => ("200 OK", content_type(&file), bytes), | ||
| 68 | Err(_) => ("404 Not Found", "text/plain", b"Not found\n".to_vec()), | ||
| 69 | }, | ||
| 70 | None => ("404 Not Found", "text/plain", b"Not found\n".to_vec()), | ||
| 71 | } | ||
| 72 | }; | ||
| 73 | let cache = if !status.starts_with("200") || kind.starts_with("text/html") { | ||
| 74 | "no-cache" | ||
| 75 | } else if path.starts_with("/b/") { | ||
| 76 | "public, max-age=31536000, immutable" | ||
| 77 | } else { | ||
| 78 | // Fonts and dictionaries. | ||
| 79 | "public, max-age=86400" | ||
| 80 | }; | ||
| 81 | let mut writer = &stream; | ||
| 82 | write!( | ||
| 83 | writer, | ||
| 84 | "HTTP/1.1 {status}\r\nContent-Type: {kind}\r\nContent-Length: {}\r\nCache-Control: {cache}\r\n\ | ||
| 85 | X-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n", | ||
| 86 | body.len() | ||
| 87 | )?; | ||
| 88 | if method != "HEAD" { | ||
| 89 | writer.write_all(&body)?; | ||
| 90 | } | ||
| 91 | Ok(()) | ||
| 92 | } | ||
| 93 | |||
| 94 | /// The file `path` names in `root`, `index.html` for a folder; none outside it. | ||
| 95 | fn file(root: &Path, path: &str) -> Option<PathBuf> { | ||
| 96 | let relative = Path::new(path.trim_start_matches('/')); | ||
| 97 | if relative | ||
| 98 | .components() | ||
| 99 | .any(|part| !matches!(part, Component::Normal(_))) | ||
| 100 | || path.contains(['%', '\\', '\0']) | ||
| 101 | { | ||
| 102 | return None; | ||
| 103 | } | ||
| 104 | let mut file = root.join(relative); | ||
| 105 | if file.is_dir() { | ||
| 106 | file = file.join("index.html"); | ||
| 107 | } | ||
| 108 | file.is_file().then_some(file) | ||
| 109 | } | ||
| 110 | |||
| 111 | fn content_type(file: &Path) -> &'static str { | ||
| 112 | match file.extension().and_then(|extension| extension.to_str()) { | ||
| 113 | Some("html") => "text/html; charset=utf-8", | ||
| 114 | Some("js") => "text/javascript; charset=utf-8", | ||
| 115 | Some("wasm") => "application/wasm", | ||
| 116 | Some("css") => "text/css; charset=utf-8", | ||
| 117 | Some("json") => "application/json", | ||
| 118 | Some("svg") => "image/svg+xml", | ||
| 119 | Some("png") => "image/png", | ||
| 120 | Some("ico") => "image/x-icon", | ||
| 121 | Some("ttf") => "font/ttf", | ||
| 122 | Some("otf") => "font/otf", | ||
| 123 | Some("gz") => "application/gzip", | ||
| 124 | Some("txt") => "text/plain; charset=utf-8", | ||
| 125 | _ => "application/octet-stream", | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | /// The page a shared notebook's link opens: the code, and the ways to open it. | ||
| 130 | fn landing(shown: &str, site: &Site) -> String { | ||
| 131 | let web = site.web.as_ref().map_or_else(String::new, |web| { | ||
| 132 | format!(r#"<a class="other" href="{web}{shown}">Open in Web</a>"#) | ||
| 133 | }); | ||
| 134 | format!( | ||
| 135 | r#"<!doctype html> | ||
| 136 | <html lang="en"> | ||
| 137 | <meta charset="utf-8"> | ||
| 138 | <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 139 | <title>Shared notebook {shown} · Snowbound</title> | ||
| 140 | <style> | ||
| 141 | :root {{ color-scheme: light dark; font-family: system-ui, sans-serif; }} | ||
| 142 | body {{ margin: 0; min-height: 100vh; display: grid; place-items: center; background: Canvas; color: CanvasText; }} | ||
| 143 | main {{ max-width: 26rem; padding: 2rem; text-align: center; }} | ||
| 144 | h1 {{ font-size: 1.3rem; font-weight: 600; margin: 0 0 .5rem; }} | ||
| 145 | p {{ margin: .5rem 0 1.5rem; opacity: .75; }} | ||
| 146 | code {{ display: block; font: 600 2rem ui-monospace, monospace; letter-spacing: .08em; margin: 1rem 0 1.5rem; }} | ||
| 147 | a {{ display: inline-block; margin: .25rem; padding: .6rem 1.1rem; border-radius: .5rem; text-decoration: none; font-weight: 600; }} | ||
| 148 | .open {{ background: #3768c7; color: white; }} | ||
| 149 | .other {{ border: 1px solid #3768c788; color: inherit; }} | ||
| 150 | </style> | ||
| 151 | <main> | ||
| 152 | <h1>Someone shared a notebook with you</h1> | ||
| 153 | <code>{shown}</code> | ||
| 154 | <a class="open" href="snowbound://join/{shown}">Open in Snowbound</a> | ||
| 155 | {web} | ||
| 156 | <p>If Snowbound doesn’t open, open it yourself, choose Open Shared Notebook, and enter this code.</p> | ||
| 157 | </main> | ||
| 158 | </html> | ||
| 159 | "# | ||
| 160 | ) | ||
| 161 | } | ||
crates/relay/src/site_main.rs created+72| ... | @@ -0,0 +1,72 @@ | ||
| 1 | //! `snowbound-site`: see `crates/relay/README.md`. | ||
| 2 | |||
| 3 | use relay::site::{self, Site}; | ||
| 4 | use std::{net::TcpListener, path::PathBuf, process::ExitCode}; | ||
| 5 | |||
| 6 | const USAGE: &str = "\ | ||
| 7 | usage: snowbound-site [OPTION VALUE]... | ||
| 8 | |||
| 9 | Each option may also come from the environment as SNOWBOUND_SITE_<OPTION>, upper case: | ||
| 10 | SNOWBOUND_SITE_ROOT=/srv/snowbound/web. Flags win. | ||
| 11 | |||
| 12 | --listen ADDRESS where to listen (127.0.0.1:23593) | ||
| 13 | --root FOLDER the web build's folder (web) | ||
| 14 | --web URL where Open in Web goes, the code appended, as | ||
| 15 | https://snowbound.paperclover.net/?join= (none: no Open in Web) | ||
| 16 | "; | ||
| 17 | |||
| 18 | fn main() -> ExitCode { | ||
| 19 | let mut listen = String::from("127.0.0.1:23593"); | ||
| 20 | let mut site = Site { | ||
| 21 | root: PathBuf::from("web"), | ||
| 22 | web: None, | ||
| 23 | }; | ||
| 24 | let from_environment = std::env::vars().filter_map(|(key, value)| { | ||
| 25 | Some((key.strip_prefix("SNOWBOUND_SITE_")?.to_lowercase(), value)) | ||
| 26 | }); | ||
| 27 | let mut arguments = std::env::args().skip(1); | ||
| 28 | let mut from_flags = Vec::new(); | ||
| 29 | while let Some(flag) = arguments.next() { | ||
| 30 | if flag == "--help" { | ||
| 31 | print!("{USAGE}"); | ||
| 32 | return ExitCode::SUCCESS; | ||
| 33 | } | ||
| 34 | let (Some(option), Some(value)) = (flag.strip_prefix("--"), arguments.next()) else { | ||
| 35 | eprint!("{USAGE}"); | ||
| 36 | return ExitCode::from(2); | ||
| 37 | }; | ||
| 38 | from_flags.push((option.to_owned(), value)); | ||
| 39 | } | ||
| 40 | for (option, value) in from_environment.chain(from_flags) { | ||
| 41 | match option.as_str() { | ||
| 42 | "listen" => listen = value, | ||
| 43 | "root" => site.root = PathBuf::from(value), | ||
| 44 | "web" => site.web = Some(value).filter(|web| !web.is_empty()), | ||
| 45 | _ => { | ||
| 46 | eprintln!("--{option}: no such option\n\n{USAGE}"); | ||
| 47 | return ExitCode::from(2); | ||
| 48 | } | ||
| 49 | } | ||
| 50 | } | ||
| 51 | let listener = match TcpListener::bind(&listen) { | ||
| 52 | Ok(listener) => listener, | ||
| 53 | Err(error) => { | ||
| 54 | eprintln!("Cannot listen on {listen}: {error}"); | ||
| 55 | return ExitCode::FAILURE; | ||
| 56 | } | ||
| 57 | }; | ||
| 58 | if let Ok(address) = listener.local_addr() { | ||
| 59 | println!( | ||
| 60 | "snowbound-site {} listening on {address}, serving {}", | ||
| 61 | env!("CARGO_PKG_VERSION"), | ||
| 62 | site.root.display() | ||
| 63 | ); | ||
| 64 | } | ||
| 65 | match site::serve(listener, site) { | ||
| 66 | Ok(()) => ExitCode::SUCCESS, | ||
| 67 | Err(error) => { | ||
| 68 | eprintln!("{error}"); | ||
| 69 | ExitCode::FAILURE | ||
| 70 | } | ||
| 71 | } | ||
| 72 | } | ||
crates/relay/tests/site.rs created+107| ... | @@ -0,0 +1,107 @@ | ||
| 1 | //! The site as shipped: a code's page, the web build's files, nothing outside them. | ||
| 2 | |||
| 3 | use std::{ | ||
| 4 | io::{BufRead, BufReader, Read, Write}, | ||
| 5 | net::{SocketAddr, TcpStream}, | ||
| 6 | process::{Child, Command, Stdio}, | ||
| 7 | }; | ||
| 8 | |||
| 9 | struct Site { | ||
| 10 | child: Child, | ||
| 11 | address: SocketAddr, | ||
| 12 | } | ||
| 13 | |||
| 14 | impl Site { | ||
| 15 | fn start(root: &std::path::Path, options: &[&str]) -> Site { | ||
| 16 | let mut child = Command::new(env!("CARGO_BIN_EXE_snowbound-site")) | ||
| 17 | .args(["--listen", "127.0.0.1:0", "--root"]) | ||
| 18 | .arg(root) | ||
| 19 | .args(options) | ||
| 20 | .env_clear() | ||
| 21 | .stdout(Stdio::piped()) | ||
| 22 | .spawn() | ||
| 23 | .unwrap(); | ||
| 24 | let mut line = String::new(); | ||
| 25 | BufReader::new(child.stdout.take().unwrap()) | ||
| 26 | .read_line(&mut line) | ||
| 27 | .unwrap(); | ||
| 28 | let address = line | ||
| 29 | .split(' ') | ||
| 30 | .nth(4) | ||
| 31 | .unwrap() | ||
| 32 | .trim_end_matches(',') | ||
| 33 | .parse() | ||
| 34 | .unwrap(); | ||
| 35 | Site { child, address } | ||
| 36 | } | ||
| 37 | |||
| 38 | fn get(&self, path: &str) -> String { | ||
| 39 | let mut stream = TcpStream::connect(self.address).unwrap(); | ||
| 40 | write!(stream, "GET {path} HTTP/1.1\r\nHost: site\r\n\r\n").unwrap(); | ||
| 41 | let mut response = String::new(); | ||
| 42 | stream.read_to_string(&mut response).unwrap(); | ||
| 43 | response | ||
| 44 | } | ||
| 45 | } | ||
| 46 | |||
| 47 | impl Drop for Site { | ||
| 48 | fn drop(&mut self) { | ||
| 49 | let _ = self.child.kill(); | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 53 | #[test] | ||
| 54 | fn a_code_gets_its_page_and_the_web_build_its_files() { | ||
| 55 | let folder = tempfile::tempdir().unwrap(); | ||
| 56 | let root = folder.path().join("web"); | ||
| 57 | std::fs::create_dir_all(root.join("b/0f3a")).unwrap(); | ||
| 58 | std::fs::create_dir_all(root.join("fonts")).unwrap(); | ||
| 59 | std::fs::write(root.join("index.html"), "<p>the app</p>").unwrap(); | ||
| 60 | std::fs::write(root.join("b/0f3a/snowbound_bg.wasm"), b"\0asm").unwrap(); | ||
| 61 | std::fs::write(root.join("fonts/Face.ttf"), b"font").unwrap(); | ||
| 62 | std::fs::write(folder.path().join("secret.txt"), "secret").unwrap(); | ||
| 63 | let code = relay::code::format(412, "4MZ9XR").unwrap(); | ||
| 64 | let site = Site::start(&root, &[]); | ||
| 65 | let page = site.get(&format!("/{code}")); | ||
| 66 | assert!(page.starts_with("HTTP/1.1 200"), "{page}"); | ||
| 67 | assert!(page.contains(&format!("snowbound://join/{code}")), "{page}"); | ||
| 68 | assert!(!page.contains("Open in Web"), "no web build joins yet"); | ||
| 69 | // Typed loosely, the code's page names it as shown. | ||
| 70 | let loose = site.get(&format!("/{}", code.to_lowercase().replace('-', ""))); | ||
| 71 | assert!(loose.contains(&format!("<code>{code}</code>")), "{loose}"); | ||
| 72 | // The page that names a build is checked every load; a build is kept for good. | ||
| 73 | let index = site.get("/"); | ||
| 74 | assert!( | ||
| 75 | index.ends_with("<p>the app</p>") && index.contains("no-cache"), | ||
| 76 | "{index}" | ||
| 77 | ); | ||
| 78 | let wasm = site.get("/b/0f3a/snowbound_bg.wasm"); | ||
| 79 | assert!(wasm.contains("Content-Type: application/wasm"), "{wasm}"); | ||
| 80 | assert!(wasm.contains("max-age=31536000, immutable"), "{wasm}"); | ||
| 81 | assert!(site.get("/fonts/Face.ttf").contains("max-age=86400")); | ||
| 82 | let missing = site.get("/b/9999/snowbound_bg.wasm"); | ||
| 83 | assert!( | ||
| 84 | missing.starts_with("HTTP/1.1 404") && missing.contains("no-cache"), | ||
| 85 | "{missing}" | ||
| 86 | ); | ||
| 87 | for outside in [ | ||
| 88 | "/../secret.txt", | ||
| 89 | "/%2e%2e/secret.txt", | ||
| 90 | "/b/../../secret.txt", | ||
| 91 | ] { | ||
| 92 | assert!(site.get(outside).starts_with("HTTP/1.1 404"), "{outside}"); | ||
| 93 | } | ||
| 94 | // A mistyped code is no code: it is looked for as a file. | ||
| 95 | let typo = format!("/8{}", &code[1..]); | ||
| 96 | assert!(site.get(&typo).starts_with("HTTP/1.1 404")); | ||
| 97 | drop(site); | ||
| 98 | let site = Site::start( | ||
| 99 | &root, | ||
| 100 | &["--web", "https://snowbound.paperclover.net/?join="], | ||
| 101 | ); | ||
| 102 | let page = site.get(&format!("/{code}")); | ||
| 103 | assert!( | ||
| 104 | page.contains(&format!("https://snowbound.paperclover.net/?join={code}")), | ||
| 105 | "{page}" | ||
| 106 | ); | ||
| 107 | } | ||
crates/snowbound/linux/snowbound.desktop+2-2| ... | @@ -3,10 +3,10 @@ Type=Application | ... | @@ -3,10 +3,10 @@ Type=Application |
| 3 | Name=Snowbound | 3 | Name=Snowbound |
| 4 | GenericName=Note Taking | 4 | GenericName=Note Taking |
| 5 | Comment=Type and draw notes in OneNote notebooks | 5 | Comment=Type and draw notes in OneNote notebooks |
| 6 | Exec=snowbound %F | 6 | Exec=snowbound %U |
| 7 | Icon=net.paperclover.snowbound | 7 | Icon=net.paperclover.snowbound |
| 8 | Terminal=false | 8 | Terminal=false |
| 9 | Categories=Office; | 9 | Categories=Office; |
| 10 | MimeType=application/onenote; | 10 | MimeType=application/onenote;x-scheme-handler/snowbound; |
| 11 | StartupNotify=true | 11 | StartupNotify=true |
| 12 | StartupWMClass=net.paperclover.snowbound | 12 | StartupWMClass=net.paperclover.snowbound |
crates/snowbound/src/desktop_linux.rs+1-1| ... | @@ -567,7 +567,7 @@ mod tests { | ... | @@ -567,7 +567,7 @@ mod tests { |
| 567 | #[test] | 567 | #[test] |
| 568 | fn only_the_hidden_entry_names_an_owner() { | 568 | fn only_the_hidden_entry_names_an_owner() { |
| 569 | let installed = entry_text(Path::new("/bin/snowbound"), APP_ID); | 569 | let installed = entry_text(Path::new("/bin/snowbound"), APP_ID); |
| 570 | assert!(installed.contains("\nExec=\"/bin/snowbound\" %F\n")); | 570 | assert!(installed.contains("\nExec=\"/bin/snowbound\" %U\n")); |
| 571 | assert!(installed.contains(&format!("\nIcon={APP_ID}\n"))); | 571 | assert!(installed.contains(&format!("\nIcon={APP_ID}\n"))); |
| 572 | assert_eq!(portable_owner(&installed), None); | 572 | assert_eq!(portable_owner(&installed), None); |
| 573 | let hidden = installed + &format!("NoDisplay=true\n{PORTABLE}=42\n"); | 573 | let hidden = installed + &format!("NoDisplay=true\n{PORTABLE}=42\n"); |
crates/snowbound/src/library.rs+1-1| ... | @@ -215,7 +215,7 @@ fn bonjour_instance(server: &str) -> Option<String> { | ... | @@ -215,7 +215,7 @@ fn bonjour_instance(server: &str) -> Option<String> { |
| 215 | } | 215 | } |
| 216 | 216 | ||
| 217 | /// `text` with `%XX` escapes decoded. | 217 | /// `text` with `%XX` escapes decoded. |
| 218 | fn decode(text: &str) -> String { | 218 | pub fn decode(text: &str) -> String { |
| 219 | let bytes = text.as_bytes(); | 219 | let bytes = text.as_bytes(); |
| 220 | let mut decoded = Vec::with_capacity(bytes.len()); | 220 | let mut decoded = Vec::with_capacity(bytes.len()); |
| 221 | let mut at = 0; | 221 | let mut at = 0; |
crates/snowbound/src/live.rs+36| ... | @@ -34,6 +34,25 @@ pub(crate) const DEFAULT_RELAY: &str = "wss://relay.snowbound.paperclover.net"; | ... | @@ -34,6 +34,25 @@ pub(crate) const DEFAULT_RELAY: &str = "wss://relay.snowbound.paperclover.net"; |
| 34 | /// How long opening a notebook joined for the first time waits for the computer sharing it. | 34 | /// How long opening a notebook joined for the first time waits for the computer sharing it. |
| 35 | const FIRST_LISTING: std::time::Duration = std::time::Duration::from_secs(15); | 35 | const FIRST_LISTING: std::time::Duration = std::time::Duration::from_secs(15); |
| 36 | 36 | ||
| 37 | /// Where a code's link goes: the page that opens it (`crates/relay`'s `snowbound-site`). | ||
| 38 | const SITE: &str = "https://snowbound.paperclover.net/"; | ||
| 39 | /// What opens Snowbound at a code, as the site's page and the desktop pass it. | ||
| 40 | const SCHEME: &str = "snowbound://join/"; | ||
| 41 | |||
| 42 | /// The link that opens `code`. | ||
| 43 | pub(crate) fn link(code: &str) -> String { | ||
| 44 | format!("{SITE}{code}") | ||
| 45 | } | ||
| 46 | |||
| 47 | /// The code a link names, `snowbound://join/<code>` or the site's page for it. | ||
| 48 | pub(crate) fn linked(text: &str) -> Option<String> { | ||
| 49 | let text = text.trim(); | ||
| 50 | let code = text | ||
| 51 | .strip_prefix(SCHEME) | ||
| 52 | .or_else(|| text.strip_prefix(SITE))?; | ||
| 53 | notebook::live::share::code(code.trim_end_matches('/')) | ||
| 54 | } | ||
| 55 | |||
| 37 | /// Who this computer is to others and where it meets them, as Options last said. | 56 | /// Who this computer is to others and where it meets them, as Options last said. |
| 38 | struct Me { | 57 | struct Me { |
| 39 | name: String, | 58 | name: String, |
| ... | @@ -1024,3 +1043,20 @@ fn system_picture() -> Option<Vec<u8>> { | ... | @@ -1024,3 +1043,20 @@ fn system_picture() -> Option<Vec<u8>> { |
| 1024 | fn system_picture() -> Option<Vec<u8>> { | 1043 | fn system_picture() -> Option<Vec<u8>> { |
| 1025 | None | 1044 | None |
| 1026 | } | 1045 | } |
| 1046 | |||
| 1047 | #[cfg(test)] | ||
| 1048 | mod tests { | ||
| 1049 | use super::*; | ||
| 1050 | |||
| 1051 | /// A link names its code however it came, and anything else names none. | ||
| 1052 | #[test] | ||
| 1053 | fn links_name_their_codes() { | ||
| 1054 | let code = notebook::live::code::format(412, "4MZ9XR").unwrap(); | ||
| 1055 | assert_eq!(linked(&link(&code)), Some(code.clone())); | ||
| 1056 | let typed = format!("snowbound://join/{}/", code.to_lowercase()); | ||
| 1057 | assert_eq!(linked(&typed), Some(code.clone())); | ||
| 1058 | assert_eq!(linked(&code), None, "a bare code is not a link"); | ||
| 1059 | assert_eq!(linked("https://example.com/7KQ-4MZ-9XR"), None); | ||
| 1060 | assert_eq!(linked("snowbound://join/not-a-code"), None); | ||
| 1061 | } | ||
| 1062 | } |
crates/snowbound/src/macos.rs+20| ... | @@ -1547,6 +1547,11 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLo | ... | @@ -1547,6 +1547,11 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLo |
| 1547 | sel!(application:openFiles:), | 1547 | sel!(application:openFiles:), |
| 1548 | open_files as unsafe extern "C" fn(_, _, _, _), | 1548 | open_files as unsafe extern "C" fn(_, _, _, _), |
| 1549 | ); | 1549 | ); |
| 1550 | // And -application:openURLs:, which 10.13 and later send for a snowbound:// link. | ||
| 1551 | class.add_method( | ||
| 1552 | sel!(application:openURLs:), | ||
| 1553 | open_urls as unsafe extern "C" fn(_, _, _, _), | ||
| 1554 | ); | ||
| 1550 | let class = class.register(); | 1555 | let class = class.register(); |
| 1551 | // The subclass adds no ivars, so the existing allocation remains valid. | 1556 | // The subclass adds no ivars, so the existing allocation remains valid. |
| 1552 | AnyObject::set_class(&delegate, class); | 1557 | AnyObject::set_class(&delegate, class); |
| ... | @@ -1568,6 +1573,21 @@ unsafe extern "C" fn open_files( | ... | @@ -1568,6 +1573,21 @@ unsafe extern "C" fn open_files( |
| 1568 | let _: () = unsafe { msg_send![app, replyToOpenOrPrint: 0usize] }; | 1573 | let _: () = unsafe { msg_send![app, replyToOpenOrPrint: 0usize] }; |
| 1569 | } | 1574 | } |
| 1570 | 1575 | ||
| 1576 | unsafe extern "C" fn open_urls( | ||
| 1577 | _: &AnyObject, | ||
| 1578 | _: Sel, | ||
| 1579 | _: &NSApplication, | ||
| 1580 | urls: &objc2_foundation::NSArray<objc2_foundation::NSURL>, | ||
| 1581 | ) { | ||
| 1582 | if let Some(proxy) = PROXY.get() { | ||
| 1583 | let links = (urls.iter()) | ||
| 1584 | .filter_map(|url| unsafe { url.absoluteString() }) | ||
| 1585 | .map(|url| url.to_string().into()) | ||
| 1586 | .collect(); | ||
| 1587 | let _ = proxy.send_event(crate::UserEvent::Open(links)); | ||
| 1588 | } | ||
| 1589 | } | ||
| 1590 | |||
| 1571 | /// Replaces Winit's application menu with the menu bar, whose items the table validates. | 1591 | /// Replaces Winit's application menu with the menu bar, whose items the table validates. |
| 1572 | pub fn install_menu() { | 1592 | pub fn install_menu() { |
| 1573 | let mtm = MainThreadMarker::new().expect("Menus belong to the main thread"); | 1593 | let mtm = MainThreadMarker::new().expect("Menus belong to the main thread"); |
crates/snowbound/src/main.rs+6| ... | @@ -6769,6 +6769,12 @@ fn launch() -> Result<(), Box<dyn Error>> { | ... | @@ -6769,6 +6769,12 @@ fn launch() -> Result<(), Box<dyn Error>> { |
| 6769 | .to_str() | 6769 | .to_str() |
| 6770 | .ok_or("The page title must be valid Unicode.")?, | 6770 | .ok_or("The page title must be valid Unicode.")?, |
| 6771 | )?); | 6771 | )?); |
| 6772 | } else if arg.to_string_lossy().starts_with("snowbound:") { | ||
| 6773 | // A Live Share link the desktop opens with Snowbound. | ||
| 6774 | opening.push(PathBuf::from(arg)); | ||
| 6775 | } else if let Some(file) = arg.to_str().and_then(|arg| arg.strip_prefix("file://")) { | ||
| 6776 | // A file as a desktop entry's %U passes it. | ||
| 6777 | opening.push(PathBuf::from(library::decode(file))); | ||
| 6772 | } else if library::locate(Path::new(&arg)) != library::Located::Nothing { | 6778 | } else if library::locate(Path::new(&arg)) != library::Located::Nothing { |
| 6773 | // A file the desktop opens with Snowbound, as a double-clicked section. | 6779 | // A file the desktop opens with Snowbound, as a double-clicked section. |
| 6774 | opening.push(std::path::absolute(arg)?); | 6780 | opening.push(std::path::absolute(arg)?); |
crates/snowbound/src/share.rs+36-11| ... | @@ -41,11 +41,14 @@ pub(crate) struct ShareDialog { | ... | @@ -41,11 +41,14 @@ pub(crate) struct ShareDialog { |
| 41 | library: Arc<Library>, | 41 | library: Arc<Library>, |
| 42 | protect: bool, | 42 | protect: bool, |
| 43 | password: String, | 43 | password: String, |
| 44 | copied: bool, | 44 | /// What was copied last: the code, or the link. |
| 45 | copied: Option<&'static str>, | ||
| 45 | } | 46 | } |
| 46 | 47 | ||
| 47 | /// Open Shared Notebook while it is open. | 48 | /// Open Shared Notebook while it is open. |
| 48 | pub(crate) struct JoinDialog { | 49 | pub(crate) struct JoinDialog { |
| 50 | /// Join without waiting for Open, as a link asks. | ||
| 51 | open: bool, | ||
| 49 | code: String, | 52 | code: String, |
| 50 | password: String, | 53 | password: String, |
| 51 | status: Status, | 54 | status: Status, |
| ... | @@ -234,14 +237,24 @@ impl State { | ... | @@ -234,14 +237,24 @@ impl State { |
| 234 | library, | 237 | library, |
| 235 | protect: false, | 238 | protect: false, |
| 236 | password: String::new(), | 239 | password: String::new(), |
| 237 | copied: false, | 240 | copied: None, |
| 238 | }); | 241 | }); |
| 239 | self.ui.open_popup(share_id()); | 242 | self.ui.open_popup(share_id()); |
| 240 | } | 243 | } |
| 241 | 244 | ||
| 245 | /// Opens the shared notebook a link names: Open Shared Notebook, joining at once. | ||
| 246 | pub(crate) fn join_link(&mut self, code: String) { | ||
| 247 | self.open_shared(); | ||
| 248 | if let Some(dialog) = &mut self.peers.join { | ||
| 249 | dialog.code = code; | ||
| 250 | dialog.open = true; | ||
| 251 | } | ||
| 252 | } | ||
| 253 | |||
| 242 | /// Opens Open Shared Notebook. | 254 | /// Opens Open Shared Notebook. |
| 243 | pub(crate) fn open_shared(&mut self) { | 255 | pub(crate) fn open_shared(&mut self) { |
| 244 | self.peers.join = Some(JoinDialog { | 256 | self.peers.join = Some(JoinDialog { |
| 257 | open: false, | ||
| 245 | code: String::new(), | 258 | code: String::new(), |
| 246 | password: String::new(), | 259 | password: String::new(), |
| 247 | status: Status::Idle, | 260 | status: Status::Idle, |
| ... | @@ -304,9 +317,19 @@ impl State { | ... | @@ -304,9 +317,19 @@ impl State { |
| 304 | }, | 317 | }, |
| 305 | ); | 318 | ); |
| 306 | if let Some(code) = &code { | 319 | if let Some(code) = &code { |
| 307 | let label = if dialog.copied { "Copied" } else { "Copy" }; | 320 | let link = crate::live::link(code); |
| 308 | if ui::button(ui, "copy", label).clicked { | 321 | for (part, what, label, text) in [ |
| 309 | copy = Some(code.clone()); | 322 | ("copy", "code", "Copy", code.clone()), |
| 323 | ("link", "link", "Copy Link", link), | ||
| 324 | ] { | ||
| 325 | let label = if dialog.copied == Some(what) { | ||
| 326 | "Copied" | ||
| 327 | } else { | ||
| 328 | label | ||
| 329 | }; | ||
| 330 | if ui::button(ui, part, label).clicked { | ||
| 331 | copy = Some((what, text)); | ||
| 332 | } | ||
| 310 | } | 333 | } |
| 311 | } | 334 | } |
| 312 | ui.close(); | 335 | ui.close(); |
| ... | @@ -315,9 +338,10 @@ impl State { | ... | @@ -315,9 +338,10 @@ impl State { |
| 315 | ui, | 338 | ui, |
| 316 | "how", | 339 | "how", |
| 317 | if protected { | 340 | if protected { |
| 318 | "Others open it with Open Shared Notebook, this code and the password." | 341 | "Send the link, or the code for Open Shared Notebook. They also need \ |
| 342 | the password." | ||
| 319 | } else { | 343 | } else { |
| 320 | "Others open it with Open Shared Notebook and this code." | 344 | "Send the link, or the code for Open Shared Notebook." |
| 321 | }, | 345 | }, |
| 322 | true, | 346 | true, |
| 323 | ); | 347 | ); |
| ... | @@ -394,8 +418,8 @@ impl State { | ... | @@ -394,8 +418,8 @@ impl State { |
| 394 | let done = ui::button(ui, "done", "Done").clicked || entered && !offered; | 418 | let done = ui::button(ui, "done", "Done").clicked || entered && !offered; |
| 395 | ui.close(); | 419 | ui.close(); |
| 396 | ui.close(); | 420 | ui.close(); |
| 397 | if let Some(code) = copy { | 421 | if let Some((what, text)) = copy { |
| 398 | dialog.copied = self.clipboard.set_text(code).is_ok(); | 422 | dialog.copied = self.clipboard.set_text(text).is_ok().then_some(what); |
| 399 | } | 423 | } |
| 400 | if stop { | 424 | if stop { |
| 401 | self.stop_sharing(&location); | 425 | self.stop_sharing(&location); |
| ... | @@ -482,7 +506,8 @@ impl State { | ... | @@ -482,7 +506,8 @@ impl State { |
| 482 | } | 506 | } |
| 483 | buttons(ui); | 507 | buttons(ui); |
| 484 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; | 508 | let cancel = ui::button(ui, "cancel", "Cancel").clicked; |
| 485 | let open = ui::button(ui, "open", "Open").clicked || entered; | 509 | let open = |
| 510 | ui::button(ui, "open", "Open").clicked || entered || std::mem::take(&mut dialog.open); | ||
| 486 | ui.close(); | 511 | ui.close(); |
| 487 | ui.close(); | 512 | ui.close(); |
| 488 | if cancel { | 513 | if cancel { |
| ... | @@ -491,7 +516,7 @@ impl State { | ... | @@ -491,7 +516,7 @@ impl State { |
| 491 | return; | 516 | return; |
| 492 | } | 517 | } |
| 493 | if open && !matches!(dialog.status, Status::Waiting) { | 518 | if open && !matches!(dialog.status, Status::Waiting) { |
| 494 | match share::code(&dialog.code) { | 519 | match crate::live::linked(&dialog.code).or_else(|| share::code(&dialog.code)) { |
| 495 | None => dialog.status = Status::Failed(refusal(&Refusal::Malformed)), | 520 | None => dialog.status = Status::Failed(refusal(&Refusal::Malformed)), |
| 496 | Some(code) => { | 521 | Some(code) => { |
| 497 | dialog.status = Status::Waiting; | 522 | dialog.status = Status::Waiting; |
crates/snowbound/src/sidebar.rs+4| ... | @@ -1133,6 +1133,10 @@ impl crate::State { | ... | @@ -1133,6 +1133,10 @@ impl crate::State { |
| 1133 | /// Opens what the open panel chose: a notebook folder, a notebook's table of contents, | 1133 | /// Opens what the open panel chose: a notebook folder, a notebook's table of contents, |
| 1134 | /// or a section, in its notebook where it has one. | 1134 | /// or a section, in its notebook where it has one. |
| 1135 | pub(crate) fn open_path(&mut self, path: &std::path::Path) { | 1135 | pub(crate) fn open_path(&mut self, path: &std::path::Path) { |
| 1136 | #[cfg(feature = "live")] | ||
| 1137 | if let Some(code) = path.to_str().and_then(crate::live::linked) { | ||
| 1138 | return self.join_link(code); | ||
| 1139 | } | ||
| 1136 | match crate::library::locate(path) { | 1140 | match crate::library::locate(path) { |
| 1137 | crate::library::Located::Notebook { root, section } => { | 1141 | crate::library::Located::Notebook { root, section } => { |
| 1138 | self.open_notebook(root.to_string_lossy().into_owned(), section) | 1142 | self.open_notebook(root.to_string_lossy().into_owned(), section) |
crates/snowbound/src/windows.rs+8| ... | @@ -225,6 +225,14 @@ fn offer_to_open() { | ... | @@ -225,6 +225,14 @@ fn offer_to_open() { |
| 225 | for extension in [".one", ".onetoc2", ".onepkg"] { | 225 | for extension in [".one", ".onetoc2", ".onepkg"] { |
| 226 | set(&format!(r"{extension}\OpenWithProgids"), Some(PROG_ID), ""); | 226 | set(&format!(r"{extension}\OpenWithProgids"), Some(PROG_ID), ""); |
| 227 | } | 227 | } |
| 228 | // Live Share's links, snowbound://join/<code>, as snowbound.paperclover.net's pages open. | ||
| 229 | set("snowbound", None, "URL:Snowbound Live Share"); | ||
| 230 | set("snowbound", Some("URL Protocol"), ""); | ||
| 231 | set( | ||
| 232 | r"snowbound\shell\open\command", | ||
| 233 | None, | ||
| 234 | &format!("\"{executable}\" \"%1\""), | ||
| 235 | ); | ||
| 228 | } | 236 | } |
| 229 | 237 | ||
| 230 | /// The executable's icon, and where the row draws the caption buttons, no system frame: | 238 | /// The executable's icon, and where the row draws the caption buttons, no system frame: |
crates/snowbound/tests/replay.rs+2-1| ... | @@ -720,8 +720,9 @@ fn stop_sharing_ends_the_share() { | ... | @@ -720,8 +720,9 @@ fn stop_sharing_ends_the_share() { |
| 720 | let mut steps = vec!["modifiers command shift", "key p", "modifiers", "settle"]; | 720 | let mut steps = vec!["modifiers command shift", "key p", "modifiers", "settle"]; |
| 721 | steps.extend(["type Live Share", "settle", "key Enter", "settle"]); | 721 | steps.extend(["type Live Share", "settle", "key Enter", "settle"]); |
| 722 | steps.extend(["key Enter", "wait 1500", "accessibility shared"]); | 722 | steps.extend(["key Enter", "wait 1500", "accessibility shared"]); |
| 723 | // Copy, then Stop Sharing. | 723 | // Copy, Copy Link, then Stop Sharing. |
| 724 | steps.extend([ | 724 | steps.extend([ |
| 725 | "key Tab", | ||
| 725 | "key Tab", | 726 | "key Tab", |
| 726 | "key Tab", | 727 | "key Tab", |
| 727 | "key Enter", | 728 | "key Enter", |
tools/canvas/build_macos.py+2| ... | @@ -157,6 +157,8 @@ if build: | ... | @@ -157,6 +157,8 @@ if build: |
| 157 | 'NSUbiquitousContainerSupportedFolderLevels': 'Any', | 157 | 'NSUbiquitousContainerSupportedFolderLevels': 'Any', |
| 158 | }}, | 158 | }}, |
| 159 | 'LSMinimumSystemVersion': minimum, | 159 | 'LSMinimumSystemVersion': minimum, |
| 160 | # Live Share's links: snowbound://join/<code>, as snowbound.paperclover.net's pages open. | ||
| 161 | 'CFBundleURLTypes': [{'CFBundleURLName': 'Snowbound Live Share', 'CFBundleURLSchemes': ['snowbound']}], | ||
| 160 | 'CFBundleDocumentTypes': [{ | 162 | 'CFBundleDocumentTypes': [{ |
| 161 | 'CFBundleTypeName': 'OneNote Notebook', | 163 | 'CFBundleTypeName': 'OneNote Notebook', |
| 162 | 'CFBundleTypeRole': 'Editor', | 164 | 'CFBundleTypeRole': 'Editor', |
tools/release_relay.py+10-6| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | """Builds snowbound-relay, the Live Share relay (crates/relay), as one static executable for each | 2 | """Builds snowbound-relay, the Live Share relay, and snowbound-site, which serves the web build and |
| 3 | Linux architecture a server runs, with the files that deploy it, into target/relay/ by default. | 3 | the codes' pages (crates/relay), as static executables for each Linux architecture a server |
| 4 | runs, with the files that deploy them, into target/relay/ by default. | ||
| 4 | It ships on its own, outside the app's builds and latest.json; see crates/relay/README.md.""" | 5 | It ships on its own, outside the app's builds and latest.json; see crates/relay/README.md.""" |
| 5 | import argparse | 6 | import argparse |
| 6 | import hashlib | 7 | import hashlib |
| ... | @@ -23,9 +24,12 @@ def build(arch, output): | ... | @@ -23,9 +24,12 @@ def build(arch, output): |
| 23 | subprocess.run(['cargo', 'build', '--locked', '--release', '-p', 'relay', '--target', triple], | 24 | subprocess.run(['cargo', 'build', '--locked', '--release', '-p', 'relay', '--target', triple], |
| 24 | cwd=ROOT, env=environment, check=True) | 25 | cwd=ROOT, env=environment, check=True) |
| 25 | target = Path(os.environ.get('CARGO_TARGET_DIR') or ROOT / 'target') | 26 | target = Path(os.environ.get('CARGO_TARGET_DIR') or ROOT / 'target') |
| 26 | file = output / f'snowbound-relay-linux-{arch}' | 27 | files = [] |
| 27 | shutil.copy(target / triple / 'release/snowbound-relay', file) | 28 | for name in ['snowbound-relay', 'snowbound-site']: |
| 28 | return file | 29 | file = output / f'{name}-linux-{arch}' |
| 30 | shutil.copy(target / triple / f'release/{name}', file) | ||
| 31 | files.append(file) | ||
| 32 | return files | ||
| 29 | 33 | ||
| 30 | 34 | ||
| 31 | def main(): | 35 | def main(): |
| ... | @@ -34,7 +38,7 @@ def main(): | ... | @@ -34,7 +38,7 @@ def main(): |
| 34 | parser.add_argument('--architectures', nargs='+', choices=ARCHITECTURES, default=ARCHITECTURES) | 38 | parser.add_argument('--architectures', nargs='+', choices=ARCHITECTURES, default=ARCHITECTURES) |
| 35 | args = parser.parse_args() | 39 | args = parser.parse_args() |
| 36 | args.output.mkdir(parents=True, exist_ok=True) | 40 | args.output.mkdir(parents=True, exist_ok=True) |
| 37 | files = [build(arch, args.output) for arch in args.architectures] | 41 | files = [file for arch in args.architectures for file in build(arch, args.output)] |
| 38 | for deployed in (ROOT / 'crates/relay/deploy').iterdir(): | 42 | for deployed in (ROOT / 'crates/relay/deploy').iterdir(): |
| 39 | shutil.copy(deployed, args.output) | 43 | shutil.copy(deployed, args.output) |
| 40 | shutil.copy(ROOT / 'crates/relay/README.md', args.output) | 44 | shutil.copy(ROOT / 'crates/relay/README.md', args.output) |
tools/release_web.py+17-9| ... | @@ -6,9 +6,11 @@ static folder and deploys it to the VPS, which serves it at https://snowbound.pa | ... | @@ -6,9 +6,11 @@ static folder and deploys it to the VPS, which serves it at https://snowbound.pa |
| 6 | python3 tools/release_web.py --deploy # build, then replace the site | 6 | python3 tools/release_web.py --deploy # build, then replace the site |
| 7 | 7 | ||
| 8 | The module and its JavaScript go in a folder named by their contents, b/HASH/, which | 8 | The module and its JavaScript go in a folder named by their contents, b/HASH/, which |
| 9 | index.html names, so a page never pairs one build's module with another's JavaScript; the | 9 | index.html names, so a page never pairs one build's module with another's JavaScript. The |
| 10 | server (tools/web/serve.py, which pm2's `snowbound-web` runs as `serve.py 23593 site` in the | 10 | server is snowbound-site (crates/relay), which pm2's `snowbound-site` runs from the VPS's |
| 11 | VPS's snowbound-web/) has index.html checked on every load and keeps a build's folder for good. | 11 | snowbound-web/ on 127.0.0.1:23593, serving site/ and the Live Share codes' pages: it has |
| 12 | index.html checked on every load and keeps a build's folder for good. Deploying builds it | ||
| 13 | for the VPS too, and restarts it only when its binary changed. | ||
| 12 | """ | 14 | """ |
| 13 | import argparse | 15 | import argparse |
| 14 | import gzip | 16 | import gzip |
| ... | @@ -31,10 +33,11 @@ WEB = ROOT / 'crates/snowbound/web' | ... | @@ -31,10 +33,11 @@ WEB = ROOT / 'crates/snowbound/web' |
| 31 | FONTS = ROOT / 'crates/canvas/assets/fonts' | 33 | FONTS = ROOT / 'crates/canvas/assets/fonts' |
| 32 | # Where the loading shell's icons are looked up by name, in order. | 34 | # Where the loading shell's icons are looked up by name, in order. |
| 33 | ICONS = [ROOT / 'crates/snowbound/assets/icons', ROOT / 'crates/canvas/assets/tags', ROOT / 'crates/ui/assets'] | 35 | ICONS = [ROOT / 'crates/snowbound/assets/icons', ROOT / 'crates/canvas/assets/tags', ROOT / 'crates/ui/assets'] |
| 34 | # The VPS's folder: serve.py, and the site it serves on 127.0.0.1:23593 as pm2's | 36 | # The VPS's folder: snowbound-site, and the site/ it serves on 127.0.0.1:23593 as pm2's |
| 35 | # `snowbound-web`, behind https://snowbound.paperclover.net. | 37 | # `snowbound-site`, behind https://snowbound.paperclover.net. |
| 36 | HOST = 'clo@paperclover.net' | 38 | HOST = 'clo@paperclover.net' |
| 37 | DEPLOYED = 'snowbound-web' | 39 | DEPLOYED = 'snowbound-web' |
| 40 | SERVER = 'snowbound-site' | ||
| 38 | URL = 'https://snowbound.paperclover.net' | 41 | URL = 'https://snowbound.paperclover.net' |
| 39 | # Builds the server keeps beside the newest, for pages still running one. | 42 | # Builds the server keeps beside the newest, for pages still running one. |
| 40 | KEPT = 3 | 43 | KEPT = 3 |
| ... | @@ -208,14 +211,19 @@ def fallbacks(fonts): | ... | @@ -208,14 +211,19 @@ def fallbacks(fonts): |
| 208 | 211 | ||
| 209 | def deploy(built): | 212 | def deploy(built): |
| 210 | """Makes the VPS's site `built`, each file taking its place once all have arrived, and | 213 | """Makes the VPS's site `built`, each file taking its place once all have arrived, and |
| 211 | leaves the last builds' folders for pages still running them.""" | 214 | leaves the last builds' folders for pages still running them; replaces its server, and |
| 215 | restarts it, where the server built now differs.""" | ||
| 212 | sync = ['rsync', '--recursive', '--links', '--times', '--compress', '--itemize-changes'] | 216 | sync = ['rsync', '--recursive', '--links', '--times', '--compress', '--itemize-changes'] |
| 213 | server = subprocess.run([*sync, ROOT / 'tools/web/serve.py', f'{HOST}:{DEPLOYED}/'], | 217 | arch = subprocess.check_output(['ssh', HOST, 'uname -m'], text=True).strip() |
| 214 | check=True, capture_output=True, text=True).stdout | 218 | with tempfile.TemporaryDirectory() as scratch: |
| 219 | run([sys.executable, ROOT / 'tools/release_relay.py', '--architectures', arch, '--output', scratch]) | ||
| 220 | server = subprocess.run([*sync, '--checksum', Path(scratch) / f'{SERVER}-linux-{arch}', | ||
| 221 | f'{HOST}:{DEPLOYED}/{SERVER}'], | ||
| 222 | check=True, capture_output=True, text=True).stdout | ||
| 215 | run([*sync, '--delete', '--delay-updates', '--filter=P b/*', f'{built}/', f'{HOST}:{DEPLOYED}/site/']) | 223 | run([*sync, '--delete', '--delay-updates', '--filter=P b/*', f'{built}/', f'{HOST}:{DEPLOYED}/site/']) |
| 216 | run(['ssh', HOST, f'cd {DEPLOYED}/site/b && ls -t | tail -n +{KEPT + 2} | xargs -r rm -rf --']) | 224 | run(['ssh', HOST, f'cd {DEPLOYED}/site/b && ls -t | tail -n +{KEPT + 2} | xargs -r rm -rf --']) |
| 217 | if server.strip(): | 225 | if server.strip(): |
| 218 | run(['ssh', HOST, 'pm2 restart snowbound-web']) | 226 | run(['ssh', HOST, f'pm2 restart {SERVER}']) |
| 219 | print(f'Deployed {URL}') | 227 | print(f'Deployed {URL}') |
| 220 | 228 | ||
| 221 | 229 |
tools/web/serve.py deleted-31| ... | @@ -1,31 +0,0 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Serves the web build on 127.0.0.1, behind the VPS's Caddy and Cloudflare: index.html is | ||
| 3 | checked every load, a build's folder (b/HASH/) is kept for good, and the fonts and | ||
| 4 | dictionaries for a day. | ||
| 5 | |||
| 6 | python3 serve.py PORT FOLDER | ||
| 7 | """ | ||
| 8 | import functools | ||
| 9 | import http.server | ||
| 10 | import sys | ||
| 11 | |||
| 12 | |||
| 13 | class Handler(http.server.SimpleHTTPRequestHandler): | ||
| 14 | extensions_map = {**http.server.SimpleHTTPRequestHandler.extensions_map, '.wasm': 'application/wasm'} | ||
| 15 | |||
| 16 | def end_headers(self): | ||
| 17 | path = self.path.split('?')[0] | ||
| 18 | if path.startswith('/b/'): | ||
| 19 | cache = 'public, max-age=31536000, immutable' | ||
| 20 | elif path in ('/', '/index.html'): | ||
| 21 | cache = 'no-cache' | ||
| 22 | else: | ||
| 23 | cache = 'public, max-age=86400' | ||
| 24 | self.send_header('Cache-Control', cache) | ||
| 25 | super().end_headers() | ||
| 26 | |||
| 27 | |||
| 28 | if __name__ == '__main__': | ||
| 29 | port, folder = int(sys.argv[1]), sys.argv[2] | ||
| 30 | handler = functools.partial(Handler, directory=folder) | ||
| 31 | http.server.ThreadingHTTPServer(('127.0.0.1', port), handler).serve_forever() | ||