diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index c22029193..1471c1798 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -956,11 +956,18 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit // Sessions and the operator may use the last connections; one-shot requests // (hosting, libraries, browsing plugins) leave them free. let session = matches!(first, ClientMsg::Hello(_) | ClientMsg::Manage(_)); + // A publish uploads a whole file; a large one on a slow link takes the server a + // while to receive and answer. The manage/hosting connections that carry uploads + // wait the longer request-reply timeout, not the short request idle. + let idle = match first { + ClientMsg::Manage(_) | ClientMsg::OpenHosting(_) => limits.request_reply_timeout, + ClientMsg::Hello(_) => limits.idle_timeout, + _ => limits.request_idle, + }; let max = match &first { ClientMsg::OpenPlugins(o) if o.key.is_empty() => limits.max_request, _ => limits.max_client_message, }; - let idle = if session { limits.idle_timeout } else { limits.request_idle }; let reserved = !session && open.0.load(Ordering::Relaxed) > limits.max_connections.saturating_sub(limits.reserve_for_sessions); // (In flight is only taken when the connection goes on: the hub gives it back.) if reserved || !shared.in_flight.take(bytes, limits.max_in_flight) { diff --git a/crates/ui-egui/src/context_menu_tests.rs b/crates/ui-egui/src/context_menu_tests.rs index 61df3fdb1..a447347d7 100644 --- a/crates/ui-egui/src/context_menu_tests.rs +++ b/crates/ui-egui/src/context_menu_tests.rs @@ -55,6 +55,23 @@ fn the_context_menu_groups_the_copy_commands_under_copy_as() { assert!(u.app.ui.context_menu.is_none(), "the menu closes"); } +#[test] +fn the_context_menu_offers_go_to_main_for_an_instance() { + // Right-clicking an instance reaches its main component: the menu offers "Go to main + // component" instead of "Create component" (which is for plain layers). + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 60, "name": "Button"})).unwrap()); + let comp = id(&s.execute("component.create", &json!({"ids": [f]})).unwrap()); + let inst = id(&s.execute("instance.create", &json!({"component": comp, "x": 0, "y": 200})).unwrap()); + let mut u = Ui::new(s); + u.frame(vec![]); + u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), vec![inst])); + u.frame(vec![]); + let shapes = flat(&u.frame_output(vec![]).shapes); + assert!(has_text(&shapes, "Go to main component"), "an instance's menu reaches its main"); + assert!(!has_text(&shapes, "Create component"), "not offered for an instance"); +} + #[test] fn copying_a_link_with_nothing_selected_copies_nothing() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index bcc0bc77f..e16d2cfaf 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -98,10 +98,11 @@ 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, + /// The server key for online sharing, and the server address it was entered for (a server + /// that needs one; `None`: none). Passed to `collab.share` so a keyed server can be used + /// from the Share popover without the Publish dialog. The key goes out only to the server it + /// belongs to: switching the server in Preferences must not leak the old one to it. + pub share_key: Option<(String, 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 @@ -750,8 +751,14 @@ 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()); + // The key goes out only to the server it belongs to (the server in Preferences): changing + // the server must not send the old server's key to a new one. + if !local + && let Some((at, key)) = app.ui.live.share_key.as_ref() + && *at == server + && !key.is_empty() + { + params["key"] = json!(key); } app.run("collab.share", params); } @@ -770,12 +777,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). + // It is remembered for the server it was typed for, so it never leaks to another one. ui.horizontal(|ui| { widgets::label(ui, tr("Server key"), t.text); - 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(); + let shown = app.ui.live.share_key.as_ref().and_then(|(at, key)| (*at == server).then_some(key.as_str())).unwrap_or_default(); + if let Some(key) = widgets::secret_field_named(ui, egui::Id::unique("live_share_key"), shown, 200.0, tr("Server key")) { + let key: String = key.trim().chars().filter(|c| !c.is_whitespace() && !c.is_control()).take(128).collect(); + app.ui.live.share_key = if key.is_empty() { None } else { Some((server.trim().to_string(), key)) }; } }); if widgets::button(ui, tr("Publish to another server…"), false, true).clicked() { diff --git a/crates/ui-egui/src/live_share_tests.rs b/crates/ui-egui/src/live_share_tests.rs index 6c5bdb84d..a2ac63c2e 100644 --- a/crates/ui-egui/src/live_share_tests.rs +++ b/crates/ui-egui/src/live_share_tests.rs @@ -237,7 +237,7 @@ 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(); + h.app.ui.live.share_key = Some(("wss://share.example/".to_string(), "s3cret-key".to_string())); let shapes = h.frames(2); let dots = "•".repeat(10); assert!(has_text(&shapes, &dots) && !has_text(&shapes, "s3cret-key")); @@ -505,6 +505,22 @@ fn the_server_preference_resets_and_turns_online_sharing_off() { assert!(s.published.is_empty() && s.hosted.len() == 1, "shared on the local network"); } +#[test] +fn a_key_typed_for_one_server_is_not_sent_to_another() { + // The Server key field remembers the server it was typed for: it goes out only to that + // server. Changing the server in Preferences must not send the old server's key to the new + // one. (The key is set as the field editor would, bound to the server it was typed for.) + let (mut h, script) = harness(); + h.app.ui.live.share_key = Some(("wss://share.example/".to_string(), "KeyKeyKeyKeyKeyKeyKey0".to_string())); + h.frames(2); + // Change the server in Preferences to another one, then share: the old key must not go to it. + h.app.session.execute("pref.publishServer", &json!({"url": "wss://other.example/"})).unwrap(); + h.frames(2); + let shapes = open(&mut h); + click(&mut h, &shapes, "Start sharing"); + assert_eq!(script.lock().unwrap().published.last().unwrap().key, None, "the old server's key is not sent to the new server"); +} + #[test] fn an_open_popover_asks_for_no_frames() { let (mut h, script) = harness(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d3647bb05..af6de8c27 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1019,6 +1019,17 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { { entries.insert(at + 1, (label, "component.combineVariants")); } + // An instance's actions reach its main component: replace "Create component" with + // "Go to main component" (as in Figma's context menu). + if app + .session + .doc() + .ok() + .is_some_and(|doc| ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })))) + && let Some(at) = entries.iter().position(|e| e.1 == "component.create") + { + entries[at] = (tr("Go to main component"), "component.goToMain"); + } // A right-clicked ruler guide (selected, with no layers) can be removed. if !has_sel && app.session.ds().is_ok_and(|d| d.selected_guide().is_some()) { entries.splice(0..0, [(tr("Remove guide"), "guide.remove"), ("-", "")]);