authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 22:38:37-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 05:26:36-07:00
log3dff91e5b8938209f2a7b38e8323227ba4a12a71
tree9a786b5f53250d5fbe2d52b51897ac41e3457e1f
parent02acde3ba6bf8683a349deb02c298332a7c4f4ed
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: Live Share links: snowbound.paperclover.net/<code> opens the share in Snowbound

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.5

27 files changed, 725 insertions(+), 222 deletions(-)

Cargo.lock+1
......@@ -3147,6 +3147,7 @@ dependencies = [
31473147 "base64",
31483148 "getrandom 0.4.3",
31493149 "sha1",
3150 "tempfile",
31503151]
31513152
31523153[[package]]
crates/notebook/src/live.rs+1-1
......@@ -7,7 +7,7 @@
77//! and one writing. A connection whose frames arrive out of order is dropped and met again
88//! from scratch.
99
10pub mod code;
10pub use ::relay::code;
1111mod relay;
1212pub mod share;
1313pub 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
12use std::io;
13
14const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
15/// The symbols naming a code's room, and how many rooms they name.
16const NAMEPLATE: usize = 2;
17pub const NAMEPLATES: u32 = 1 << (5 * NAMEPLATE);
18/// The symbols of a code's secret.
19pub const SECRET: usize = 6;
20
21/// A new secret, `SECRET` random symbols.
22pub 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
31fn 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.
36fn 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
48fn 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.
57pub 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.
76pub 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)]
92mod 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
1010name = "snowbound-relay"
1111path = "src/main.rs"
1212
13# snowbound.paperclover.net: the hosted web build, and a page for each Live Share code.
14[[bin]]
15name = "snowbound-site"
16path = "src/site_main.rs"
17
1318[dependencies]
1419base64 = { version = "0.23.1", default-features = false, features = ["std"] }
1520getrandom = "0.4.3"
1621sha1 = "0.11.0"
22
23[dev-dependencies]
24tempfile = "3"
crates/relay/README.md+43-7
......@@ -12,11 +12,11 @@ HTTP and WebSocket; a proxy in front of it terminates TLS.
1212## Build
1313
1414```sh
15python3 tools/release_relay.py # target/relay/snowbound-relay-linux-{x86_64,aarch64}
15python3 tools/release_relay.py # target/relay/snowbound-{relay,site}-linux-{x86_64,aarch64}
1616```
1717
1818It links with Rust's own lld against Rust's own musl, so it needs only `rustup`. The folder
19it makes holds both executables, `SHA256SUMS`, this README and `snowbound-relay.service`.
19it makes holds the executables, `SHA256SUMS`, this README and both systemd units.
2020
2121## Deploy
2222
......@@ -28,7 +28,7 @@ sudo install -m 755 /tmp/snowbound-relay /usr/local/bin/snowbound-relay
2828sudo install -m 644 /tmp/snowbound-relay.service /etc/systemd/system/
2929sudo systemctl daemon-reload
3030sudo systemctl enable --now snowbound-relay
31curl -s http://127.0.0.1:7650/health # {"rooms":0,"peers":0,"connections":1,"seconds":3}
31curl -s http://127.0.0.1:23592/health # {"rooms":0,"peers":0,"connections":1,"seconds":3}
3232```
3333
3434Then 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:
3636
3737```text
3838live.example.net {
39 reverse_proxy 127.0.0.1:7650
39 reverse_proxy 127.0.0.1:23592
4040}
4141```
4242
......@@ -49,7 +49,7 @@ server {
4949 ssl_certificate /etc/letsencrypt/live/live.example.net/fullchain.pem;
5050 ssl_certificate_key /etc/letsencrypt/live/live.example.net/privkey.pem;
5151 location / {
52 proxy_pass http://127.0.0.1:7650;
52 proxy_pass http://127.0.0.1:23592;
5353 proxy_http_version 1.1;
5454 proxy_set_header Upgrade $http_upgrade;
5555 proxy_set_header Connection "upgrade";
......@@ -60,14 +60,50 @@ server {
6060}
6161```
6262
63The app then uses `wss://live.example.net` (for now, `SNOWBOUND_LIVE_RELAY=wss://live.example.net`
64with a build that has the `live` feature).
63The app uses `wss://relay.snowbound.paperclover.net` unless Options ▸ Sync & Storage ▸ Live
64Share names another relay.
6565
6666`--trust-forwarded true`, as the unit sets it, counts each peer by the last
6767`X-Forwarded-For` entry, the one the proxy added. Without a proxy, leave it off: a client
6868could otherwise claim any address. Listening on a public address without TLS works but lets
6969anyone on the path see room tags and nameplates.
7070
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
74code (`/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
76joins shares). Anything else under the folder is a file; `/` is its `index.html`. A build's
77module and JavaScript sit in `b/<hash>/` and are cached for good; `index.html` and the
78codes' pages are checked on every load, and the rest (fonts, dictionaries) for a day. It
79holds no state.
80
81`python3 tools/release_web.py --deploy` builds the web app and the site for the VPS's
82architecture, copies the build into `~/snowbound-web/site/` (keeping the last three builds'
83folders for pages still running them) and the binary to `~/snowbound-web/snowbound-site`,
84and restarts pm2's `snowbound-site` only when the binary changed. The first time:
85
86```sh
87ssh vps
88mkdir -p ~/snowbound-web/site
89# after the first `release_web.py --deploy` has put the binary there:
90pm2 start ~/snowbound-web/snowbound-site --name snowbound-site -- \
91 --listen 127.0.0.1:23593 --root "$HOME/snowbound-web/site"
92pm2 save
93```
94
95Caddy in front:
96
97```text
98snowbound.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
71107## Options
72108
73109`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
66Wants=network-online.target
77
88[Service]
9ExecStart=/usr/local/bin/snowbound-relay --listen 127.0.0.1:7650 --trust-forwarded true
9ExecStart=/usr/local/bin/snowbound-relay --listen 127.0.0.1:23592 --trust-forwarded true
1010Restart=always
1111RestartSec=2
1212DynamicUser=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]
5Description=Snowbound web build and Live Share links
6After=network-online.target
7Wants=network-online.target
8
9[Service]
10ExecStart=/usr/local/bin/snowbound-site --listen 127.0.0.1:23593 --root /srv/snowbound/web
11Restart=always
12RestartSec=2
13DynamicUser=yes
14TasksMax=200
15MemoryMax=256M
16NoNewPrivileges=yes
17ProtectSystem=strict
18ReadOnlyPaths=/srv/snowbound/web
19ProtectHome=yes
20PrivateTmp=yes
21PrivateDevices=yes
22ProtectKernelTunables=yes
23ProtectKernelModules=yes
24ProtectControlGroups=yes
25RestrictAddressFamilies=AF_INET AF_INET6
26RestrictNamespaces=yes
27LockPersonality=yes
28MemoryDenyWriteExecute=yes
29SystemCallArchitectures=native
30CapabilityBoundingSet=
31
32[Install]
33WantedBy=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
12use std::io;
13
14const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
15/// The symbols naming a code's room, and how many rooms they name.
16const NAMEPLATE: usize = 2;
17pub const NAMEPLATES: u32 = 1 << (5 * NAMEPLATE);
18/// The symbols of a code's secret.
19pub const SECRET: usize = 6;
20
21/// A new secret, `SECRET` random symbols.
22pub 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
31fn 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.
36fn 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
48fn 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.
57pub 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.
76pub 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)]
92mod 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 @@
1212//! its opening `met`; a peer that `failed`, left first, or stayed silent too long counts a
1313//! wrong code against its address and the code. See `resources/live-share.md`.
1414
15pub mod code;
1516pub mod server;
17pub mod site;
1618pub mod ws;
1719
1820use std::{fmt, str::FromStr};
crates/relay/src/main.rs+3-3
......@@ -7,9 +7,9 @@ const USAGE: &str = "\
77usage: snowbound-relay [OPTION VALUE]...
88
99Each option may also come from the environment as SNOWBOUND_RELAY_<OPTION>, upper case with
10underscores: SNOWBOUND_RELAY_LISTEN=127.0.0.1:7650. Flags win.
10underscores: SNOWBOUND_RELAY_LISTEN=127.0.0.1:23592. Flags win.
1111
12 --listen ADDRESS where to listen (127.0.0.1:7650)
12 --listen ADDRESS where to listen (127.0.0.1:23592)
1313 --trust-forwarded true|false count peers by X-Forwarded-For, behind a proxy (false)
1414 --max-connections N (256)
1515 --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.
2727";
2828
2929fn main() -> ExitCode {
30 let mut listen = String::from("127.0.0.1:7650");
30 let mut listen = String::from("127.0.0.1:23592");
3131 let mut config = Config::default();
3232 let from_environment = std::env::vars().filter_map(|(key, value)| {
3333 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
8use crate::{code, ws};
9use 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)]
20pub 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.
29pub 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
41fn 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.
95fn 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
111fn 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.
130fn 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
3use relay::site::{self, Site};
4use std::{net::TcpListener, path::PathBuf, process::ExitCode};
5
6const USAGE: &str = "\
7usage: snowbound-site [OPTION VALUE]...
8
9Each option may also come from the environment as SNOWBOUND_SITE_<OPTION>, upper case:
10SNOWBOUND_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
18fn 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
3use std::{
4 io::{BufRead, BufReader, Read, Write},
5 net::{SocketAddr, TcpStream},
6 process::{Child, Command, Stdio},
7};
8
9struct Site {
10 child: Child,
11 address: SocketAddr,
12}
13
14impl 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
47impl Drop for Site {
48 fn drop(&mut self) {
49 let _ = self.child.kill();
50 }
51}
52
53#[test]
54fn 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
33Name=Snowbound
44GenericName=Note Taking
55Comment=Type and draw notes in OneNote notebooks
6Exec=snowbound %F
6Exec=snowbound %U
77Icon=net.paperclover.snowbound
88Terminal=false
99Categories=Office;
10MimeType=application/onenote;
10MimeType=application/onenote;x-scheme-handler/snowbound;
1111StartupNotify=true
1212StartupWMClass=net.paperclover.snowbound
crates/snowbound/src/desktop_linux.rs+1-1
......@@ -567,7 +567,7 @@ mod tests {
567567 #[test]
568568 fn only_the_hidden_entry_names_an_owner() {
569569 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"));
571571 assert!(installed.contains(&format!("\nIcon={APP_ID}\n")));
572572 assert_eq!(portable_owner(&installed), None);
573573 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> {
215215}
216216
217217/// `text` with `%XX` escapes decoded.
218fn decode(text: &str) -> String {
218pub fn decode(text: &str) -> String {
219219 let bytes = text.as_bytes();
220220 let mut decoded = Vec::with_capacity(bytes.len());
221221 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";
3434/// How long opening a notebook joined for the first time waits for the computer sharing it.
3535const FIRST_LISTING: std::time::Duration = std::time::Duration::from_secs(15);
3636
37/// Where a code's link goes: the page that opens it (`crates/relay`'s `snowbound-site`).
38const SITE: &str = "https://snowbound.paperclover.net/";
39/// What opens Snowbound at a code, as the site's page and the desktop pass it.
40const SCHEME: &str = "snowbound://join/";
41
42/// The link that opens `code`.
43pub(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.
48pub(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
3756/// Who this computer is to others and where it meets them, as Options last said.
3857struct Me {
3958 name: String,
......@@ -1024,3 +1043,20 @@ fn system_picture() -> Option<Vec<u8>> {
10241043fn system_picture() -> Option<Vec<u8>> {
10251044 None
10261045}
1046
1047#[cfg(test)]
1048mod 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
15471547 sel!(application:openFiles:),
15481548 open_files as unsafe extern "C" fn(_, _, _, _),
15491549 );
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 );
15501555 let class = class.register();
15511556 // The subclass adds no ivars, so the existing allocation remains valid.
15521557 AnyObject::set_class(&delegate, class);
......@@ -1568,6 +1573,21 @@ unsafe extern "C" fn open_files(
15681573 let _: () = unsafe { msg_send![app, replyToOpenOrPrint: 0usize] };
15691574}
15701575
1576unsafe 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
15711591/// Replaces Winit's application menu with the menu bar, whose items the table validates.
15721592pub fn install_menu() {
15731593 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>> {
67696769 .to_str()
67706770 .ok_or("The page title must be valid Unicode.")?,
67716771 )?);
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)));
67726778 } else if library::locate(Path::new(&arg)) != library::Located::Nothing {
67736779 // A file the desktop opens with Snowbound, as a double-clicked section.
67746780 opening.push(std::path::absolute(arg)?);
crates/snowbound/src/share.rs+36-11
......@@ -41,11 +41,14 @@ pub(crate) struct ShareDialog {
4141 library: Arc<Library>,
4242 protect: bool,
4343 password: String,
44 copied: bool,
44 /// What was copied last: the code, or the link.
45 copied: Option<&'static str>,
4546}
4647
4748/// Open Shared Notebook while it is open.
4849pub(crate) struct JoinDialog {
50 /// Join without waiting for Open, as a link asks.
51 open: bool,
4952 code: String,
5053 password: String,
5154 status: Status,
......@@ -234,14 +237,24 @@ impl State {
234237 library,
235238 protect: false,
236239 password: String::new(),
237 copied: false,
240 copied: None,
238241 });
239242 self.ui.open_popup(share_id());
240243 }
241244
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
242254 /// Opens Open Shared Notebook.
243255 pub(crate) fn open_shared(&mut self) {
244256 self.peers.join = Some(JoinDialog {
257 open: false,
245258 code: String::new(),
246259 password: String::new(),
247260 status: Status::Idle,
......@@ -304,9 +317,19 @@ impl State {
304317 },
305318 );
306319 if let Some(code) = &code {
307 let label = if dialog.copied { "Copied" } else { "Copy" };
308 if ui::button(ui, "copy", label).clicked {
309 copy = Some(code.clone());
320 let link = crate::live::link(code);
321 for (part, what, label, text) in [
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 }
310333 }
311334 }
312335 ui.close();
......@@ -315,9 +338,10 @@ impl State {
315338 ui,
316339 "how",
317340 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."
319343 } else {
320 "Others open it with Open Shared Notebook and this code."
344 "Send the link, or the code for Open Shared Notebook."
321345 },
322346 true,
323347 );
......@@ -394,8 +418,8 @@ impl State {
394418 let done = ui::button(ui, "done", "Done").clicked || entered && !offered;
395419 ui.close();
396420 ui.close();
397 if let Some(code) = copy {
398 dialog.copied = self.clipboard.set_text(code).is_ok();
421 if let Some((what, text)) = copy {
422 dialog.copied = self.clipboard.set_text(text).is_ok().then_some(what);
399423 }
400424 if stop {
401425 self.stop_sharing(&location);
......@@ -482,7 +506,8 @@ impl State {
482506 }
483507 buttons(ui);
484508 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);
486511 ui.close();
487512 ui.close();
488513 if cancel {
......@@ -491,7 +516,7 @@ impl State {
491516 return;
492517 }
493518 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)) {
495520 None => dialog.status = Status::Failed(refusal(&Refusal::Malformed)),
496521 Some(code) => {
497522 dialog.status = Status::Waiting;
crates/snowbound/src/sidebar.rs+4
......@@ -1133,6 +1133,10 @@ impl crate::State {
11331133 /// Opens what the open panel chose: a notebook folder, a notebook's table of contents,
11341134 /// or a section, in its notebook where it has one.
11351135 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 }
11361140 match crate::library::locate(path) {
11371141 crate::library::Located::Notebook { root, section } => {
11381142 self.open_notebook(root.to_string_lossy().into_owned(), section)
crates/snowbound/src/windows.rs+8
......@@ -225,6 +225,14 @@ fn offer_to_open() {
225225 for extension in [".one", ".onetoc2", ".onepkg"] {
226226 set(&format!(r"{extension}\OpenWithProgids"), Some(PROG_ID), "");
227227 }
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 );
228236}
229237
230238/// 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() {
720720 let mut steps = vec!["modifiers command shift", "key p", "modifiers", "settle"];
721721 steps.extend(["type Live Share", "settle", "key Enter", "settle"]);
722722 steps.extend(["key Enter", "wait 1500", "accessibility shared"]);
723 // Copy, then Stop Sharing.
723 // Copy, Copy Link, then Stop Sharing.
724724 steps.extend([
725 "key Tab",
725726 "key Tab",
726727 "key Tab",
727728 "key Enter",
tools/canvas/build_macos.py+2
......@@ -157,6 +157,8 @@ if build:
157157 'NSUbiquitousContainerSupportedFolderLevels': 'Any',
158158 }},
159159 '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']}],
160162 'CFBundleDocumentTypes': [{
161163 'CFBundleTypeName': 'OneNote Notebook',
162164 'CFBundleTypeRole': 'Editor',
tools/release_relay.py+10-6
......@@ -1,6 +1,7 @@
11#!/usr/bin/env python3
2"""Builds snowbound-relay, the Live Share relay (crates/relay), as one static executable for each
3Linux architecture a server runs, with the files that deploy it, into target/relay/ by default.
2"""Builds snowbound-relay, the Live Share relay, and snowbound-site, which serves the web build and
3the codes' pages (crates/relay), as static executables for each Linux architecture a server
4runs, with the files that deploy them, into target/relay/ by default.
45It ships on its own, outside the app's builds and latest.json; see crates/relay/README.md."""
56import argparse
67import hashlib
......@@ -23,9 +24,12 @@ def build(arch, output):
2324 subprocess.run(['cargo', 'build', '--locked', '--release', '-p', 'relay', '--target', triple],
2425 cwd=ROOT, env=environment, check=True)
2526 target = Path(os.environ.get('CARGO_TARGET_DIR') or ROOT / 'target')
26 file = output / f'snowbound-relay-linux-{arch}'
27 shutil.copy(target / triple / 'release/snowbound-relay', file)
28 return file
27 files = []
28 for name in ['snowbound-relay', 'snowbound-site']:
29 file = output / f'{name}-linux-{arch}'
30 shutil.copy(target / triple / f'release/{name}', file)
31 files.append(file)
32 return files
2933
3034
3135def main():
......@@ -34,7 +38,7 @@ def main():
3438 parser.add_argument('--architectures', nargs='+', choices=ARCHITECTURES, default=ARCHITECTURES)
3539 args = parser.parse_args()
3640 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)]
3842 for deployed in (ROOT / 'crates/relay/deploy').iterdir():
3943 shutil.copy(deployed, args.output)
4044 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
66 python3 tools/release_web.py --deploy # build, then replace the site
77
88The module and its JavaScript go in a folder named by their contents, b/HASH/, which
9index.html names, so a page never pairs one build's module with another's JavaScript; the
10server (tools/web/serve.py, which pm2's `snowbound-web` runs as `serve.py 23593 site` in the
11VPS's snowbound-web/) has index.html checked on every load and keeps a build's folder for good.
9index.html names, so a page never pairs one build's module with another's JavaScript. The
10server is snowbound-site (crates/relay), which pm2's `snowbound-site` runs from the VPS's
11snowbound-web/ on 127.0.0.1:23593, serving site/ and the Live Share codes' pages: it has
12index.html checked on every load and keeps a build's folder for good. Deploying builds it
13for the VPS too, and restarts it only when its binary changed.
1214"""
1315import argparse
1416import gzip
......@@ -31,10 +33,11 @@ WEB = ROOT / 'crates/snowbound/web'
3133FONTS = ROOT / 'crates/canvas/assets/fonts'
3234# Where the loading shell's icons are looked up by name, in order.
3335ICONS = [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
35# `snowbound-web`, behind https://snowbound.paperclover.net.
36# The VPS's folder: snowbound-site, and the site/ it serves on 127.0.0.1:23593 as pm2's
37# `snowbound-site`, behind https://snowbound.paperclover.net.
3638HOST = 'clo@paperclover.net'
3739DEPLOYED = 'snowbound-web'
40SERVER = 'snowbound-site'
3841URL = 'https://snowbound.paperclover.net'
3942# Builds the server keeps beside the newest, for pages still running one.
4043KEPT = 3
......@@ -208,14 +211,19 @@ def fallbacks(fonts):
208211
209212def deploy(built):
210213 """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."""
212216 sync = ['rsync', '--recursive', '--links', '--times', '--compress', '--itemize-changes']
213 server = subprocess.run([*sync, ROOT / 'tools/web/serve.py', f'{HOST}:{DEPLOYED}/'],
214 check=True, capture_output=True, text=True).stdout
217 arch = subprocess.check_output(['ssh', HOST, 'uname -m'], text=True).strip()
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
215223 run([*sync, '--delete', '--delay-updates', '--filter=P b/*', f'{built}/', f'{HOST}:{DEPLOYED}/site/'])
216224 run(['ssh', HOST, f'cd {DEPLOYED}/site/b && ls -t | tail -n +{KEPT + 2} | xargs -r rm -rf --'])
217225 if server.strip():
218 run(['ssh', HOST, 'pm2 restart snowbound-web'])
226 run(['ssh', HOST, f'pm2 restart {SERVER}'])
219227 print(f'Deployed {URL}')
220228
221229
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
3checked every load, a build's folder (b/HASH/) is kept for good, and the fonts and
4dictionaries for a day.
5
6 python3 serve.py PORT FOLDER
7"""
8import functools
9import http.server
10import sys
11
12
13class 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
28if __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()