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 = [...@@ -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]
31513152
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 again7//! and one writing. A connection whose frames arrive out of order is dropped and met again
8//! from scratch.8//! from scratch.
99
10pub mod code;10pub use ::relay::code;
11mod relay;11mod relay;
12pub mod share;12pub mod share;
13pub mod wire;13pub 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...@@ -10,7 +10,15 @@ publish = false
10name = "snowbound-relay"10name = "snowbound-relay"
11path = "src/main.rs"11path = "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
13[dependencies]18[dependencies]
14base64 = { version = "0.23.1", default-features = false, features = ["std"] }19base64 = { version = "0.23.1", default-features = false, features = ["std"] }
15getrandom = "0.4.3"20getrandom = "0.4.3"
16sha1 = "0.11.0"21sha1 = "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....@@ -12,11 +12,11 @@ HTTP and WebSocket; a proxy in front of it terminates TLS.
12## Build12## Build
1313
14```sh14```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}
16```16```
1717
18It links with Rust's own lld against Rust's own musl, so it needs only `rustup`. The folder18It 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
21## Deploy21## Deploy
2222
...@@ -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
28sudo install -m 644 /tmp/snowbound-relay.service /etc/systemd/system/28sudo install -m 644 /tmp/snowbound-relay.service /etc/systemd/system/
29sudo systemctl daemon-reload29sudo systemctl daemon-reload
30sudo systemctl enable --now snowbound-relay30sudo 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}
32```32```
3333
34Then point a name at the server and put a TLS proxy in front. Caddy fetches its own34Then 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:
3636
37```text37```text
38live.example.net {38live.example.net {
39 reverse_proxy 127.0.0.1:765039 reverse_proxy 127.0.0.1:23592
40}40}
41```41```
4242
...@@ -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```
6262
63The app then uses `wss://live.example.net` (for now, `SNOWBOUND_LIVE_RELAY=wss://live.example.net`63The app uses `wss://relay.snowbound.paperclover.net` unless Options ▸ Sync & Storage ▸ Live
64with a build that has the `live` feature).64Share names another relay.
6565
66`--trust-forwarded true`, as the unit sets it, counts each peer by the last66`--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 client67`X-Forwarded-For` entry, the one the proxy added. Without a proxy, leave it off: a client
68could otherwise claim any address. Listening on a public address without TLS works but lets68could otherwise claim any address. Listening on a public address without TLS works but lets
69anyone on the path see room tags and nameplates.69anyone 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
71## Options107## Options
72108
73`snowbound-relay --help` lists every option and its default. Each can also be set in the109`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
6Wants=network-online.target6Wants=network-online.target
77
8[Service]8[Service]
9ExecStart=/usr/local/bin/snowbound-relay --listen 127.0.0.1:7650 --trust-forwarded true9ExecStart=/usr/local/bin/snowbound-relay --listen 127.0.0.1:23592 --trust-forwarded true
10Restart=always10Restart=always
11RestartSec=211RestartSec=2
12DynamicUser=yes12DynamicUser=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 @@...@@ -12,7 +12,9 @@
12//! its opening `met`; a peer that `failed`, left first, or stayed silent too long counts a12//! 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`.
1414
15pub mod code;
15pub mod server;16pub mod server;
17pub mod site;
16pub mod ws;18pub mod ws;
1719
18use std::{fmt, str::FromStr};20use 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 = "\
7usage: snowbound-relay [OPTION VALUE]...7usage: snowbound-relay [OPTION VALUE]...
88
9Each option may also come from the environment as SNOWBOUND_RELAY_<OPTION>, upper case with9Each 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)
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";
2828
29fn main() -> ExitCode {29fn 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
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...@@ -3,10 +3,10 @@ Type=Application
3Name=Snowbound3Name=Snowbound
4GenericName=Note Taking4GenericName=Note Taking
5Comment=Type and draw notes in OneNote notebooks5Comment=Type and draw notes in OneNote notebooks
6Exec=snowbound %F6Exec=snowbound %U
7Icon=net.paperclover.snowbound7Icon=net.paperclover.snowbound
8Terminal=false8Terminal=false
9Categories=Office;9Categories=Office;
10MimeType=application/onenote;10MimeType=application/onenote;x-scheme-handler/snowbound;
11StartupNotify=true11StartupNotify=true
12StartupWMClass=net.paperclover.snowbound12StartupWMClass=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}
216216
217/// `text` with `%XX` escapes decoded.217/// `text` with `%XX` escapes decoded.
218fn decode(text: &str) -> String {218pub 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.
35const FIRST_LISTING: std::time::Duration = std::time::Duration::from_secs(15);35const 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
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.
38struct Me {57struct 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>> {
1024fn system_picture() -> Option<Vec<u8>> {1043fn system_picture() -> Option<Vec<u8>> {
1025 None1044 None
1026}1045}
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...@@ -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}
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
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.
1572pub fn install_menu() {1592pub 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}
4647
47/// Open Shared Notebook while it is open.48/// Open Shared Notebook while it is open.
48pub(crate) struct JoinDialog {49pub(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 }
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
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}
229237
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 python31#!/usr/bin/env python3
2"""Builds snowbound-relay, the Live Share relay (crates/relay), as one static executable for each2"""Builds snowbound-relay, the Live Share relay, and snowbound-site, which serves the web build and
3Linux architecture a server runs, with the files that deploy it, into target/relay/ by default.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.
4It ships on its own, outside the app's builds and latest.json; see crates/relay/README.md."""5It ships on its own, outside the app's builds and latest.json; see crates/relay/README.md."""
5import argparse6import argparse
6import hashlib7import 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 file29 file = output / f'{name}-linux-{arch}'
30 shutil.copy(target / triple / f'release/{name}', file)
31 files.append(file)
32 return files
2933
3034
31def main():35def 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 site6 python3 tools/release_web.py --deploy # build, then replace the site
77
8The module and its JavaScript go in a folder named by their contents, b/HASH/, which8The 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; the9index.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 the10server is snowbound-site (crates/relay), which pm2's `snowbound-site` runs from the VPS's
11VPS's snowbound-web/) has index.html checked on every load and keeps a build's folder for good.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.
12"""14"""
13import argparse15import argparse
14import gzip16import gzip
...@@ -31,10 +33,11 @@ WEB = ROOT / 'crates/snowbound/web'...@@ -31,10 +33,11 @@ WEB = ROOT / 'crates/snowbound/web'
31FONTS = ROOT / 'crates/canvas/assets/fonts'33FONTS = 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.
33ICONS = [ROOT / 'crates/snowbound/assets/icons', ROOT / 'crates/canvas/assets/tags', ROOT / 'crates/ui/assets']35ICONS = [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's36# 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.
36HOST = 'clo@paperclover.net'38HOST = 'clo@paperclover.net'
37DEPLOYED = 'snowbound-web'39DEPLOYED = 'snowbound-web'
40SERVER = 'snowbound-site'
38URL = 'https://snowbound.paperclover.net'41URL = '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.
40KEPT = 343KEPT = 3
...@@ -208,14 +211,19 @@ def fallbacks(fonts):...@@ -208,14 +211,19 @@ def fallbacks(fonts):
208211
209def deploy(built):212def deploy(built):
210 """Makes the VPS's site `built`, each file taking its place once all have arrived, and213 """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).stdout218 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}')
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()