From 2a08ad32443498bbee480044c19022eeceb329bc Mon Sep 17 00:00:00 2001 From: Mojtaba Jafarzadeh Date: Sat, 24 Oct 2026 22:35:05 +0330 Subject: [PATCH 1/5] collab-net: wait longer for one-shot server replies Publishing uploads a whole file, and a large project on a slow link takes the server several minutes to receive and answer. The one-shot request connections (hosting, the publish upload, a library, a plugin directory) waited the session idle timeout (45s), so a large publish timed out with "no answer from the server" before the server finished. Add a dedicated request_reply_timeout (5m) for these connections, keeping the session idle timeout unchanged. Co-Authored-By: Claude --- crates/collab-net/src/limits.rs | 20 ++++++++++++++++++++ crates/collab-net/src/manage.rs | 8 +++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/collab-net/src/limits.rs b/crates/collab-net/src/limits.rs index a0647fa6..0321b36c 100644 --- a/crates/collab-net/src/limits.rs +++ b/crates/collab-net/src/limits.rs @@ -24,6 +24,12 @@ pub struct NetLimits { pub hello_timeout: Duration, /// A connection with no traffic for this long is dead. pub idle_timeout: Duration, + /// How long a one-shot request connection (hosting, a library, a plugin directory — and, most + /// importantly, publishing a file) waits for the server's reply before giving up. Publishing + /// uploads a whole file and a large one on a slow link takes the server a while to receive and + /// answer, so this is longer than `idle_timeout` (a large project on a slow connection can + /// easily need several minutes). + pub request_reply_timeout: Duration, /// A quiet client pings this often (keeps it under `idle_timeout`). pub keepalive: Duration, /// Messages queued for one client before it counts as stuck and is disconnected. @@ -68,6 +74,7 @@ impl Default for NetLimits { burst: 400.0, hello_timeout: Duration::from_secs(10), idle_timeout: Duration::from_secs(45), + request_reply_timeout: Duration::from_secs(300), keepalive: Duration::from_secs(15), queue: 1024, write_timeout: Duration::from_secs(10), @@ -85,3 +92,16 @@ impl Default for NetLimits { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_reply_wait_is_longer_than_idle() { + let d = NetLimits::default(); + // Publishing uploads a whole file; a slow link must not cut it off at the session idle + // timeout. Keep the reply wait comfortably above it. + assert!(d.request_reply_timeout > d.idle_timeout); + } +} diff --git a/crates/collab-net/src/manage.rs b/crates/collab-net/src/manage.rs index 6e995bdb..843f91cf 100644 --- a/crates/collab-net/src/manage.rs +++ b/crates/collab-net/src/manage.rs @@ -196,13 +196,15 @@ pub fn plugins(server: &Endpoint, key: &str, req: PluginReq, limits: &NetLimits, Ok(r) } -/// A WebSocket to `server` that waits for answers up to the idle timeout (a large upload takes the -/// server a while to keep). +/// A WebSocket to `server` that waits for answers up to the request-reply timeout (a large upload +/// takes the server a while to keep). fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result { let (stream, _, _) = open(server, timeout, limits)?; let (ws, _) = tungstenite::client::client_with_config(server.url(), stream, Some(config(limits.max_server_message))) .map_err(|e| format!("{} doesn't answer as a FigCraft server: {e}", server.display()))?; - ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).map_err(|e| e.to_string())?; + // A publish uploads a whole file; a large project on a slow link can take several minutes to + // receive and answer. The wait uses `request_reply_timeout`, not the session `idle_timeout`. + ws.get_ref().set_read_timeout(Some(limits.request_reply_timeout)).map_err(|e| e.to_string())?; Ok(ws) } From 9a006175c81712020c0be2a8c6851c18c9b43739 Mon Sep 17 00:00:00 2001 From: Mojtaba Jafarzadeh Date: Sat, 24 Oct 2026 22:35:07 +0330 Subject: [PATCH 2/5] ui: server key field in the Share popover A server with a key rejects publishes without one, but Preferences' server address never holds a key. Add a "Server key" field to the Share popover's Advanced section and pass it to collab.share so a keyed server can be used from the popover without the Publish dialog. Co-Authored-By: Claude --- crates/engine/src/live_share_tests.rs | 17 +++++++++++++++++ crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/live.rs | 14 ++++++++++++++ 4 files changed, 33 insertions(+) diff --git a/crates/engine/src/live_share_tests.rs b/crates/engine/src/live_share_tests.rs index c52e1ccb..f486250f 100644 --- a/crates/engine/src/live_share_tests.rs +++ b/crates/engine/src/live_share_tests.rs @@ -272,6 +272,23 @@ fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() { assert_eq!((&r["status"], &r["code"], &r["share"]), (&json!("off"), &Value::Null, &Value::Null)); } +#[test] +fn sharing_online_uses_a_separate_key_param() { + // The Share popover sends the server key as its own field when the address (from Preferences, + // which never holds a key) needs one. It must reach `publish` and not leak into the summary. + let (mut s, script) = session(); + ex(&mut s, "pref.publishServer", json!({"url": "wss://share.example/"})); + let r = ex(&mut s, "collab.share", json!({"key": "KeyKeyKeyKeyKeyKeyKey1"})); + assert_eq!(r["status"], "connecting"); + { + let sc = script.lock().unwrap(); + let [p] = sc.published.as_slice() else { panic!("published {}", sc.published.len()) }; + assert_eq!(p.server, "wss://share.example/"); + assert_eq!(p.key.as_deref(), Some("KeyKeyKeyKeyKeyKeyKey1")); + } + assert!(!r.to_string().contains("KeyKey"), "the server's key is nowhere in the summary"); +} + #[test] fn a_server_without_join_codes_leaves_the_links() { let (mut s, script) = session(); diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ae566fc8..b7b06be2 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1220,6 +1220,7 @@ name = "English" "Server address (ws://host:47900/)" = "Server address (ws://host:47900/)" "Server address" = "Server address" "Server for online sharing" = "Server for online sharing" +"Server key" = "Server key" "Set as default" = "Set as default" "Set variable" = "Set variable" "Set variable mode" = "Set variable mode" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 596feda6..678ebed7 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1221,6 +1221,7 @@ status = "draft — needs native review" "Server address (ws://host:47900/)" = "Dirección del servidor (ws://host:47900/)" "Server address" = "Dirección del servidor" "Server for online sharing" = "Servidor para compartir en línea" +"Server key" = "Clave del servidor" "Set as default" = "Establecer como predeterminado" "Set variable" = "Establecer variable" "Set variable mode" = "Establecer modo de variable" diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index a4baa303..9d896a29 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -98,6 +98,10 @@ pub struct LiveUi { pub advanced: bool, pub this_computer: bool, pub port: String, + /// The server key for online sharing (a server that needs one; empty: none). Passed to + /// `collab.share` so a keyed server can be used from the Share popover without the Publish + /// dialog. + pub share_key: String, /// What is typed in the join field of the Share popover and the home screen. pub code: String, /// The join started from the home screen: what it is doing shows under the field, and its @@ -746,6 +750,9 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { if let (true, Ok(port)) = (local, app.ui.live.port.trim().parse::()) { params["port"] = json!(port); } + if !local && !app.ui.live.share_key.trim().is_empty() { + params["key"] = json!(app.ui.live.share_key.trim()); + } app.run("collab.share", params); } join_and_advanced(app, ui, t, |app, ui, t| { @@ -762,6 +769,13 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { }); } else { widgets::label(ui, tr("Another server, or one that needs its key. The usual server is set in Preferences."), t.text_secondary); + // The server key (a server that needs one; the address in Preferences is without it). + ui.horizontal(|ui| { + widgets::label(ui, tr("Server key"), t.text); + if let Some(key) = widgets::text_field_named(ui, egui::Id::new("live_share_key"), &app.ui.live.share_key, 200.0, tr("Server key")) { + app.ui.live.share_key = key.trim().chars().filter(|c| !c.is_whitespace() && !c.is_control()).take(128).collect(); + } + }); if widgets::button(ui, tr("Publish to another server…"), false, true).clicked() { open_publish(app); ui.close(); From 726700aa2a1177eb676d76e6f578ad55c5a055ff Mon Sep 17 00:00:00 2001 From: Mojtaba Jafarzadeh Date: Sat, 24 Oct 2026 22:35:07 +0330 Subject: [PATCH 3/5] contributors: add Mojtaba Jafarzadeh's real name Co-Authored-By: Claude --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 27307935..e5975522 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -71,3 +71,6 @@ sync_github_name = true [people.AganzeFelicite] sync_github_name = true + +[people.mojtabajafarzadeh] +real_name = "Mojtaba Jafarzadeh" From 994ddfc47e9d589bbcbc5f8b599e095a14e740ad Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 26 Oct 2026 09:11:23 +0700 Subject: [PATCH 4/5] #1299: the server key field shows dots, as a password field does --- crates/ui-egui/src/live.rs | 2 +- crates/ui-egui/src/live_share_tests.rs | 14 +++++++++++++ crates/ui-egui/src/widgets.rs | 29 +++++++++++++++++--------- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 199a4e33..9e7ce352 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -772,7 +772,7 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { // The server key (a server that needs one; the address in Preferences is without it). ui.horizontal(|ui| { widgets::label(ui, tr("Server key"), t.text); - if let Some(key) = widgets::text_field_named(ui, egui::Id::new("live_share_key"), &app.ui.live.share_key, 200.0, tr("Server key")) { + if let Some(key) = widgets::secret_field_named(ui, egui::Id::new("live_share_key"), &app.ui.live.share_key, 200.0, tr("Server key")) { app.ui.live.share_key = key.trim().chars().filter(|c| !c.is_whitespace() && !c.is_control()).take(128).collect(); } }); diff --git a/crates/ui-egui/src/live_share_tests.rs b/crates/ui-egui/src/live_share_tests.rs index d7fc61c8..6c5bdb84 100644 --- a/crates/ui-egui/src/live_share_tests.rs +++ b/crates/ui-egui/src/live_share_tests.rs @@ -232,6 +232,20 @@ fn not_sharing_the_popover_offers_one_way_to_start_and_one_to_join() { assert_eq!(h.app.session.live().status, Status::Off); } +#[test] +fn the_server_key_shows_as_dots_while_shown_and_typed() { + let (mut h, _) = harness(); + let shapes = open(&mut h); + click(&mut h, &shapes, "Advanced"); + h.app.ui.live.share_key = "s3cret-key".into(); + let shapes = h.frames(2); + let dots = "•".repeat(10); + assert!(has_text(&shapes, &dots) && !has_text(&shapes, "s3cret-key")); + // Editing it keeps it hidden. + let shapes = click(&mut h, &shapes, &dots); + assert!(!has_text(&shapes, "s3cret-key")); +} + #[test] fn sharing_online_shows_the_code_copies_it_and_stops() { let (mut h, script) = harness(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a5dd2c7b..cb58e1d1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -486,13 +486,20 @@ pub fn bound_field(ui: &mut Ui, id: Id, lead: Lead, rect: Rect, name: &str) -> O /// A text field that commits on Enter / focus loss. Returns the new text when committed. pub fn text_field(ui: &mut Ui, id: Id, value: &str, width: f32, placeholder: &str) -> Option { let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); - text_field_in(ui, id, value, rect, placeholder, placeholder) + text_field_in(ui, id, value, rect, placeholder, placeholder, false) } /// [`text_field`] without a placeholder, named `name` for screen readers (its label is beside it). pub fn text_field_named(ui: &mut Ui, id: Id, value: &str, width: f32, name: &str) -> Option { let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); - text_field_in(ui, id, value, rect, "", name) + text_field_in(ui, id, value, rect, "", name, false) +} + +/// [`text_field_named`] for a secret (a server key): it shows dots, typing included, and screen +/// readers hear dots too. +pub fn secret_field_named(ui: &mut Ui, id: Id, value: &str, width: f32, name: &str) -> Option { + let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); + text_field_in(ui, id, value, rect, "", name, true) } /// A multi-line text field across the panel (descriptions, notes) that commits when it loses @@ -536,7 +543,7 @@ pub fn multiline_field(ui: &mut Ui, id: Id, value: &str, hint: &str, rows: usize pub fn combo_field(ui: &mut Ui, id: Id, value: &str, width: f32, options: &[String]) -> Option { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); - let mut out = text_field_in(ui, id, value, rect, "", ""); + let mut out = text_field_in(ui, id, value, rect, "", "", false); // The chevron sits on top of the field's right end. let chev = Rect::from_min_max(pos2(rect.max.x - 18.0, rect.min.y), rect.max); let resp = ui.interact(chev, id.with("menu"), Sense::click()); @@ -557,13 +564,14 @@ pub fn combo_field(ui: &mut Ui, id: Id, value: &str, width: f32, options: &[Stri } /// [`text_field`] drawn into `rect`; `name` names it for screen readers. -fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str, name: &str) -> Option { +fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str, name: &str, secret: bool) -> Option { cancel_retargeted_field_save(ui, id); let t = Tokens::get(ui.ctx()); let editing_id = id.with("editing"); let mut editing: Option = ui.data(|d| d.get_temp(editing_id)); let resp = ui.interact(rect, id.with("click"), Sense::click()); - a11y::text(&resp, name, value); + let shown = if secret { std::borrow::Cow::Owned("•".repeat(value.chars().count())) } else { std::borrow::Cow::Borrowed(value) }; + a11y::text(&resp, name, &shown); ui.painter().rect_filled(rect, CornerRadius::same(5), t.field); if editing.is_some() { ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside); @@ -579,7 +587,7 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str let mut out = None; match editing { Some(mut text) => { - let r = field_editor(ui, rect, id.with("te"), &mut text, ""); + let r = field_editor(ui, rect, id.with("te"), &mut text, "", secret); a11y::own_focus(&r); keep_focus(&r, started); crate::shortcuts::release_field_for_save(&r); @@ -595,7 +603,7 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str } } None => { - let (s, col) = if value.is_empty() { (placeholder, t.text_tertiary) } else { (value, t.text) }; + let (s, col) = if value.is_empty() { (placeholder, t.text_tertiary) } else { (&*shown, t.text) }; // Long text stays inside the field. let painter = ui.painter().with_clip_rect(rect.shrink2(vec2(8.0, 0.0))); painter.text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), col); @@ -605,14 +613,15 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str } /// The editor inside a field's `rect`: one line of text in the field's font, without a frame of -/// its own (the field draws it), showing `hint` while empty. -fn field_editor(ui: &mut Ui, rect: Rect, id: Id, text: &mut String, hint: &str) -> Response { +/// its own (the field draws it), showing `hint` while empty; `password` shows dots. +fn field_editor(ui: &mut Ui, rect: Rect, id: Id, text: &mut String, hint: &str, password: bool) -> Response { let t = Tokens::get(ui.ctx()); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(8.0, 4.0)))); let r = child.add( egui::TextEdit::singleline(text) .id(id) .frame(egui::Frame::NONE) + .password(password) .font(regular(11.0)) .hint_text(egui::RichText::new(hint).font(regular(11.0)).color(t.text_tertiary)) .desired_width(rect.width() - 16.0), @@ -628,7 +637,7 @@ pub fn text_input(ui: &mut Ui, id: Id, text: &mut String, width: f32, hint: &str let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); ui.painter().rect_filled(rect, CornerRadius::same(5), t.field); - let r = field_editor(ui, rect, id, text, hint); + let r = field_editor(ui, rect, id, text, hint, false); if r.has_focus() { ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside); } else if ui.rect_contains_pointer(rect) { From 454ec26c11c6c8c569f38c06680ada20d36609d3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 26 Oct 2026 09:11:24 +0700 Subject: [PATCH 5/5] #1299: the server key field's id uses egui 0.37's Id::unique --- crates/ui-egui/src/live.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 9e7ce352..bcc0bc77 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -772,7 +772,9 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { // The server key (a server that needs one; the address in Preferences is without it). ui.horizontal(|ui| { widgets::label(ui, tr("Server key"), t.text); - if let Some(key) = widgets::secret_field_named(ui, egui::Id::new("live_share_key"), &app.ui.live.share_key, 200.0, tr("Server key")) { + if let Some(key) = + widgets::secret_field_named(ui, egui::Id::unique("live_share_key"), &app.ui.live.share_key, 200.0, tr("Server key")) + { app.ui.live.share_key = key.trim().chars().filter(|c| !c.is_whitespace() && !c.is_control()).take(128).collect(); } });