mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 11:26:50 -06:00
Merge pull request #1299 from mojtabajafarzadeh/fix/share-key-and-large-file-publish
ui: server key field in the Share popover (plus a longer publish reply timeout)
This commit is contained in:
@@ -72,6 +72,9 @@ sync_github_name = true
|
||||
[people.AganzeFelicite]
|
||||
sync_github_name = true
|
||||
|
||||
[people.mojtabajafarzadeh]
|
||||
real_name = "Mojtaba Jafarzadeh"
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Ws, String> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1256,6 +1256,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 corner smoothing to 60%" = "Set corner smoothing to 60%"
|
||||
"Set variable" = "Set variable"
|
||||
|
||||
@@ -1250,6 +1250,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"
|
||||
|
||||
@@ -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::<u16>()) {
|
||||
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,15 @@ 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::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();
|
||||
}
|
||||
});
|
||||
if widgets::button(ui, tr("Publish to another server…"), false, true).clicked() {
|
||||
open_publish(app);
|
||||
ui.close();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str, name: &str, secret: bool) -> Option<String> {
|
||||
cancel_retargeted_field_save(ui, id);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let editing_id = id.with("editing");
|
||||
let mut editing: Option<String> = 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) {
|
||||
|
||||
Reference in New Issue
Block a user