fix(ui): 表单校验加固与对话框生命周期
- 端口 1~65535(0 不是可连接端口);接收上限/最大报文长度/主题别名上限拒 0——MQTT 5 规范定义为 Protocol Error,broker 直接断连且值会被持久化,表现为每次连接都失败 - 遗嘱主题 trim 后入库,并拒绝包含通配符 + / #(通配符只用于订阅过滤器) - 16 个被校验字段订阅输入变更:编辑即清空字段错误,红字不再滞后于实际输入(此前错误只在保存时登记,改好值后红字仍挂着) - 传输切换只按「上一个方式」的默认端口覆盖端口:与全部默认端口比较会把用户手输的 8084/8883 误判为默认值,WSS:9443 → TCP → WSS 往返丢端口 - 五个对话框 open() 统一加 has_active_dialog 守卫:对话框是栈式叠加,多入口重复 open 会压入第二个模态,同 id 控件在两层间串台 - 阿里云预设对话框表单预填即选中第一项:此前 selected=None,打开即保存会 push 一条内容相同的重复预设 - OptionDelegate::position 按框架契约实现(按值反查索引):恒返回 None 会让 set_selected_value 失效、下拉显示空 - 非 Windows 不渲染置顶按钮:set_topmost 恒 false,显示必然报错的按钮只会让人困惑 - 主题键名对齐 gpui-component 的 serde rename:font.size / radius.lg 此前被静默忽略,14px 紧凑排版从未生效(实际渲染 16px) - aliyun.rs DeviceName 空值校验;Cargo.toml 声明 Apache-2.0(与关于页一致)
This commit is contained in:
+30
-1
@@ -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", &"<redacted>")
|
||||
.field("group_id", &self.group_id)
|
||||
.field("device_id", &self.device_id)
|
||||
.field("product_key", &self.product_key)
|
||||
.field("device_secret", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -14,6 +14,38 @@ use super::*;
|
||||
impl MqttXApp {
|
||||
pub(super) fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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")
|
||||
|
||||
@@ -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::<u16>().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::<u16>().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<InputState>; 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<u16, String> {
|
||||
match s.trim().parse::<u16>() {
|
||||
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<Option<u32>, 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<Option<u32>, 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) }
|
||||
|
||||
@@ -52,10 +52,13 @@ macro_rules! text_field {
|
||||
impl AliyunDialog {
|
||||
fn new(app: Entity<MqttXApp>, presets: Vec<AliyunPreset>, window: &mut Window, cx: &mut Context<Self>) -> 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<MqttXApp>, 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, _, _| {
|
||||
|
||||
@@ -558,6 +558,11 @@ impl Render for PresetsDialog {
|
||||
}
|
||||
|
||||
pub fn open(app: Entity<MqttXApp>, 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<PresetsDialog> =
|
||||
cx.new(|cx| PresetsDialog::new(app, presets, window, cx));
|
||||
|
||||
@@ -470,6 +470,11 @@ impl Render for ResourceMonitorDialog {
|
||||
}
|
||||
|
||||
pub fn open(app: Entity<MqttXApp>, 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。
|
||||
|
||||
@@ -11,6 +11,11 @@ use crate::ui::app::MqttXApp;
|
||||
use crate::ui::widgets::KvEditor;
|
||||
|
||||
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||
// 对话框是栈式叠加:标题栏菜单、快捷键、行内按钮等多个入口都会调 open,
|
||||
// 不加守卫会压入第二个模态,同 id 的控件在两层之间串台
|
||||
if window.has_active_dialog(cx) {
|
||||
return;
|
||||
}
|
||||
let pairs: Vec<(String, String)> = app
|
||||
.read(cx)
|
||||
.variables
|
||||
|
||||
@@ -49,13 +49,17 @@ impl SearchableListDelegate for OptionDelegate {
|
||||
fn item(&self, ix: IndexPath) -> Option<&Self::Item> {
|
||||
self.items.get(ix.row)
|
||||
}
|
||||
fn position<V>(&self, _value: &V) -> Option<IndexPath>
|
||||
fn position<V>(&self, value: &V) -> Option<IndexPath>
|
||||
where
|
||||
Self::Item: SearchableListItem<Value = V>,
|
||||
V: PartialEq,
|
||||
{
|
||||
// 选中项统一通过 IndexPath 构造,无需按值反查
|
||||
None
|
||||
// 框架契约:按值反查索引,set_selected_value 与过滤视图都依赖它。
|
||||
// 返回 None 会导致下拉显示空、保存写默认值,这里必须如实实现。
|
||||
self.items
|
||||
.iter()
|
||||
.position(|i| i.value() == value)
|
||||
.map(IndexPath::new)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user