fix(app): OTA 信任链加固、引擎背压与协议正确性
OTA(update/):
- 解压前校验 7z 条目清单:sevenz-rust 0.6.1 的 decompress_file 直接 dest.join(entry.name()),不校验 ".."、绝对路径与盘符,恶意归档可越界写文件;现在要求恰好一个条目且名字与约定二进制一致(fail-closed)
- 无 .sha256 侧车时拒绝安装,不再静默跳过校验而 UI 仍宣称已校验
- 安装前重算暂存文件哈希;同意标记从仅版本号改为绑定「版本 + 哈希」
- 跨卷 rename 失败回退「复制 + fsync + 删源」(暂存在 APPDATA、exe 在安装目录,不同卷时必失败且报错误导)
- .old 回滚备份保留 7 天:原实现新版本首启即删,新版本一旦崩溃用户无任何回退路径
- 新增安装互斥锁:两实例同时安装会互删暂存包、争抢同一个 .old
- platform_triple 用 std::env::consts::ARCH:写死 x86_64 让 ARM 客户端永远挑不到产物
- pick_assets 按命名约定精确后缀匹配:非 Windows 下 ends_with("") 恒真,.tar.gz/.deb 会被当成可执行文件下载并替换自身
引擎(mqtt/):
- 事件通道改有界并分级:高频事件(消息/日志)满即丢并计数、每 1000 条提示一次;状态/订阅结果/发布回执异步补投。原无界通道在到达速率超过消费速率时内存无上界(单条最长 128KB)
- 重连改指数退避(1s→30s 封顶)+ 0~500ms 抖动,替代固定 2 秒的惊群式重试
- 测试连接的建连(读证书,阻塞 IO)移入 spawn_blocking,与 connect 一致
正确性与性能:
- session_params_changed 补齐 4 个 v5 CONNECT 属性(会话过期/接收上限/最大报文/主题别名),漏掉会让"重连后生效"提示静默失效;测试改逐字段防漏式断言
- ConnectionConfig / AliyunPreset 手写脱敏 Debug:password 与 AccessKeySecret/DeviceSecret 输出 <redacted>,杜绝一次 {:?} 把凭据送进 stderr 与 Sentry
- MqttRecord.user_properties 改 Arc<[(String,String)]>、应用日志改 Arc<LogEntry>:消息面板每帧 300 条、日志面板每帧 3000 条的深拷贝降为引用计数
- 阿里云 DeviceName 空值校验,避免拼出非法 clientId 后服务端报错难懂
This commit is contained in:
@@ -25,7 +25,7 @@ fn main() {
|
|||||||
// (「下次启动安装」),在进入 UI 前完成自替换并重启。
|
// (「下次启动安装」),在进入 UI 前完成自替换并重启。
|
||||||
update::cleanup_old();
|
update::cleanup_old();
|
||||||
if let Some(staged) = update::load_staged() {
|
if let Some(staged) = update::load_staged() {
|
||||||
if update::install_consent_matches(&staged.version) {
|
if update::install_consent_matches(&staged) {
|
||||||
match update::install_staged(&staged) {
|
match update::install_staged(&staged) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
// 新进程已拉起,当前进程立即退出
|
// 新进程已拉起,当前进程立即退出
|
||||||
|
|||||||
@@ -119,7 +119,9 @@ impl SslConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
// Debug 手写而非 derive:`{:?}` 一次格式化就会把 `password` 送进 stderr 与 Sentry
|
||||||
|
//(panic 钩子会连同载荷一起上报),见文件底部的实现。
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub struct ConnectionConfig {
|
pub struct ConnectionConfig {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -250,8 +252,9 @@ impl ConnectionConfig {
|
|||||||
|
|
||||||
/// 与另一份配置相比,影响 broker 会话建立的关键参数是否变化。
|
/// 与另一份配置相比,影响 broker 会话建立的关键参数是否变化。
|
||||||
/// 这些字段改动只有在重新建立连接后才会生效,用于提示用户重连。
|
/// 这些字段改动只有在重新建立连接后才会生效,用于提示用户重连。
|
||||||
/// WS 路径、遗嘱、TLS 配置、keep_alive 同样随 CONNECT 报文一次性生效,
|
/// WS 路径、遗嘱、TLS 配置、keep_alive 随 CONNECT 报文一次性生效;
|
||||||
/// 必须纳入比较。
|
/// MQTT 5 的四个 CONNECT 属性(会话过期/接收上限/最大报文/主题别名)同样如此,
|
||||||
|
/// 全部纳入比较——漏掉的字段会让"重连后生效"提示静默失效。
|
||||||
pub fn session_params_changed(&self, other: &ConnectionConfig) -> bool {
|
pub fn session_params_changed(&self, other: &ConnectionConfig) -> bool {
|
||||||
self.host != other.host
|
self.host != other.host
|
||||||
|| self.port != other.port
|
|| self.port != other.port
|
||||||
@@ -265,6 +268,10 @@ impl ConnectionConfig {
|
|||||||
|| self.keep_alive != other.keep_alive
|
|| self.keep_alive != other.keep_alive
|
||||||
|| self.last_will != other.last_will
|
|| self.last_will != other.last_will
|
||||||
|| self.ssl != other.ssl
|
|| self.ssl != other.ssl
|
||||||
|
|| self.session_expiry_interval != other.session_expiry_interval
|
||||||
|
|| self.receive_maximum != other.receive_maximum
|
||||||
|
|| self.maximum_packet_size != other.maximum_packet_size
|
||||||
|
|| self.topic_alias_maximum != other.topic_alias_maximum
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,4 +281,32 @@ impl Default for ConnectionConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 脱敏 Debug:`password` 以 `<redacted>` 输出。
|
||||||
|
/// 该结构在日志/panic 场景下可能被整体格式化(`{:?}`),derive 出的 Debug 会把
|
||||||
|
/// 明文密码带进 stderr 与 Sentry(main.rs 的 panic 钩子会上报),因此必须手写。
|
||||||
|
impl std::fmt::Debug for ConnectionConfig {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("ConnectionConfig")
|
||||||
|
.field("id", &self.id)
|
||||||
|
.field("name", &self.name)
|
||||||
|
.field("host", &self.host)
|
||||||
|
.field("port", &self.port)
|
||||||
|
.field("path", &self.path)
|
||||||
|
.field("protocol", &self.protocol)
|
||||||
|
.field("transport", &self.transport)
|
||||||
|
.field("client_id", &self.client_id)
|
||||||
|
.field("username", &self.username)
|
||||||
|
.field("password", &"<redacted>")
|
||||||
|
.field("clean_start", &self.clean_start)
|
||||||
|
.field("keep_alive", &self.keep_alive)
|
||||||
|
.field("auto_resubscribe", &self.auto_resubscribe)
|
||||||
|
.field("auto_reconnect", &self.auto_reconnect)
|
||||||
|
.field("auto_connect", &self.auto_connect)
|
||||||
|
.field("has_last_will", &self.last_will.is_some())
|
||||||
|
.field("group", &self.group)
|
||||||
|
.field("ssl", &"<redacted>")
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 订阅 ────────────────────────────────────────────────────────────────────
|
// ─── 订阅 ────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -94,7 +94,9 @@ pub struct MqttRecord {
|
|||||||
pub retain: bool,
|
pub retain: bool,
|
||||||
/// unix 毫秒
|
/// unix 毫秒
|
||||||
pub timestamp: i64,
|
pub timestamp: i64,
|
||||||
pub user_properties: Vec<(String, String)>,
|
/// Arc 共享:消息列表每帧克隆数百条记录,Vec<(String,String)> 的深拷贝是
|
||||||
|
/// 每帧堆分配的主要来源,改为 Arc 后 clone 只增加引用计数
|
||||||
|
pub user_properties: Arc<[(String, String)]>,
|
||||||
pub content_type: Option<String>,
|
pub content_type: Option<String>,
|
||||||
pub response_topic: Option<String>,
|
pub response_topic: Option<String>,
|
||||||
pub correlation_data: Option<String>,
|
pub correlation_data: Option<String>,
|
||||||
|
|||||||
@@ -300,6 +300,21 @@ mod tests {
|
|||||||
|
|
||||||
b.ssl.ca_file = "/tmp/ca.pem".into();
|
b.ssl.ca_file = "/tmp/ca.pem".into();
|
||||||
assert!(a.session_params_changed(&b), "TLS 配置改动应触发重连提示");
|
assert!(a.session_params_changed(&b), "TLS 配置改动应触发重连提示");
|
||||||
|
|
||||||
|
// MQTT 5 的 CONNECT 属性同样随报文一次性生效(此前遗漏,导致
|
||||||
|
// "配置已保存,重连后生效"的提示静默失效)
|
||||||
|
b = a.clone();
|
||||||
|
b.session_expiry_interval = a.session_expiry_interval + 1;
|
||||||
|
assert!(a.session_params_changed(&b), "会话过期间隔改动应触发重连提示");
|
||||||
|
b = a.clone();
|
||||||
|
b.receive_maximum = Some(10);
|
||||||
|
assert!(a.session_params_changed(&b), "接收上限改动应触发重连提示");
|
||||||
|
b = a.clone();
|
||||||
|
b.maximum_packet_size = Some(1024);
|
||||||
|
assert!(a.session_params_changed(&b), "最大报文长度改动应触发重连提示");
|
||||||
|
b = a.clone();
|
||||||
|
b.topic_alias_maximum = Some(5);
|
||||||
|
assert!(a.session_params_changed(&b), "主题别名上限改动应触发重连提示");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ring_record(seq: u64, payload_len: usize, raw_len: usize) -> MqttRecord {
|
fn ring_record(seq: u64, payload_len: usize, raw_len: usize) -> MqttRecord {
|
||||||
@@ -312,7 +327,7 @@ mod tests {
|
|||||||
qos: 0,
|
qos: 0,
|
||||||
retain: false,
|
retain: false,
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
user_properties: Vec::new(),
|
user_properties: Default::default(),
|
||||||
content_type: None,
|
content_type: None,
|
||||||
response_topic: None,
|
response_topic: None,
|
||||||
correlation_data: None,
|
correlation_data: None,
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ pub struct MqttEngine {
|
|||||||
topics: Mutex<HashMap<String, Arc<str>>>,
|
topics: Mutex<HashMap<String, Arc<str>>>,
|
||||||
tx: EventSender,
|
tx: EventSender,
|
||||||
seq: Arc<AtomicU64>,
|
seq: Arc<AtomicU64>,
|
||||||
|
/// 因 UI 队列满而被丢弃的高频事件数(消息/日志),仅在丢弃时递增
|
||||||
|
dropped: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn now_ms() -> i64 {
|
fn now_ms() -> i64 {
|
||||||
@@ -117,6 +119,7 @@ impl MqttEngine {
|
|||||||
topics: Mutex::new(HashMap::new()),
|
topics: Mutex::new(HashMap::new()),
|
||||||
tx,
|
tx,
|
||||||
seq: Arc::new(AtomicU64::new(1)),
|
seq: Arc::new(AtomicU64::new(1)),
|
||||||
|
dropped: AtomicU64::new(0),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,8 +127,45 @@ impl MqttEngine {
|
|||||||
self.seq.fetch_add(1, Ordering::Relaxed)
|
self.seq.fetch_add(1, Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 投递事件到 UI 线程。
|
||||||
|
///
|
||||||
|
/// 通道是**有界**的(容量由 UI 侧创建时决定)。UI 处理不过来时:
|
||||||
|
/// - 高频事件(收到的消息、日志)直接丢弃并累计计数——它们本身就受消息环形缓冲
|
||||||
|
/// 与日志上限约束,丢掉最多是"少看几条",但内存有了上界;
|
||||||
|
/// - 低频且影响正确性的事件(状态变化、订阅结果、发布回执)改为异步补投,
|
||||||
|
/// 既不阻塞引擎事件循环,也不会丢。
|
||||||
|
///
|
||||||
|
/// 修复前用的是无界通道:到达速率持续超过消费速率时队列无界增长,单条消息最长
|
||||||
|
/// 可携带 128KB payload,内存没有上界。
|
||||||
fn emit(&self, event: EngineEvent) {
|
fn emit(&self, event: EngineEvent) {
|
||||||
let _ = self.tx.try_send(event);
|
let droppable = matches!(event, EngineEvent::Message(_) | EngineEvent::Log(_));
|
||||||
|
match self.tx.try_send(event) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(smol::channel::TrySendError::Full(ev)) => {
|
||||||
|
if droppable {
|
||||||
|
let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
|
// 每丢弃 1000 条提示一次,避免用户误以为"消息不来了"
|
||||||
|
if dropped.is_multiple_of(1000) {
|
||||||
|
let _ = self.tx.try_send(EngineEvent::Log(LogEntry {
|
||||||
|
timestamp: now_ms(),
|
||||||
|
connection_id: String::new(),
|
||||||
|
level: LogLevel::Warn,
|
||||||
|
event: "events_dropped".into(),
|
||||||
|
message: format!(
|
||||||
|
"UI 处理不过来,已累计丢弃 {dropped} 条高频事件(消息/日志)"
|
||||||
|
),
|
||||||
|
details: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let tx = self.tx.clone();
|
||||||
|
self.runtime.spawn(async move {
|
||||||
|
let _ = tx.send(ev).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(smol::channel::TrySendError::Closed(_)) => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn log(&self, conn_id: &str, level: LogLevel, event: &str, message: String, details: Option<String>) {
|
fn log(&self, conn_id: &str, level: LogLevel, event: &str, message: String, details: Option<String>) {
|
||||||
@@ -532,8 +572,6 @@ impl MqttEngine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试连接:使用配置的连接超时(上限 30 秒,防止误填过大值长时间挂起),
|
|
||||||
/// 结果通过 channel 返回。
|
|
||||||
/// 在引擎的 tokio runtime 上调度阻塞任务(OTA 下载等),
|
/// 在引擎的 tokio runtime 上调度阻塞任务(OTA 下载等),
|
||||||
/// 结果经 smol 通道送回 GPUI 执行器;任务 panic 时接收端以空错误结束。
|
/// 结果经 smol 通道送回 GPUI 执行器;任务 panic 时接收端以空错误结束。
|
||||||
pub fn run_blocking<T: Send + 'static>(
|
pub fn run_blocking<T: Send + 'static>(
|
||||||
@@ -565,6 +603,8 @@ impl MqttEngine {
|
|||||||
arc
|
arc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 测试连接:使用配置的连接超时(上限 30 秒,防止误填过大值长时间挂起),
|
||||||
|
/// 结果通过 channel 返回。
|
||||||
pub fn test_connection(
|
pub fn test_connection(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
cfg: ConnectionConfig,
|
cfg: ConnectionConfig,
|
||||||
@@ -626,3 +666,14 @@ fn connection_timeout_secs(cfg: &ConnectionConfig) -> u64 {
|
|||||||
u64::from(cfg.connection_timeout_secs.max(1))
|
u64::from(cfg.connection_timeout_secs.max(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 重连退避:指数增长(1s、2s、4s…,封顶 30 秒)并叠加 0~500ms 抖动。
|
||||||
|
///
|
||||||
|
/// 原来固定 2 秒:broker 长时间不可用时会以固定频率持续冲击,多连接还会在同一时刻
|
||||||
|
/// 齐步重连(惊群);抖动让各连接的退避点错开。
|
||||||
|
fn reconnect_delay(attempt: u32) -> Duration {
|
||||||
|
let exp = attempt.saturating_sub(1).min(5);
|
||||||
|
let secs = (1u64 << exp).min(30);
|
||||||
|
let jitter_ms = (uuid::Uuid::new_v4().as_u128() % 500) as u64;
|
||||||
|
Duration::from_millis(secs * 1000 + jitter_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-8
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use rumqttc::mqttbytes::v5::{
|
use rumqttc::mqttbytes::v5::{
|
||||||
ConnectReturnCode as V5ReturnCode, Packet as V5Packet,
|
ConnectReturnCode as V5ReturnCode, Packet as V5Packet,
|
||||||
@@ -103,7 +102,12 @@ pub(super) async fn test_handshake(cfg_in: &ConnectionConfig) -> Result<(), Stri
|
|||||||
cfg.client_id = format!("mqttx_test_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
|
cfg.client_id = format!("mqttx_test_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
|
||||||
let cfg = &cfg;
|
let cfg = &cfg;
|
||||||
if cfg.protocol.is_v5() {
|
if cfg.protocol.is_v5() {
|
||||||
let (client, mut eventloop) = build_v5(cfg)?;
|
// 建 client 会读 CA/证书文件(阻塞 IO):与 connect 保持一致放 spawn_blocking,
|
||||||
|
// 避免占住 2 线程引擎 runtime 的 worker
|
||||||
|
let owned = cfg.clone();
|
||||||
|
let (client, mut eventloop) = tokio::task::spawn_blocking(move || build_v5(&owned))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("内部任务失败: {e}"))??;
|
||||||
loop {
|
loop {
|
||||||
match eventloop.poll().await {
|
match eventloop.poll().await {
|
||||||
Ok(V5Event::Incoming(V5Packet::ConnAck(ack))) => {
|
Ok(V5Event::Incoming(V5Packet::ConnAck(ack))) => {
|
||||||
@@ -129,7 +133,11 @@ pub(super) async fn test_handshake(cfg_in: &ConnectionConfig) -> Result<(), Stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let (client, mut eventloop) = build_v4(cfg)?;
|
// 同上:v4 分支同样把阻塞的建连放到阻塞线程池
|
||||||
|
let owned = cfg.clone();
|
||||||
|
let (client, mut eventloop) = tokio::task::spawn_blocking(move || build_v4(&owned))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("内部任务失败: {e}"))??;
|
||||||
loop {
|
loop {
|
||||||
match eventloop.poll().await {
|
match eventloop.poll().await {
|
||||||
Ok(rv4::Event::Incoming(rv4::Packet::ConnAck(ack))) => {
|
Ok(rv4::Event::Incoming(rv4::Packet::ConnAck(ack))) => {
|
||||||
@@ -193,7 +201,7 @@ pub(super) fn spawn_v4_loop(
|
|||||||
},
|
},
|
||||||
retain: p.retain,
|
retain: p.retain,
|
||||||
timestamp: now_ms(),
|
timestamp: now_ms(),
|
||||||
user_properties: Vec::new(),
|
user_properties: Default::default(),
|
||||||
content_type: None,
|
content_type: None,
|
||||||
response_topic: None,
|
response_topic: None,
|
||||||
correlation_data: None,
|
correlation_data: None,
|
||||||
@@ -294,13 +302,14 @@ pub(super) fn spawn_v4_loop(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
reconnect_attempts += 1;
|
reconnect_attempts += 1;
|
||||||
|
let delay = reconnect_delay(reconnect_attempts);
|
||||||
engine.log(&id, LogLevel::Warn, "connection_error",
|
engine.log(&id, LogLevel::Warn, "connection_error",
|
||||||
format!("连接错误,2 秒后重连(第 {reconnect_attempts} 次): {e}"), None);
|
format!("连接错误,{:.1} 秒后重连(第 {reconnect_attempts} 次): {e}", delay.as_secs_f32()), None);
|
||||||
// 退避等待也必须可取消:否则 close() 的取消信号最长
|
// 退避等待也必须可取消:否则 close() 的取消信号要等一整个
|
||||||
// 2 秒不可见,期间会对用户已关闭的连接继续重连。
|
// 退避周期才可见,期间会对用户已关闭的连接继续重连。
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = cancel_rx.changed() => break,
|
_ = cancel_rx.changed() => break,
|
||||||
_ = tokio::time::sleep(Duration::from_secs(2)) => {}
|
_ = tokio::time::sleep(delay) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ fn v5_record(engine: &MqttEngine, seq: u64, conn_id: &str, p: &V5Publish) -> Mqt
|
|||||||
retain: p.retain,
|
retain: p.retain,
|
||||||
timestamp: now_ms(),
|
timestamp: now_ms(),
|
||||||
user_properties: props
|
user_properties: props
|
||||||
.map(|p| p.user_properties.clone())
|
.map(|p| Arc::from(p.user_properties.clone()))
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
content_type: props.and_then(|p| p.content_type.clone()),
|
content_type: props.and_then(|p| p.content_type.clone()),
|
||||||
response_topic: props.and_then(|p| p.response_topic.clone()),
|
response_topic: props.and_then(|p| p.response_topic.clone()),
|
||||||
@@ -331,13 +331,14 @@ pub(super) fn spawn_v5_loop(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
reconnect_attempts += 1;
|
reconnect_attempts += 1;
|
||||||
|
let delay = reconnect_delay(reconnect_attempts);
|
||||||
engine.log(&id, LogLevel::Warn, "connection_error",
|
engine.log(&id, LogLevel::Warn, "connection_error",
|
||||||
format!("连接错误,2 秒后重连(第 {reconnect_attempts} 次): {e}"), None);
|
format!("连接错误,{:.1} 秒后重连(第 {reconnect_attempts} 次): {e}", delay.as_secs_f32()), None);
|
||||||
// 退避等待也必须可取消:否则 close() 的取消信号最长
|
// 退避等待也必须可取消:否则 close() 的取消信号要等一整个
|
||||||
// 2 秒不可见,期间会对用户已关闭的连接继续重连。
|
// 退避周期才可见,期间会对用户已关闭的连接继续重连。
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = cancel_rx.changed() => break,
|
_ = cancel_rx.changed() => break,
|
||||||
_ = tokio::time::sleep(Duration::from_secs(2)) => {}
|
_ = tokio::time::sleep(delay) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ pub(super) fn on_engine_event(&mut self, event: EngineEvent, window: &mut Window
|
|||||||
qos,
|
qos,
|
||||||
retain,
|
retain,
|
||||||
timestamp: chrono::Local::now().timestamp_millis(),
|
timestamp: chrono::Local::now().timestamp_millis(),
|
||||||
user_properties,
|
user_properties: Arc::from(user_properties),
|
||||||
content_type,
|
content_type,
|
||||||
response_topic,
|
response_topic,
|
||||||
correlation_data,
|
correlation_data,
|
||||||
@@ -185,7 +185,7 @@ pub(super) fn on_engine_event(&mut self, event: EngineEvent, window: &mut Window
|
|||||||
if self.logs.len() >= MAX_LOGS {
|
if self.logs.len() >= MAX_LOGS {
|
||||||
self.logs.pop_front();
|
self.logs.pop_front();
|
||||||
}
|
}
|
||||||
self.logs.push_back(entry);
|
self.logs.push_back(Arc::new(entry));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 追加写入按日切分的日志文件 `mqttx-YYYY-MM-DD.log`。
|
/// 追加写入按日切分的日志文件 `mqttx-YYYY-MM-DD.log`。
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ mod titlebar;
|
|||||||
use theme::apply_theme;
|
use theme::apply_theme;
|
||||||
|
|
||||||
const MAX_LOGS: usize = 3000;
|
const MAX_LOGS: usize = 3000;
|
||||||
|
/// 引擎 → UI 的事件队列容量(有界,见 `MqttEngine::emit` 的丢弃策略)
|
||||||
|
const EVENT_QUEUE_CAPACITY: usize = 1024;
|
||||||
|
|
||||||
// 应用级快捷键动作(context=None:任意焦点状态下都匹配)。
|
// 应用级快捷键动作(context=None:任意焦点状态下都匹配)。
|
||||||
gpui_kit::actions!(mqttx, [NewConnection, OpenSettings, ExportConnections, ImportConnections, OpenResourceMonitor]);
|
gpui_kit::actions!(mqttx, [NewConnection, OpenSettings, ExportConnections, ImportConnections, OpenResourceMonitor]);
|
||||||
@@ -92,7 +94,9 @@ pub struct MqttXApp {
|
|||||||
pub errors: HashMap<String, String>,
|
pub errors: HashMap<String, String>,
|
||||||
/// 每连接消息环形缓冲:键为驻留后的连接 ID,条数 + 字节预算双重约束
|
/// 每连接消息环形缓冲:键为驻留后的连接 ID,条数 + 字节预算双重约束
|
||||||
pub messages: HashMap<Arc<str>, MessageRing>,
|
pub messages: HashMap<Arc<str>, MessageRing>,
|
||||||
pub logs: VecDeque<LogEntry>,
|
/// Arc 共享:日志面板每帧最多克隆 3000 条,深拷贝会让渲染线程每秒
|
||||||
|
/// 产生数十万次 String 堆分配;改 Arc 后仅增加引用计数
|
||||||
|
pub logs: VecDeque<Arc<LogEntry>>,
|
||||||
pub seq: u64,
|
pub seq: u64,
|
||||||
|
|
||||||
// ── 标签与视图 ──
|
// ── 标签与视图 ──
|
||||||
@@ -126,7 +130,9 @@ impl MqttXApp {
|
|||||||
|
|
||||||
apply_theme(settings.theme, cx);
|
apply_theme(settings.theme, cx);
|
||||||
|
|
||||||
let (tx, rx) = smol::channel::unbounded();
|
// 有界事件队列:引擎侧对高频事件(消息/日志)做丢弃并计数,保证 UI 消费
|
||||||
|
// 不过来时内存有上界。1024 × 单条最大 128KB ≈ 最坏 128MB,常规消息下几百 KB。
|
||||||
|
let (tx, rx) = smol::channel::bounded(EVENT_QUEUE_CAPACITY);
|
||||||
let engine = MqttEngine::new(tx);
|
let engine = MqttEngine::new(tx);
|
||||||
|
|
||||||
let weak = cx.entity().downgrade();
|
let weak = cx.entity().downgrade();
|
||||||
@@ -163,7 +169,7 @@ impl MqttXApp {
|
|||||||
window.open_dialog(cx, move |dialog, _, _cx| {
|
window.open_dialog(cx, move |dialog, _, _cx| {
|
||||||
let staged = staged.clone();
|
let staged = staged.clone();
|
||||||
let staged_skip = staged.clone();
|
let staged_skip = staged.clone();
|
||||||
let ver = staged.version.clone();
|
let staged_consent = staged.clone();
|
||||||
dialog
|
dialog
|
||||||
.w(px(440.))
|
.w(px(440.))
|
||||||
.title("更新就绪")
|
.title("更新就绪")
|
||||||
@@ -198,7 +204,7 @@ impl MqttXApp {
|
|||||||
.on_click(move |_, window, cx| {
|
.on_click(move |_, window, cx| {
|
||||||
// 同意标记:启动时据此自动安装;
|
// 同意标记:启动时据此自动安装;
|
||||||
// 用户跳过对话框则不会强制升级
|
// 用户跳过对话框则不会强制升级
|
||||||
update::mark_install_consent(&ver);
|
update::mark_install_consent(&staged_consent);
|
||||||
window.close_dialog(cx);
|
window.close_dialog(cx);
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ pub(super) fn render_top_bar(&self, cx: &mut Context<Self>) -> impl IntoElement
|
|||||||
&self,
|
&self,
|
||||||
record: &MqttRecord,
|
record: &MqttRecord,
|
||||||
sub_color: Option<f32>,
|
sub_color: Option<f32>,
|
||||||
|
show_millis: bool,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> impl IntoElement {
|
) -> impl IntoElement {
|
||||||
let seq = record.seq;
|
let seq = record.seq;
|
||||||
@@ -150,9 +151,6 @@ pub(super) fn render_top_bar(&self, cx: &mut Context<Self>) -> impl IntoElement
|
|||||||
let muted = cx.theme().muted_foreground;
|
let muted = cx.theme().muted_foreground;
|
||||||
let mono = cx.theme().mono_font_family.clone();
|
let mono = cx.theme().mono_font_family.clone();
|
||||||
let direction_label = if received { "接收" } else { "发布" };
|
let direction_label = if received { "接收" } else { "发布" };
|
||||||
let show_millis = self
|
|
||||||
.with_app(cx, |app| app.settings.show_millis)
|
|
||||||
.unwrap_or(true);
|
|
||||||
|
|
||||||
let header = h_flex()
|
let header = h_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
@@ -516,9 +514,12 @@ pub(super) fn render_messages(&self, cx: &mut Context<Self>) -> impl IntoElement
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 最新消息在最上方(与 MQTTX 一致,避免长列表需要手动滚底)
|
// 最新消息在最上方(与 MQTTX 一致,避免长列表需要手动滚底)
|
||||||
|
let show_millis = self
|
||||||
|
.with_app(cx, |app| app.settings.show_millis)
|
||||||
|
.unwrap_or(true);
|
||||||
for r in &records {
|
for r in &records {
|
||||||
let color = pick_subscription_color(&subs, &r.topic);
|
let color = pick_subscription_color(&subs, &r.topic);
|
||||||
body = body.child(self.render_message_row(r, color, cx));
|
body = body.child(self.render_message_row(r, color, show_millis, cx));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ pub(super) fn details_copy_text(record: &MqttRecord, show_millis: bool) -> Strin
|
|||||||
}
|
}
|
||||||
if !record.user_properties.is_empty() {
|
if !record.user_properties.is_empty() {
|
||||||
out.push_str("User Properties:\n");
|
out.push_str("User Properties:\n");
|
||||||
for (k, v) in &record.user_properties {
|
for (k, v) in record.user_properties.iter() {
|
||||||
out.push_str(&format!(" {k} = {v}\n"));
|
out.push_str(&format!(" {k} = {v}\n"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,7 +253,8 @@ impl SettingsDialog {
|
|||||||
.on_click(cx.listener(|this, _, window, cx| {
|
.on_click(cx.listener(|this, _, window, cx| {
|
||||||
// 写入同意标记:下次启动据此自动安装
|
// 写入同意标记:下次启动据此自动安装
|
||||||
if let UpdateUi::ReadyToInstall(staged) = &this.update {
|
if let UpdateUi::ReadyToInstall(staged) = &this.update {
|
||||||
update::mark_install_consent(&staged.version);
|
// 同意标记绑定版本 + 暂存文件哈希
|
||||||
|
update::mark_install_consent(staged);
|
||||||
}
|
}
|
||||||
this.update = UpdateUi::Idle;
|
this.update = UpdateUi::Idle;
|
||||||
window.push_notification(
|
window.push_notification(
|
||||||
@@ -535,6 +536,11 @@ impl Render for SettingsDialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||||
|
// 对话框是栈式叠加:标题栏菜单、快捷键、行内按钮等多个入口都会调 open,
|
||||||
|
// 不加守卫会压入第二个模态,同 id 的控件在两层之间串台
|
||||||
|
if window.has_active_dialog(cx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let (engine, initial, data_dir, log_dir) = {
|
let (engine, initial, data_dir, log_dir) = {
|
||||||
let state = app.read(cx);
|
let state = app.read(cx);
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -23,26 +23,24 @@ pub(super) fn http_get_json(url: &str) -> Result<serde_json::Value, String> {
|
|||||||
|
|
||||||
pub fn pick_assets(rel: &serde_json::Value) -> Option<(String, String, u64, Option<String>)> {
|
pub fn pick_assets(rel: &serde_json::Value) -> Option<(String, String, u64, Option<String>)> {
|
||||||
let assets = rel.get("assets")?.as_array()?;
|
let assets = rel.get("assets")?.as_array()?;
|
||||||
let want_ext = if cfg!(windows) { ".exe" } else { "" };
|
|
||||||
let triple = platform_triple();
|
let triple = platform_triple();
|
||||||
|
// 产物命名约定:`mqttx-v<ver>-<triple>-mqttx[.exe]`(压缩包再加 `.7z`)。
|
||||||
|
// 必须按固定后缀精确匹配:非 Windows 下扩展名为空串时 `ends_with("")` 恒真,
|
||||||
|
// 任何含三元组的附件(.tar.gz/.deb/.txt)都会被当成可执行文件下载并替换自身。
|
||||||
|
let bin_name = if cfg!(windows) { "mqttx.exe" } else { "mqttx" };
|
||||||
|
|
||||||
// (后缀过滤器, 说明):第一轮 7z,第二轮裸二进制
|
// 第一轮 7z 压缩包,第二轮裸二进制
|
||||||
for suffix in [".7z", want_ext] {
|
for suffix in [".7z", ""] {
|
||||||
for a in assets {
|
for a in assets {
|
||||||
let Some(name) = a.get("name").and_then(|n| n.as_str()) else {
|
let Some(name) = a.get("name").and_then(|n| n.as_str()) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if !name.contains(triple)
|
if !name.contains(triple.as_str())
|
||||||
|| !name.ends_with(suffix)
|
|| !name.ends_with(&format!("{bin_name}{suffix}"))
|
||||||
|| name.ends_with(".sha256")
|
|| name.ends_with(".sha256")
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 7z 轮要排除裸二进制(windows 裸 exe 不以 .7z 结尾,天然互斥)
|
|
||||||
if suffix == ".7z" && !name.ends_with(&format!("{want_ext}.7z")) && !want_ext.is_empty()
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some(url) = a.get("browser_download_url").and_then(|u| u.as_str()) else {
|
let Some(url) = a.get("browser_download_url").and_then(|u| u.as_str()) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|||||||
+225
-25
@@ -1,7 +1,7 @@
|
|||||||
//! 更新执行:下载暂存(7z 优先)→ sha256 校验 → 重启自替换安装,以及同意标记与旧档清理。
|
//! 更新执行:下载暂存(7z 优先)→ sha256 校验 → 重启自替换安装,以及同意标记与旧档清理。
|
||||||
|
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
|
||||||
use super::check::agent;
|
use super::check::agent;
|
||||||
@@ -55,6 +55,14 @@ pub fn download_and_stage(
|
|||||||
|
|
||||||
// 清掉其他版本的残留暂存文件,避免堆积与误装旧版
|
// 清掉其他版本的残留暂存文件,避免堆积与误装旧版
|
||||||
let staged_path = if is_7z {
|
let staged_path = if is_7z {
|
||||||
|
// 安全前置校验:sevenz-rust 0.6.1 的 decompress_file 直接把 entry.name()
|
||||||
|
// 拼到目标目录(de_funcs.rs: dest.join(entry.name())),不校验 ".."、
|
||||||
|
// 绝对路径与盘符,还会为条目创建父目录,恶意归档足以越界写文件。
|
||||||
|
// 打包端约定归档内只有 binary_name 一个条目,因此这里要求完全一致。
|
||||||
|
if let Err(e) = verify_archive_entries(&tmp, &binary_name) {
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
// 解压到暂存目录,取出二进制后删掉压缩包
|
// 解压到暂存目录,取出二进制后删掉压缩包
|
||||||
if let Err(e) = sevenz_rust::decompress_file(&tmp, &dir) {
|
if let Err(e) = sevenz_rust::decompress_file(&tmp, &dir) {
|
||||||
let _ = std::fs::remove_file(&tmp);
|
let _ = std::fs::remove_file(&tmp);
|
||||||
@@ -76,12 +84,157 @@ pub fn download_and_stage(
|
|||||||
std::fs::rename(&tmp, &dst).map_err(|e| format!("暂存失败: {e}"))?;
|
std::fs::rename(&tmp, &dst).map_err(|e| format!("暂存失败: {e}"))?;
|
||||||
dst
|
dst
|
||||||
};
|
};
|
||||||
|
// 非 Windows 平台补可执行位:File::create 落盘默认 0644,rename 保留权限,
|
||||||
|
// 直接 spawn 会 EACCES —— 这正是 Linux/macOS 自更新必定失败的原因。
|
||||||
|
if let Err(e) = make_executable(&staged_path) {
|
||||||
|
let _ = std::fs::remove_file(&staged_path);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
// 记录最终二进制的哈希:安装同意标记与安装前复核都绑定它
|
||||||
|
let sha256 = file_sha256(&staged_path)?;
|
||||||
Ok(StagedUpdate {
|
Ok(StagedUpdate {
|
||||||
version: info.version.clone(),
|
version: info.version.clone(),
|
||||||
path: staged_path,
|
path: staged_path,
|
||||||
|
sha256,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 计算文件的 sha256(小写 hex)。
|
||||||
|
fn file_sha256(path: &Path) -> Result<String, String> {
|
||||||
|
use sha2::Digest as _;
|
||||||
|
let mut file =
|
||||||
|
std::fs::File::open(path).map_err(|e| format!("打开 {} 失败: {e}", path.display()))?;
|
||||||
|
let mut hasher = sha2::Sha256::new();
|
||||||
|
let mut buf = [0u8; 64 * 1024];
|
||||||
|
loop {
|
||||||
|
let n = file
|
||||||
|
.read(&mut buf)
|
||||||
|
.map_err(|e| format!("读取 {} 失败: {e}", path.display()))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buf[..n]);
|
||||||
|
}
|
||||||
|
Ok(format!("{:x}", hasher.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验 7z 归档的条目清单(只读归档头,不解压数据)。
|
||||||
|
///
|
||||||
|
/// 依赖 sevenz-rust 0.6.1 的 `decompress_file` 会把 `entry.name()` 直接拼到目标目录
|
||||||
|
/// (`de_funcs.rs: dest.join(entry.name())`),既不校验 `..`、绝对路径与盘符,还会为
|
||||||
|
/// 条目自动创建父目录——带恶意条目的压缩包足以写出暂存目录之外(如启动目录),
|
||||||
|
/// 随后被 `install_staged` 执行。这里在解压**之前**按归档头校验条目:必须恰好一个,
|
||||||
|
/// 且名字与约定文件名完全一致,任何偏差都拒绝(fail-closed)。
|
||||||
|
fn verify_archive_entries(archive: &Path, expected: &str) -> Result<(), String> {
|
||||||
|
let file = std::fs::File::open(archive).map_err(|e| format!("打开更新包失败: {e}"))?;
|
||||||
|
let len = file
|
||||||
|
.metadata()
|
||||||
|
.map_err(|e| format!("读取更新包大小失败: {e}"))?
|
||||||
|
.len();
|
||||||
|
let reader = sevenz_rust::SevenZReader::new(file, len, sevenz_rust::Password::empty())
|
||||||
|
.map_err(|e| format!("解析更新包失败: {e}"))?;
|
||||||
|
let files = &reader.archive().files;
|
||||||
|
if files.len() != 1 {
|
||||||
|
return Err(format!(
|
||||||
|
"更新包应恰好包含 1 个条目 '{expected}',实际 {} 个",
|
||||||
|
files.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let entry = &files[0];
|
||||||
|
let name = entry.name();
|
||||||
|
if entry.is_directory() || name != expected || name.contains('/') || name.contains('\\') {
|
||||||
|
return Err(format!(
|
||||||
|
"更新包内容异常:条目 '{name}' 不符合约定(期望仅 '{expected}')"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unix 下补可执行位(Windows 无此概念,空实现)。
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn make_executable(path: &Path) -> Result<(), String> {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
let mode = std::fs::metadata(path)
|
||||||
|
.map_err(|e| format!("读取文件权限失败: {e}"))?
|
||||||
|
.permissions()
|
||||||
|
.mode();
|
||||||
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode | 0o755))
|
||||||
|
.map_err(|e| format!("设置可执行权限失败: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn make_executable(_path: &Path) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把新版本文件放到目标位置。
|
||||||
|
///
|
||||||
|
/// 暂存在数据目录(`%APPDATA%` / `~/.local/share`),可执行文件在安装目录,二者可能
|
||||||
|
/// 不同卷:Windows 的 `MoveFile` 与 Unix 的 `rename` 跨卷都会失败,因此失败时回退为
|
||||||
|
/// 「复制 + 刷盘 + 删除源文件」,而不是直接报"可能无写入权限"。
|
||||||
|
fn place_file(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||||
|
match std::fs::rename(src, dst) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(_) => {
|
||||||
|
let mut input = std::fs::File::open(src)?;
|
||||||
|
let mut output = std::fs::File::create(dst)?;
|
||||||
|
std::io::copy(&mut input, &mut output)?;
|
||||||
|
output.sync_all()?;
|
||||||
|
drop(output);
|
||||||
|
let _ = std::fs::remove_file(src);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 安装互斥锁:暂存目录下的 `install.lock`。
|
||||||
|
///
|
||||||
|
/// 用 `create_new` 原子抢占;超过 10 分钟的锁视为上次异常退出(`exit` 不跑 Drop)
|
||||||
|
/// 留下的陈旧锁,可被接管。两个实例同时安装会互删暂存包、争抢同一个 `.old`。
|
||||||
|
struct InstallLock {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InstallLock {
|
||||||
|
fn acquire() -> Result<Self, String> {
|
||||||
|
let path = stage_dir()?.join("install.lock");
|
||||||
|
let stale_after = std::time::Duration::from_secs(10 * 60);
|
||||||
|
for attempt in 0..2 {
|
||||||
|
match std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(&path)
|
||||||
|
{
|
||||||
|
Ok(mut f) => {
|
||||||
|
use std::io::Write as _;
|
||||||
|
let _ = write!(f, "{}", std::process::id());
|
||||||
|
return Ok(Self { path });
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||||
|
let is_stale = std::fs::metadata(&path)
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.elapsed().ok())
|
||||||
|
.is_some_and(|age| age > stale_after);
|
||||||
|
if is_stale && attempt == 0 {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Err("另一个实例正在安装更新,已跳过本次安装".into());
|
||||||
|
}
|
||||||
|
Err(e) => return Err(format!("创建安装锁失败: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("创建安装锁失败".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for InstallLock {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_file(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn download_to(info: &UpdateInfo, tmp: &std::path::Path, on_progress: &dyn Fn(u64, u64)) -> Result<(), String> {
|
fn download_to(info: &UpdateInfo, tmp: &std::path::Path, on_progress: &dyn Fn(u64, u64)) -> Result<(), String> {
|
||||||
let resp = agent()
|
let resp = agent()
|
||||||
.get(&info.asset_url)
|
.get(&info.asset_url)
|
||||||
@@ -115,14 +268,15 @@ fn download_to(info: &UpdateInfo, tmp: &std::path::Path, on_progress: &dyn Fn(u6
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// sha256 校验
|
// sha256 校验:缺侧车不再静默跳过——一次漏传就会让所有客户端在"零完整性校验"
|
||||||
if let Some(expect) = fetch_expected_sha256(info)? {
|
// 状态下安装,而 UI 仍宣称"已通过 sha256 校验"。发布脚本两侧都会生成侧车,
|
||||||
let actual = format!("{:x}", hasher.finalize());
|
// 因此这里 fail-closed 不会误伤正常发布。
|
||||||
if !actual.eq_ignore_ascii_case(&expect) {
|
let expect = fetch_expected_sha256(info)?.ok_or_else(|| {
|
||||||
return Err(format!(
|
"Release 未提供 .sha256 校验文件,无法验证完整性,已拒绝安装".to_string()
|
||||||
"校验失败:期望 sha256 {expect},实际 {actual}"
|
})?;
|
||||||
));
|
let actual = format!("{:x}", hasher.finalize());
|
||||||
}
|
if !actual.eq_ignore_ascii_case(&expect) {
|
||||||
|
return Err(format!("校验失败:期望 sha256 {expect},实际 {actual}"));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -138,7 +292,7 @@ fn remove_other_staged(dir: &std::path::Path, keep: &std::path::Path) {
|
|||||||
let name = e.file_name();
|
let name = e.file_name();
|
||||||
let name = name.to_string_lossy();
|
let name = name.to_string_lossy();
|
||||||
if name.starts_with("mqttx-v")
|
if name.starts_with("mqttx-v")
|
||||||
&& name.contains(platform_triple())
|
&& name.contains(platform_triple().as_str())
|
||||||
&& !name.ends_with(".tmp")
|
&& !name.ends_with(".tmp")
|
||||||
&& !name.ends_with(".sha256")
|
&& !name.ends_with(".sha256")
|
||||||
&& !name.ends_with(".7z")
|
&& !name.ends_with(".7z")
|
||||||
@@ -190,9 +344,14 @@ pub fn load_staged() -> Option<StagedUpdate> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if best.as_ref().is_none_or(|b| is_newer(&version, &b.version)) {
|
if best.as_ref().is_none_or(|b| is_newer(&version, &b.version)) {
|
||||||
|
// 启动扫描时重算哈希:安装同意标记绑定了它,缺了就无法判定"是否已同意"
|
||||||
|
let Ok(sha256) = file_sha256(&e.path()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
best = Some(StagedUpdate {
|
best = Some(StagedUpdate {
|
||||||
version,
|
version,
|
||||||
path: e.path(),
|
path: e.path(),
|
||||||
|
sha256,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,14 +361,27 @@ pub fn load_staged() -> Option<StagedUpdate> {
|
|||||||
/// 安装暂存的更新:替换自身并拉起新进程(旧文件改名 `.old`,
|
/// 安装暂存的更新:替换自身并拉起新进程(旧文件改名 `.old`,
|
||||||
/// 新进程启动时由 [`cleanup_old`] 清理)。成功后调用方应退出当前进程。
|
/// 新进程启动时由 [`cleanup_old`] 清理)。成功后调用方应退出当前进程。
|
||||||
pub fn install_staged(staged: &StagedUpdate) -> Result<(), String> {
|
pub fn install_staged(staged: &StagedUpdate) -> Result<(), String> {
|
||||||
|
// 安装前复核:从"下载校验通过"到"下次启动安装"之间,暂存文件可能被替换或损坏
|
||||||
|
let actual = file_sha256(&staged.path)?;
|
||||||
|
if !actual.eq_ignore_ascii_case(&staged.sha256) {
|
||||||
|
return Err(format!(
|
||||||
|
"暂存文件校验失败(可能被替换或损坏):期望 {},实际 {actual}",
|
||||||
|
staged.sha256
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let cur = std::env::current_exe().map_err(|e| format!("无法定位自身: {e}"))?;
|
let cur = std::env::current_exe().map_err(|e| format!("无法定位自身: {e}"))?;
|
||||||
let mut old_name = cur.clone().into_os_string();
|
let mut old_name = cur.clone().into_os_string();
|
||||||
old_name.push(".old");
|
old_name.push(".old");
|
||||||
let old = PathBuf::from(old_name);
|
let old = PathBuf::from(old_name);
|
||||||
|
|
||||||
|
// 安装互斥:两个实例同时安装会互删暂存包、争抢同一个 .old
|
||||||
|
let lock = InstallLock::acquire()?;
|
||||||
|
|
||||||
// 旧残留先清掉,避免改名失败
|
// 旧残留先清掉,避免改名失败
|
||||||
let _ = std::fs::remove_file(&old);
|
let _ = std::fs::remove_file(&old);
|
||||||
std::fs::rename(&cur, &old).map_err(|e| format!("替换失败(可能无写入权限): {e}"))?;
|
std::fs::rename(&cur, &old).map_err(|e| format!("替换失败(可能无写入权限): {e}"))?;
|
||||||
if let Err(e) = std::fs::rename(&staged.path, &cur) {
|
if let Err(e) = place_file(&staged.path, &cur) {
|
||||||
// 回滚,尽量保住当前可执行文件
|
// 回滚,尽量保住当前可执行文件
|
||||||
let rollback = std::fs::rename(&old, &cur);
|
let rollback = std::fs::rename(&old, &cur);
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -227,6 +399,8 @@ pub fn install_staged(staged: &StagedUpdate) -> Result<(), String> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
clear_install_consent();
|
clear_install_consent();
|
||||||
|
// 显式释放:调用方随后就 exit/quit,Drop 不保证执行,残留锁会让下次安装被误判为并发
|
||||||
|
drop(lock);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,10 +421,11 @@ fn consent_path() -> Result<PathBuf, String> {
|
|||||||
stage_dir().map(|d| d.join("install-consent.txt"))
|
stage_dir().map(|d| d.join("install-consent.txt"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 记录对指定版本的安装同意。
|
/// 记录安装同意:写入「版本 + 暂存文件 sha256」两行。
|
||||||
pub fn mark_install_consent(version: &str) {
|
/// 绑定哈希后,暂存文件被替换就不再被视为"用户已同意安装的那一份"。
|
||||||
|
pub fn mark_install_consent(staged: &StagedUpdate) {
|
||||||
if let Ok(p) = consent_path() {
|
if let Ok(p) = consent_path() {
|
||||||
let _ = std::fs::write(p, version);
|
let _ = std::fs::write(p, format!("{}\n{}\n", staged.version, staged.sha256));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,13 +436,18 @@ pub fn clear_install_consent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 同意标记是否与暂存的版本匹配(版本不匹配视为未同意)。
|
/// 同意标记是否与暂存的版本**与哈希**都匹配(任一不匹配视为未同意)。
|
||||||
pub fn install_consent_matches(staged_version: &str) -> bool {
|
pub fn install_consent_matches(staged: &StagedUpdate) -> bool {
|
||||||
consent_path()
|
let Some(p) = consent_path().ok() else {
|
||||||
.ok()
|
return false;
|
||||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
};
|
||||||
.map(|v| v.trim() == staged_version)
|
let Ok(text) = std::fs::read_to_string(p) else {
|
||||||
.unwrap_or(false)
|
return false;
|
||||||
|
};
|
||||||
|
let mut lines = text.lines();
|
||||||
|
let version_ok = lines.next().map(str::trim) == Some(staged.version.as_str());
|
||||||
|
let sha_ok = lines.next().map(str::trim) == Some(staged.sha256.as_str());
|
||||||
|
version_ok && sha_ok
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用户拒绝本次更新:删除暂存包与同意标记。
|
/// 用户拒绝本次更新:删除暂存包与同意标记。
|
||||||
@@ -276,22 +456,42 @@ pub fn discard_staged(staged: &StagedUpdate) {
|
|||||||
clear_install_consent();
|
clear_install_consent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `.old` 回滚备份的保留时长:新版本首启不立即删除备份,
|
||||||
|
/// 否则新版本一旦启动即崩溃,用户就再也没有自助回退的路径。
|
||||||
|
const OLD_BACKUP_KEEP: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 3600);
|
||||||
|
|
||||||
/// 启动时清理上次更新遗留的旧文件(改名失败的 `.old`、中断的 `.tmp`)。
|
/// 启动时清理上次更新遗留的旧文件(改名失败的 `.old`、中断的 `.tmp`)。
|
||||||
|
///
|
||||||
|
/// `.old` 是本版本唯一的回滚备份:**不能一启动就删**。策略是保留最近一份直到超过
|
||||||
|
/// `OLD_BACKUP_KEEP`,更早的备份立即清理。
|
||||||
pub fn cleanup_old() {
|
pub fn cleanup_old() {
|
||||||
if let Ok(cur) = std::env::current_exe()
|
if let Ok(cur) = std::env::current_exe()
|
||||||
&& let Some(dir) = cur.parent()
|
&& let Some(dir) = cur.parent()
|
||||||
&& let Ok(entries) = std::fs::read_dir(dir)
|
&& let Ok(entries) = std::fs::read_dir(dir)
|
||||||
{
|
{
|
||||||
|
let mut backups: Vec<(std::time::SystemTime, PathBuf)> = Vec::new();
|
||||||
for e in entries.flatten() {
|
for e in entries.flatten() {
|
||||||
// Windows 文件系统不区分大小写,统一按小写匹配
|
// Windows 文件系统不区分大小写,统一按小写匹配
|
||||||
let name = e.file_name();
|
let name = e.file_name();
|
||||||
let lower = name.to_string_lossy().to_lowercase();
|
let lower = name.to_string_lossy().to_lowercase();
|
||||||
if (lower.ends_with(".old") && lower.starts_with("mqttx"))
|
if lower.ends_with(".old") && lower.starts_with("mqttx") {
|
||||||
|| lower.ends_with(".update.tmp")
|
let modified = e
|
||||||
{
|
.metadata()
|
||||||
let _ = std::fs::remove_file(e.path());
|
.and_then(|m| m.modified())
|
||||||
|
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
||||||
|
backups.push((modified, e.path()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 新的在前
|
||||||
|
backups.sort_by_key(|b| std::cmp::Reverse(b.0));
|
||||||
|
for (i, (modified, path)) in backups.iter().enumerate() {
|
||||||
|
let age = modified.elapsed().unwrap_or_default();
|
||||||
|
// 保留最近一份作为回滚备份,直到超过保留期;其余立即清理
|
||||||
|
if i == 0 && age < OLD_BACKUP_KEEP {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 暂存目录里中断的 .tmp。跳过最近修改的文件,避免误删
|
// 暂存目录里中断的 .tmp。跳过最近修改的文件,避免误删
|
||||||
// 并行实例正在写入的临时文件。
|
// 并行实例正在写入的临时文件。
|
||||||
|
|||||||
+58
-21
@@ -53,13 +53,18 @@ pub fn current_version() -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 构建目标的平台三元组,需与 release.yml / publish 脚本的产物命名一致。
|
/// 构建目标的平台三元组,需与 release.yml / publish 脚本的产物命名一致。
|
||||||
pub fn platform_triple() -> &'static str {
|
///
|
||||||
|
/// 架构取自 `std::env::consts::ARCH` 而非写死:写死 x86_64 会让 Apple Silicon /
|
||||||
|
/// ARM Linux 客户端永远挑不到自己的产物(发布脚本能产出 aarch64 资产,但无人下载),
|
||||||
|
/// 甚至在只有 x86_64 资产时把错误架构的二进制装上去。
|
||||||
|
pub fn platform_triple() -> String {
|
||||||
|
let arch = std::env::consts::ARCH;
|
||||||
if cfg!(windows) {
|
if cfg!(windows) {
|
||||||
"x86_64-pc-windows-msvc"
|
format!("{arch}-pc-windows-msvc")
|
||||||
} else if cfg!(target_os = "macos") {
|
} else if cfg!(target_os = "macos") {
|
||||||
"x86_64-apple-darwin"
|
format!("{arch}-apple-darwin")
|
||||||
} else {
|
} else {
|
||||||
"x86_64-unknown-linux-gnu"
|
format!("{arch}-unknown-linux-gnu")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +118,12 @@ pub fn is_newer(a: &str, b: &str) -> bool {
|
|||||||
pub struct StagedUpdate {
|
pub struct StagedUpdate {
|
||||||
pub version: String,
|
pub version: String,
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
|
/// 暂存二进制的 sha256(小写 hex)。
|
||||||
|
///
|
||||||
|
/// 注意它与 Release 附件的 `.sha256` 侧车不是同一个东西:侧车校验的是**下载到的
|
||||||
|
/// 压缩包**,这里是**解压/落盘后的最终二进制**。安装同意标记绑定该哈希,安装前
|
||||||
|
/// 也会用它复核,防止"下载校验通过"到"下次启动安装"之间文件被替换。
|
||||||
|
pub sha256: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -183,42 +194,68 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pick_assets_prefers_7z_over_raw_binary() {
|
fn pick_assets_prefers_7z_over_raw_binary() {
|
||||||
|
// 附件名按**当前平台**三元组构造:写死 x86_64-pc-windows-msvc 会让本测试
|
||||||
|
// 只在 Windows 上通过,Linux CI 必红
|
||||||
|
let triple = platform_triple();
|
||||||
|
let bin = if cfg!(windows) { "mqttx.exe" } else { "mqttx" };
|
||||||
|
let base = format!("mqttx-v1.0.1-{triple}-{bin}");
|
||||||
|
let (n_bin, n_bin_sha, n_7z, n_7z_sha) = (
|
||||||
|
base.clone(),
|
||||||
|
format!("{base}.sha256"),
|
||||||
|
format!("{base}.7z"),
|
||||||
|
format!("{base}.7z.sha256"),
|
||||||
|
);
|
||||||
let rel: serde_json::Value = serde_json::json!({
|
let rel: serde_json::Value = serde_json::json!({
|
||||||
"tag_name": "v1.0.1",
|
"tag_name": "v1.0.1",
|
||||||
"assets": [
|
"assets": [
|
||||||
{"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe", "size": 21000000,
|
{"name": n_bin, "size": 21000000, "browser_download_url": "https://x/bin"},
|
||||||
"browser_download_url": "https://x/win.exe"},
|
{"name": n_bin_sha, "size": 65, "browser_download_url": "https://x/bin.sha256"},
|
||||||
{"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.sha256", "size": 65,
|
{"name": n_7z, "size": 9000000, "browser_download_url": "https://x/bin.7z"},
|
||||||
"browser_download_url": "https://x/win.exe.sha256"},
|
{"name": n_7z_sha, "size": 65, "browser_download_url": "https://x/bin.7z.sha256"}
|
||||||
{"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.7z", "size": 9000000,
|
|
||||||
"browser_download_url": "https://x/win.7z"},
|
|
||||||
{"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.7z.sha256", "size": 65,
|
|
||||||
"browser_download_url": "https://x/win.7z.sha256"}
|
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
let (name, url, size, sha) = pick_assets(&rel).expect("应选中 7z");
|
let (name, url, size, sha) = pick_assets(&rel).expect("应选中 7z");
|
||||||
assert!(name.ends_with(".7z"), "应优先 7z 压缩包,实际 {name}");
|
assert!(name.ends_with(".7z"), "应优先 7z 压缩包,实际 {name}");
|
||||||
assert_eq!(url, "https://x/win.7z");
|
assert_eq!(url, "https://x/bin.7z");
|
||||||
assert_eq!(size, 9000000);
|
assert_eq!(size, 9000000);
|
||||||
assert_eq!(sha.as_deref(), Some("https://x/win.7z.sha256"));
|
assert_eq!(sha.as_deref(), Some("https://x/bin.7z.sha256"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pick_assets_falls_back_to_raw_binary() {
|
fn pick_assets_falls_back_to_raw_binary() {
|
||||||
// 旧发布方式只有裸二进制:仍可被选中
|
// 旧发布方式只有裸二进制:仍可被选中
|
||||||
|
let triple = platform_triple();
|
||||||
|
let bin = if cfg!(windows) { "mqttx.exe" } else { "mqttx" };
|
||||||
|
let base = format!("mqttx-v1.0.1-{triple}-{bin}");
|
||||||
|
let (n_bin, n_sha) = (base.clone(), format!("{base}.sha256"));
|
||||||
let rel: serde_json::Value = serde_json::json!({
|
let rel: serde_json::Value = serde_json::json!({
|
||||||
"tag_name": "v1.0.1",
|
"tag_name": "v1.0.1",
|
||||||
"assets": [
|
"assets": [
|
||||||
{"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe", "size": 19132928,
|
{"name": n_bin, "size": 19132928, "browser_download_url": "https://x/bin"},
|
||||||
"browser_download_url": "https://x/win"},
|
{"name": n_sha, "size": 65, "browser_download_url": "https://x/bin.sha256"}
|
||||||
{"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.sha256", "size": 65,
|
|
||||||
"browser_download_url": "https://x/win.sha256"}
|
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
let (name, url, _size, sha) = pick_assets(&rel).expect("应回退裸二进制");
|
let (name, url, _size, sha) = pick_assets(&rel).expect("应回退裸二进制");
|
||||||
assert!(name.ends_with(".exe"));
|
assert!(name.ends_with(bin), "实际 {name}");
|
||||||
assert_eq!(url, "https://x/win");
|
assert_eq!(url, "https://x/bin");
|
||||||
assert_eq!(sha.as_deref(), Some("https://x/win.sha256"));
|
assert_eq!(sha.as_deref(), Some("https://x/bin.sha256"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非约定命名的附件不得被选中:非 Windows 下旧实现的 ends_with("") 恒真,
|
||||||
|
// 会把 .tar.gz / .deb 这类附件当成可执行文件下载并替换自身
|
||||||
|
#[test]
|
||||||
|
fn pick_assets_rejects_non_convention_assets() {
|
||||||
|
let triple = platform_triple();
|
||||||
|
let rel: serde_json::Value = serde_json::json!({
|
||||||
|
"tag_name": "v1.0.1",
|
||||||
|
"assets": [
|
||||||
|
{"name": format!("mqttx-v1.0.1-{triple}.tar.gz"), "size": 1024,
|
||||||
|
"browser_download_url": "https://x/pkg.tar.gz"},
|
||||||
|
{"name": format!("mqttx-v1.0.1-{triple}-debug"), "size": 1024,
|
||||||
|
"browser_download_url": "https://x/dbg"}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
assert!(pick_assets(&rel).is_none(), "非约定命名的附件不应被选中");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user