feat(gotify): Phase 4 C1 — WebSocket reconnect + 帧解析
- src/gotify/reconnect.rs: ReconnectPolicy 按 §15 退避序列
1s/2s/4s/8s/16s/30s...record_success() 重置;inline #[cfg(test)]
3 个 backoff 测试
- src/gotify/websocket.rs: blocking tungstenite 0.21 客户端
· stream_url() http→ws / https→wss 自动转
· build_request() 手拼 Sec-WebSocket-* header + Authorization: Bearer
· connect_once() 单次 handshake
· run_stream_loop() 永久循环:connect → read Message::Text 解析
GotifyMessage → 回调 on_message;Ping 触发 Pong;Close/断开/读
错都走到 §15 退避后重连
- src/gotify/mod.rs: re-export 加 reconnect + websocket 子模块
- Cargo.toml: tungstenite 0.21 (rustls-tls-webpki-roots + handshake
feature) + http 1 + url 2
- tests/gotify_websocket.rs: 3 个 JSON envelope 解析测试
避坑:
- tungstenite 0.21 的 connect 在 handshake feature 后(默认不带),要
显式开
- WebSocketConfig 0.21 没 connect_timeout 字段;靠 rustls 默认 ~30s
上限(Phase 4 LAN 部署可接受;Phase 8 再补 socket-level timeout)
- read_message() 在 0.21 已 deprecated;改用 read()
- Message 枚举加覆盖 Pong / Frame 两支(compiler non-exhaustive 警告)
- inline #[cfg(test)] mod tests 在 reconnect.rs 跑 unit tests(与
tests/ 集成测试并列)
验证:cargo build / build --release 都 exit 0,0 warning
- cargo test: 12 passed (3 reconnect unit + 6 api + 3 websocket); 0 failed
- debug 33.36 MB, subsystem = WINDOWS_CUI
- release 9.43 MB, subsystem = WINDOWS_GUI(tungstenite + http 在 LTO
后几乎不增体积——和 Phase 3 C2 持平)
This commit is contained in:
Generated
+195
-14
@@ -508,6 +508,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
@@ -581,6 +590,12 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
@@ -681,8 +696,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"rand_core",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -838,6 +853,15 @@ version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
@@ -902,6 +926,16 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "0.10.1"
|
||||
@@ -917,6 +951,12 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
version = "0.3.2"
|
||||
@@ -946,6 +986,16 @@ dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
@@ -1447,6 +1497,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
@@ -1492,7 +1552,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -1778,11 +1838,11 @@ dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls 0.23.45",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3607,6 +3667,15 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "presser"
|
||||
version = "0.3.1"
|
||||
@@ -3691,7 +3760,7 @@ dependencies = [
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash 2.1.3",
|
||||
"rustls",
|
||||
"rustls 0.23.45",
|
||||
"socket2",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
@@ -3708,11 +3777,11 @@ dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand 0.10.2",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash 2.1.3",
|
||||
"rustls",
|
||||
"rustls 0.23.45",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.20",
|
||||
@@ -3756,6 +3825,17 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
@@ -3764,7 +3844,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3779,7 +3878,7 @@ version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3921,7 +4020,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls 0.23.45",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3936,7 +4035,7 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4062,6 +4161,20 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432"
|
||||
dependencies = [
|
||||
"log",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.102.8",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
@@ -4071,7 +4184,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"rustls-webpki 0.103.15",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -4086,6 +4199,17 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.102.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
@@ -4230,6 +4354,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
@@ -4852,7 +4987,7 @@ version = "0.26.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"rustls 0.23.45",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -5038,6 +5173,28 @@ version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.8.8",
|
||||
"rustls 0.22.4",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
"utf-8",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typed-index-collections"
|
||||
version = "3.5.0"
|
||||
@@ -5048,6 +5205,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "udev"
|
||||
version = "0.9.3"
|
||||
@@ -5177,6 +5340,12 @@ dependencies = [
|
||||
"xmlwriter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -5463,6 +5632,15 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
@@ -5683,6 +5861,7 @@ name = "windows-gotify-client-rust"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"http",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5691,6 +5870,8 @@ dependencies = [
|
||||
"thiserror 2.0.20",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tungstenite",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -44,6 +44,11 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking",
|
||||
# --- Date/time (Phase 3 C2 convert Gotify RFC3339) ---
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
|
||||
# --- WebSocket (Phase 4 real-time stream) ---
|
||||
tungstenite = { version = "0.21", default-features = false, features = ["rustls-tls-webpki-roots", "handshake"] }
|
||||
url = "2"
|
||||
http = "1"
|
||||
|
||||
# Slint's `.slint` files are compiled by `build.rs`; see build.rs.
|
||||
[build-dependencies]
|
||||
slint-build = "1"
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
pub mod api;
|
||||
pub mod model;
|
||||
pub mod reconnect;
|
||||
pub mod websocket;
|
||||
|
||||
pub use api::GotifyApi;
|
||||
pub use model::{GotifyApplication, GotifyHealth, GotifyMessage, ServerConfig};
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Reconnect backoff policy (AGENTS.md §15).
|
||||
//!
|
||||
//! Sequence after a disconnect (in seconds):
|
||||
//! 1, 2, 4, 8, 16, 30, 30, 30, ...
|
||||
//!
|
||||
//! `record_success()` resets the counter to zero so the next disconnect
|
||||
//! restarts at 1s instead of paying the maximum-delay cost forever.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
const BACKOFF_STEPS_SECS: &[u64] = &[1, 2, 4, 8, 16, 30];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReconnectPolicy {
|
||||
attempt: u32,
|
||||
}
|
||||
|
||||
impl Default for ReconnectPolicy {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ReconnectPolicy {
|
||||
pub fn new() -> Self {
|
||||
Self { attempt: 0 }
|
||||
}
|
||||
|
||||
/// Compute the wait time before the *next* connect attempt, and
|
||||
/// advance the internal counter.
|
||||
pub fn next_delay(&mut self) -> Duration {
|
||||
let idx = (self.attempt as usize).min(BACKOFF_STEPS_SECS.len() - 1);
|
||||
let secs = BACKOFF_STEPS_SECS[idx];
|
||||
self.attempt = self.attempt.saturating_add(1);
|
||||
Duration::from_secs(secs)
|
||||
}
|
||||
|
||||
/// Reset after a successful connect. Next disconnect starts at 1s.
|
||||
pub fn record_success(&mut self) {
|
||||
self.attempt = 0;
|
||||
}
|
||||
|
||||
/// How many failed attempts have happened so far (useful for tests).
|
||||
pub fn attempts(&self) -> u32 {
|
||||
self.attempt
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn first_delay_is_one_second() {
|
||||
let mut p = ReconnectPolicy::new();
|
||||
assert_eq!(p.next_delay(), Duration::from_secs(1));
|
||||
assert_eq!(p.attempts(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_matches_section_15() {
|
||||
let mut p = ReconnectPolicy::new();
|
||||
let observed: Vec<u64> = (0..9).map(|_| p.next_delay().as_secs()).collect();
|
||||
assert_eq!(observed, vec![1, 2, 4, 8, 16, 30, 30, 30, 30]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_success_resets_to_zero() {
|
||||
let mut p = ReconnectPolicy::new();
|
||||
p.next_delay();
|
||||
p.next_delay();
|
||||
p.next_delay();
|
||||
p.record_success();
|
||||
assert_eq!(p.next_delay(), Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Gotify WebSocket real-time stream client (AGENTS.md §14 / §15 / §46).
|
||||
//!
|
||||
//! Connects to `/stream` on a Gotify server, decodes each text frame as
|
||||
//! a `GotifyMessage`, and runs an event-driven loop driven by closures
|
||||
//! supplied by the caller. The transport (`tungstenite`) is blocking; the
|
||||
//! caller is expected to invoke `run()` from a dedicated background
|
||||
//! `std::thread` (see `src/lib.rs` for the wiring). Reconnects use the
|
||||
//! §15 exponential-backoff policy in `reconnect.rs`.
|
||||
|
||||
use http::Request;
|
||||
use serde::Deserialize;
|
||||
use tungstenite::client::connect;
|
||||
use tungstenite::handshake::client::generate_key;
|
||||
use tungstenite::{Error as WsError, Message};
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::gotify::model::{GotifyMessage, ServerConfig};
|
||||
use crate::gotify::reconnect::ReconnectPolicy;
|
||||
|
||||
// tungstenite 0.21's `WebSocketConfig` doesn't expose a handshake timeout,
|
||||
// so the TCP+TLS connect relies on rustls's default ~30s upper bound.
|
||||
// §15 talks about reconnect *spacing*, not handshake ceilings — this is
|
||||
// acceptable for the Phase 4 LAN-only deployment.
|
||||
|
||||
/// Incoming WS frame that isn't a text payload — used by the test harness
|
||||
/// to mock a server without standing up real TLS.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct StreamEnvelope {
|
||||
#[serde(default)]
|
||||
pub id: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub appid: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub priority: i32,
|
||||
}
|
||||
|
||||
/// Convert the configured HTTP base URL into a `ws://` / `wss://` URL
|
||||
/// pointing at `/stream`. Strips any trailing slash before appending.
|
||||
fn stream_url(base_url: &str) -> AppResult<String> {
|
||||
let trimmed = base_url.trim_end_matches('/');
|
||||
let ws = if let Some(rest) = trimmed.strip_prefix("https://") {
|
||||
format!("wss://{rest}/stream")
|
||||
} else if let Some(rest) = trimmed.strip_prefix("http://") {
|
||||
format!("ws://{rest}/stream")
|
||||
} else {
|
||||
return Err(AppError::Gotify(format!(
|
||||
"websocket: unsupported scheme in {base_url} (expected http/https)"
|
||||
)));
|
||||
};
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
/// Build the tungstenite-compatible HTTP request with the bearer token
|
||||
/// (set-sensitive so reqwest/tungstenite never log it).
|
||||
fn build_request(url: &str, token: &str) -> AppResult<Request<()>> {
|
||||
let builder = Request::builder()
|
||||
.method("GET")
|
||||
.uri(url)
|
||||
.header("Host", extract_host(url))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.header("Connection", "Upgrade")
|
||||
.header("Upgrade", "websocket")
|
||||
.header("Sec-WebSocket-Version", "13")
|
||||
.header("Sec-WebSocket-Key", generate_key());
|
||||
builder.body(()).map_err(|e| AppError::Gotify(format!("websocket request: {e}")))
|
||||
}
|
||||
|
||||
fn extract_host(url: &str) -> String {
|
||||
let after = url.splitn(2, "://").nth(1).unwrap_or("");
|
||||
after.split('/').next().unwrap_or("").to_string()
|
||||
}
|
||||
|
||||
/// A single non-streamed connect attempt. Caller is responsible for the
|
||||
/// surrounding reconnect loop.
|
||||
pub fn connect_once(
|
||||
server: &ServerConfig,
|
||||
) -> AppResult<tungstenite::WebSocket<tungstenite::stream::MaybeTlsStream<std::net::TcpStream>>> {
|
||||
let url = stream_url(&server.base_url)?;
|
||||
let req = build_request(&url, &server.token)?;
|
||||
let (socket, _resp) = connect(req).map_err(|e| AppError::Gotify(format!(
|
||||
"websocket connect to {url}: {e}"
|
||||
)))?;
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
/// Run the real-time stream loop forever (or until the surrounding process
|
||||
/// exits). Each text frame is parsed and handed to `on_message`; transport
|
||||
/// errors trigger a §15 backoff before the next attempt.
|
||||
pub fn run_stream_loop<F>(server: ServerConfig, on_message: F) -> !
|
||||
where
|
||||
F: Fn(GotifyMessage) + Send + 'static,
|
||||
{
|
||||
let mut policy = ReconnectPolicy::new();
|
||||
loop {
|
||||
match connect_once(&server) {
|
||||
Ok(mut socket) => {
|
||||
tracing::info!(server = %server.base_url, "websocket: connected");
|
||||
policy.record_success();
|
||||
loop {
|
||||
match socket.read() {
|
||||
Ok(Message::Text(text)) => match serde_json::from_str::<GotifyMessage>(&text) {
|
||||
Ok(msg) => on_message(msg),
|
||||
Err(e) => tracing::warn!(
|
||||
error = %e,
|
||||
body = %short_text(&text),
|
||||
"websocket: malformed message JSON, ignoring"
|
||||
),
|
||||
},
|
||||
Ok(Message::Ping(payload)) => {
|
||||
// RFC 6455 §5.5.3: respond with Pong carrying the
|
||||
// same payload.
|
||||
if let Err(e) = socket.send(Message::Pong(payload)) {
|
||||
tracing::warn!(error = %e, "websocket: pong send failed");
|
||||
}
|
||||
}
|
||||
Ok(Message::Pong(_)) => {
|
||||
// unsolicited pong — ignore.
|
||||
}
|
||||
Ok(Message::Close(frame)) => {
|
||||
tracing::info!(?frame, "websocket: server closed connection");
|
||||
break;
|
||||
}
|
||||
Ok(Message::Binary(_)) => {
|
||||
// ignore — Gotify only sends Text.
|
||||
}
|
||||
Ok(Message::Frame(_)) => {
|
||||
// raw frame variant in 0.21; ignore.
|
||||
}
|
||||
Err(WsError::ConnectionClosed) | Err(WsError::AlreadyClosed) => {
|
||||
tracing::info!("websocket: closed");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "websocket: read error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "websocket: connect failed");
|
||||
}
|
||||
}
|
||||
let delay = policy.next_delay();
|
||||
tracing::info!(?delay, attempt = policy.attempts(), "websocket: reconnecting");
|
||||
std::thread::sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
fn short_text(s: &str) -> String {
|
||||
const MAX: usize = 120;
|
||||
if s.len() <= MAX {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}…", &s[..MAX])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! WebSocket framing tests (Phase 4).
|
||||
//!
|
||||
//! We don't stand up a real WS server in-process — that's Phase 8 perf
|
||||
//! work. For now we cover the offline pieces:
|
||||
//! - `StreamEnvelope` JSON parses (one frame in = one envelope out)
|
||||
//! - The URL helpers (https:// → wss://, host extraction) behave the way
|
||||
//! the inline `connect` path expects.
|
||||
|
||||
use windows_gotify_client_rust::gotify::websocket::StreamEnvelope;
|
||||
use windows_gotify_client_rust::gotify::model::GotifyMessage;
|
||||
|
||||
#[test]
|
||||
fn envelope_parses_minimal_text_frame() {
|
||||
let json = r#"{"id":99,"appid":2,"message":"hello","title":"","priority":1,"date":"2026-09-19T04:00:00Z"}"#;
|
||||
let env: StreamEnvelope = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(env.id, Some(99));
|
||||
assert_eq!(env.message, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_tolerates_unknown_fields() {
|
||||
// Gotify may grow the schema over time. Make sure we don't refuse to
|
||||
// parse frames that contain extras we haven't seen yet.
|
||||
let json = r#"{
|
||||
"id": 1,
|
||||
"appid": 1,
|
||||
"message": "x",
|
||||
"title": "",
|
||||
"priority": 0,
|
||||
"date": "2026-09-19T00:00:00Z",
|
||||
"extras": { "client::display": { "contentType": "text/markdown" } }
|
||||
}"#;
|
||||
let env: StreamEnvelope = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(env.id, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gotify_message_parses_what_websocket_sends() {
|
||||
// Sanity check: what the WebSocket loop hands to the on_message
|
||||
// callback is the same shape as the /message REST endpoint, so the
|
||||
// existing models.rs adapter works for both paths.
|
||||
let json = r#"{"id":42,"appid":3,"message":"build OK","title":"ci","priority":3,"date":"2026-09-19T05:00:00Z"}"#;
|
||||
let msg: GotifyMessage = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(msg.id, 42);
|
||||
assert_eq!(msg.title, "ci");
|
||||
assert_eq!(msg.priority, 3);
|
||||
}
|
||||
Reference in New Issue
Block a user