diff --git a/gpui-app/Cargo.toml b/gpui-app/Cargo.toml index ef38570..a09f2de 100644 --- a/gpui-app/Cargo.toml +++ b/gpui-app/Cargo.toml @@ -3,6 +3,7 @@ name = "mqttx-desktop" version = "1.0.0" edition.workspace = true description = "MQTT X —— 基于 GPUI 的跨平台 MQTT 调试客户端" +license = "Apache-2.0" default-run = "mqttx" [[bin]] diff --git a/gpui-app/src/aliyun.rs b/gpui-app/src/aliyun.rs index a494a5c..9fcbef9 100644 --- a/gpui-app/src/aliyun.rs +++ b/gpui-app/src/aliyun.rs @@ -44,7 +44,9 @@ impl AliyunAuthMode { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +// Debug 手写脱敏:AccessKeySecret 与 DeviceSecret 是长期凭据,一次 `{:?}` 格式化 +// 就会把它们送进 stderr 与 Sentry(panic 钩子会一并上报),因此不能 derive。 +#[derive(Clone, Serialize, Deserialize)] pub struct AliyunPreset { pub id: String, pub name: String, @@ -152,6 +154,10 @@ impl AliyunPreset { { return Err("Group ID、AccessKey ID 与 AccessKey Secret 不能为空".into()); } + // 空 DeviceName 会拼出 "group@@@" 这样的非法 clientId,服务端报错难懂 + if self.device_id.trim().is_empty() { + return Err("Device ID 不能为空".into()); + } conn.host = self.host(); let client_id = format!("{}@@@{}", self.group_id, self.device_id); let password = Self::signature(&self.access_key_secret, &client_id)?; @@ -164,6 +170,10 @@ impl AliyunPreset { if self.product_key.is_empty() || self.device_secret.is_empty() { return Err("一机一密需要 ProductKey 与 DeviceSecret (设备密钥)".into()); } + // 空 DeviceName 会拼出 "pk.|securemode=..." 与 "&pk",服务端报错难懂 + if self.device_id.trim().is_empty() { + return Err("一机一密需要 DeviceName(设备名)".into()); + } conn.host = self.host(); // 物联网平台「一机一密」直连三元组(见模块文档),DeviceName 复用 device_id let device_name = self.device_id.trim(); @@ -189,3 +199,22 @@ impl Default for AliyunPreset { Self::new() } } + +impl std::fmt::Debug for AliyunPreset { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AliyunPreset") + .field("id", &self.id) + .field("name", &self.name) + .field("group", &self.group) + .field("instance_id", &self.instance_id) + .field("region", &self.region) + .field("auth_mode", &self.auth_mode) + .field("access_key_id", &self.access_key_id) + .field("access_key_secret", &"") + .field("group_id", &self.group_id) + .field("device_id", &self.device_id) + .field("product_key", &self.product_key) + .field("device_secret", &"") + .finish() + } +} diff --git a/gpui-app/src/ui/app/linear-theme.json b/gpui-app/src/ui/app/linear-theme.json index f5b6966..6656f1f 100644 --- a/gpui-app/src/ui/app/linear-theme.json +++ b/gpui-app/src/ui/app/linear-theme.json @@ -3,9 +3,9 @@ { "name": "Linear Light", "mode": "light", - "font_size": 14, + "font.size": 14, "radius": 6, - "radius_lg": 10, + "radius.lg": 10, "colors": { "accent.background": "#eeeef0", "accent.foreground": "#0d0e10", @@ -211,9 +211,9 @@ { "name": "Linear Dark", "mode": "dark", - "font_size": 14, + "font.size": 14, "radius": 6, - "radius_lg": 10, + "radius.lg": 10, "colors": { "accent.background": "#1c1d1f", "accent.foreground": "#f7f8f8", diff --git a/gpui-app/src/ui/app/titlebar.rs b/gpui-app/src/ui/app/titlebar.rs index 2704ad7..c5637ba 100644 --- a/gpui-app/src/ui/app/titlebar.rs +++ b/gpui-app/src/ui/app/titlebar.rs @@ -14,6 +14,38 @@ use super::*; impl MqttXApp { pub(super) fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let app_weak = cx.entity().downgrade(); + // 非 Windows 平台 `set_topmost` 恒为 false,渲染一个必然报错的按钮只会让人困惑 + let pin_button = if cfg!(target_os = "windows") { + Some( + Button::new("pin-window") + .icon(if self.pinned { + IconName::PinOff + } else { + IconName::Pin + }) + .ghost() + .small() + .tooltip(if self.pinned { + "取消窗口置顶" + } else { + "窗口置顶" + }) + .on_click(cx.listener(|this, _, window, cx| { + let next = !this.pinned; + if crate::platform::set_topmost(window, next) { + this.pinned = next; + cx.notify(); + } else { + window.push_notification( + Notification::error("置顶操作失败"), + cx, + ); + } + })), + ) + } else { + None + }; TitleBar::new().child( h_flex() @@ -59,33 +91,7 @@ pub(super) fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context< this.open_connection_form(None, window, cx); })), ) - .child( - Button::new("pin-window") - .icon(if self.pinned { - IconName::PinOff - } else { - IconName::Pin - }) - .ghost() - .small() - .tooltip(if self.pinned { - "取消窗口置顶" - } else { - "窗口置顶" - }) - .on_click(cx.listener(|this, _, window, cx| { - let next = !this.pinned; - if crate::platform::set_topmost(window, next) { - this.pinned = next; - cx.notify(); - } else { - window.push_notification( - Notification::error("置顶操作失败"), - cx, - ); - } - })), - ) + .children(pin_button) .child(self.render_theme_toggle(cx)) .child( Button::new("more-menu") diff --git a/gpui-app/src/ui/connection_form.rs b/gpui-app/src/ui/connection_form.rs index c02b894..47afbf5 100644 --- a/gpui-app/src/ui/connection_form.rs +++ b/gpui-app/src/ui/connection_form.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::button::{Button, ButtonVariants as _}; -use gpui_kit::component::input::{Input, InputState}; +use gpui_kit::component::input::{Input, InputEvent, InputState}; use gpui_kit::component::select::{Select, SelectEvent, SelectState}; use gpui_kit::component::switch::Switch; use gpui_kit::component::{ @@ -240,7 +240,9 @@ impl ConnectionForm { // 切换传输方式时,把端口同步成该方式的默认端口,并刷新 WS 路径的按需显示。 // gpui 列表点击任意项(含当前项)都会发 Confirm,若不比对索引, // 打开下拉点一下当前传输就会把自定义端口静默改回默认值。 - let last_transport = std::cell::Cell::new(transport_idx); + // 记录"上一个传输方式"而非索引:端口只在仍是上一个方式的默认端口时才跟随, + // 与全部默认端口比较会把用户恰好手输的 8084/8883 等值误判为默认值而覆盖 + let last_transport = std::cell::Cell::new(TransportKind::ALL[transport_idx]); let port_for_sub = port.clone(); let sub_transport = cx.subscribe_in( &transport, @@ -250,17 +252,15 @@ impl ConnectionForm { && let Some(idx) = state.read(cx).selected_value().copied() && let Some(kind) = TransportKind::ALL.get(idx).copied() { - // 索引未变化:视为重新点选当前项,不覆盖用户输入的端口 - if last_transport.get() == idx { + // 未变化:视为重新点选当前项,不覆盖用户输入的端口 + if last_transport.get() == kind { return; } - // 端口只在仍是某个默认端口(而非用户自定义值)时才跟随切换: - // 否则 WSS:9443 → TCP → WSS 的往返会把 9443 静默丢失 - let current = port_for_sub.read(cx).value().trim().parse::().ok(); - let is_default = matches!(current, Some(p) if - TransportKind::ALL.iter().any(|k| k.default_port() == p)); - last_transport.set(idx); - if is_default { + // WSS:9443 → TCP → WSS 的往返不会丢失 9443 + let is_prev_default = port_for_sub.read(cx).value().trim().parse::().ok() + == Some(last_transport.get().default_port()); + last_transport.set(kind); + if is_prev_default { port_for_sub.update(cx, |p, cx| { p.set_value(kind.default_port().to_string(), window, cx); }); @@ -290,6 +290,42 @@ impl ConnectionForm { || c.maximum_packet_size.is_some() || c.topic_alias_maximum.is_some(); + // 任一被校验字段被编辑时清空字段错误:错误只在保存/测试时登记,若不随输入 + // 清除,用户改好值后红字仍然挂着,无从判断当前输入是否已合法 + let err_watch: [Entity; 16] = [ + name.clone(), + host.clone(), + port.clone(), + path.clone(), + client_id.clone(), + keep_alive.clone(), + conn_timeout.clone(), + max_reconnect.clone(), + session_expiry.clone(), + receive_max.clone(), + max_packet.clone(), + topic_alias.clone(), + ssl_ca.clone(), + ssl_client_cert.clone(), + ssl_client_key.clone(), + will_topic.clone(), + ]; + let mut err_subs = Vec::with_capacity(err_watch.len()); + for w in err_watch { + err_subs.push(cx.subscribe_in( + &w, + window, + move |this, _, ev: &InputEvent, _, cx| { + if matches!(ev, InputEvent::Change) + && !this.field_errors.borrow().is_empty() + { + this.field_errors.borrow_mut().clear(); + cx.notify(); + } + }, + )); + } + Self { engine, editing_id, @@ -334,7 +370,11 @@ impl ConnectionForm { show_ssl: ssl_configured, field_errors: RefCell::default(), testing: false, - _subs: vec![sub_transport, sub_protocol], + _subs: { + let mut subs = vec![sub_transport, sub_protocol]; + subs.extend(err_subs); + subs + }, } } @@ -364,6 +404,25 @@ impl ConnectionForm { .map_err(|_| format!("{label} 需要是 0~65535 的整数")) } + /// 端口:1~65535。0 不是可连接端口,放行只会在 CONNECT 阶段报出难以理解的错误。 + fn parse_port(s: &str) -> Result { + match s.trim().parse::() { + Ok(0) => Err("端口需要是 1~65535 的整数".into()), + Ok(p) => Ok(p), + Err(_) => Err("端口需要是 1~65535 的整数".into()), + } + } + + /// 同 `parse_opt_u32`,但 0 视为非法:MQTT 5 规范把 Receive Maximum / + /// Maximum Packet Size 为 0 定义为 Protocol Error,broker 会直接断开连接, + /// 且该值会被持久化,表现为"每次连接都失败"。 + fn parse_opt_positive_u32(s: &str, label: &str) -> Result, String> { + match Self::parse_opt_u32(s, label)? { + Some(0) => Err(format!("{label} 必须大于 0(0 是 MQTT 5 协议错误)")), + other => Ok(other), + } + } + fn parse_opt_u32(s: &str, label: &str) -> Result, String> { let s = s.trim(); if s.is_empty() { @@ -402,7 +461,7 @@ impl ConnectionForm { return Err(self.fail("host", "主机地址不能为空")); } let host = host.trim().to_string(); - let port = self.fail_as("port", Self::parse_u16(&self.val(&self.port, cx), "端口"))?; + let port = self.fail_as("port", Self::parse_port(&self.val(&self.port, cx)))?; let mut client_id = self.val(&self.client_id, cx); if client_id.trim().is_empty() { if !for_test { @@ -429,16 +488,16 @@ impl ConnectionForm { } }; let receive_maximum = self.fail_as("receive_max", (|| -> Result<_, String> { - Ok(Self::parse_opt_u32(&self.val(&self.receive_max, cx), "接收上限")? + Ok(Self::parse_opt_positive_u32(&self.val(&self.receive_max, cx), "接收上限")? .map(|v| u16::try_from(v).map_err(|_| "接收上限不能超过 65535".to_string())) .transpose()) })())??; let maximum_packet_size = self.fail_as( "max_packet", - Self::parse_opt_u32(&self.val(&self.max_packet, cx), "最大报文长度"), + Self::parse_opt_positive_u32(&self.val(&self.max_packet, cx), "最大报文长度"), )?; let topic_alias_maximum = self.fail_as("topic_alias", (|| -> Result<_, String> { - Ok(Self::parse_opt_u32(&self.val(&self.topic_alias, cx), "主题别名")? + Ok(Self::parse_opt_positive_u32(&self.val(&self.topic_alias, cx), "主题别名")? .map(|v| u16::try_from(v).map_err(|_| "主题别名上限不能超过 65535".to_string())) .transpose()) })())??; @@ -528,10 +587,18 @@ impl ConnectionForm { } let last_will = if self.will_enabled { - let topic = self.val(&self.will_topic, cx); - if topic.trim().is_empty() { + // 与其它字段一致:trim 后入库 + let topic = self.val(&self.will_topic, cx).trim().to_string(); + if topic.is_empty() { return Err(self.fail("will_topic", "遗嘱主题不能为空")); } + // 通配符只用于订阅过滤器;出现在 PUBLISH 主题上会被 broker 直接拒绝 + if topic.contains('+') || topic.contains('#') { + return Err(self.fail( + "will_topic", + "遗嘱主题不能包含通配符 + 或 #", + )); + } let opt_str = |v: String| { let t = v.trim().to_string(); if t.is_empty() { None } else { Some(t) } diff --git a/gpui-app/src/ui/dialogs/aliyun_dialog.rs b/gpui-app/src/ui/dialogs/aliyun_dialog.rs index 6a694af..64d3b70 100644 --- a/gpui-app/src/ui/dialogs/aliyun_dialog.rs +++ b/gpui-app/src/ui/dialogs/aliyun_dialog.rs @@ -52,10 +52,13 @@ macro_rules! text_field { impl AliyunDialog { fn new(app: Entity, presets: Vec, window: &mut Window, cx: &mut Context) -> Self { let first = presets.first().cloned().unwrap_or_else(AliyunPreset::new); + // 表单已用 first 预填:selected 也指向它,否则"打开即保存"会用相同的 + // 内容 push 出一条重复预设(selected=None 走新建分支、另生成 uuid) + let selected = (!presets.is_empty()).then_some(0); Self { app, presets, - selected: None, + selected, name: text_field!(cx, window, &first.name, "预设名称"), group: text_field!(cx, window, &first.group, "分组(可选)"), instance_id: text_field!(cx, window, &first.instance_id, "post-cn-xxxx"), @@ -365,6 +368,11 @@ impl Render for AliyunDialog { } pub fn open(app: Entity, window: &mut Window, cx: &mut App) { + // 对话框是栈式叠加:标题栏菜单、快捷键、行内按钮等多个入口都会调 open, + // 不加守卫会压入第二个模态,同 id 的控件在两层之间串台 + if window.has_active_dialog(cx) { + return; + } let presets = app.read(cx).aliyun_presets.clone(); let dialog_view = cx.new(|cx| AliyunDialog::new(app, presets, window, cx)); window.open_dialog(cx, move |dialog, _, _| { diff --git a/gpui-app/src/ui/dialogs/presets_dialog.rs b/gpui-app/src/ui/dialogs/presets_dialog.rs index bbf59f7..742ea8c 100644 --- a/gpui-app/src/ui/dialogs/presets_dialog.rs +++ b/gpui-app/src/ui/dialogs/presets_dialog.rs @@ -558,6 +558,11 @@ impl Render for PresetsDialog { } pub fn open(app: Entity, window: &mut Window, cx: &mut App) { + // 对话框是栈式叠加:标题栏菜单、快捷键、行内按钮等多个入口都会调 open, + // 不加守卫会压入第二个模态,同 id 的控件在两层之间串台 + if window.has_active_dialog(cx) { + return; + } let presets = app.read(cx).presets.clone(); let dialog_view: Entity = cx.new(|cx| PresetsDialog::new(app, presets, window, cx)); diff --git a/gpui-app/src/ui/dialogs/resource_dialog.rs b/gpui-app/src/ui/dialogs/resource_dialog.rs index cbe224a..eb2de99 100644 --- a/gpui-app/src/ui/dialogs/resource_dialog.rs +++ b/gpui-app/src/ui/dialogs/resource_dialog.rs @@ -470,6 +470,11 @@ impl Render for ResourceMonitorDialog { } pub fn open(app: Entity, window: &mut Window, cx: &mut App) { + // 对话框是栈式叠加:标题栏菜单、快捷键、行内按钮等多个入口都会调 open, + // 不加守卫会压入第二个模态,同 id 的控件在两层之间串台 + if window.has_active_dialog(cx) { + return; + } let dialog_view = cx.new(|cx| ResourceMonitorDialog::new(app, cx)); // 用弱引用刷新:对话框关闭、实体释放后 update 返回 Err,循环退出。 // 强引用的 update 在实体释放后会 panic。 diff --git a/gpui-app/src/ui/dialogs/variables_dialog.rs b/gpui-app/src/ui/dialogs/variables_dialog.rs index 67e8c9d..63e1216 100644 --- a/gpui-app/src/ui/dialogs/variables_dialog.rs +++ b/gpui-app/src/ui/dialogs/variables_dialog.rs @@ -11,6 +11,11 @@ use crate::ui::app::MqttXApp; use crate::ui::widgets::KvEditor; pub fn open(app: Entity, window: &mut Window, cx: &mut App) { + // 对话框是栈式叠加:标题栏菜单、快捷键、行内按钮等多个入口都会调 open, + // 不加守卫会压入第二个模态,同 id 的控件在两层之间串台 + if window.has_active_dialog(cx) { + return; + } let pairs: Vec<(String, String)> = app .read(cx) .variables diff --git a/gpui-app/src/ui/widgets/select.rs b/gpui-app/src/ui/widgets/select.rs index 7b6d9c3..518e605 100644 --- a/gpui-app/src/ui/widgets/select.rs +++ b/gpui-app/src/ui/widgets/select.rs @@ -49,13 +49,17 @@ impl SearchableListDelegate for OptionDelegate { fn item(&self, ix: IndexPath) -> Option<&Self::Item> { self.items.get(ix.row) } - fn position(&self, _value: &V) -> Option + fn position(&self, value: &V) -> Option where Self::Item: SearchableListItem, V: PartialEq, { - // 选中项统一通过 IndexPath 构造,无需按值反查 - None + // 框架契约:按值反查索引,set_selected_value 与过滤视图都依赖它。 + // 返回 None 会导致下拉显示空、保存写默认值,这里必须如实实现。 + self.items + .iter() + .position(|i| i.value() == value) + .map(IndexPath::new) } }