feat(gotify): Phase 3 C2 — UI 接线 + Connection status + 真实数据

- src/config.rs: AppConfig::placeholder() 硬编码 NAS 地址 + 假 token
  (§20 token 永不日志;Phase 5 接持久化前占位)
- src/models.rs: gotify_messages_to_items 把 GotifyMessage 列表转
  Slint MessageItem[];try_fetch_real_messages 一次完成 health →
  applications → messages(50);失败 None 让调用方 fallback mock
- src/gotify/mod.rs: re-export ServerConfig
- src/lib.rs: run() 启动时调 try_fetch_real_messages,成功用真实数
  据 + Connected + 服务端 URL,失败 fallback mock + Disconnected;
  on_test_connection_requested 重新跑同一路径
- Cargo.toml: 加 chrono 0.4 (RFC3339 解析)
- ui/app.slint: is-connected (绿/灰 dot) + status-text (顶部副标)
- ui/settings-page.slint: Connection section 加 Test Connection 按钮
  (in-progress 时禁用显示 "Testing...")

避坑:
- Gotify RFC3339 date 解析失败 silent fallback 到 unix=0(显示
  "just now" 而不是崩)
- Gotify priority 0..10 → Slint Priority 4 桶分桶:<=0 Low / <=3
  Normal / <=7 High / else Max
- ServerConfig 在 crate::gotify::model 子模块,mod.rs 必须 re-export

验证:cargo build / build --release 都 exit 0,0 warning
- cargo test --test gotify_api: 6 passed
- debug   33.36 MB, subsystem = WINDOWS_CUI
- release  9.43 MB, subsystem = WINDOWS_GUI(chrono +1.3 MB)
This commit is contained in:
2026-09-19 05:18:26 +08:00
parent c6ad3883f8
commit 0cf7a4eaaa
8 changed files with 260 additions and 41 deletions
+29
View File
@@ -0,0 +1,29 @@
//! Application configuration placeholder.
//!
//! Phase 3 ships only a hardcoded `placeholder()` so the UI wiring can
//! demonstrate the real-data path. Phase 5 replaces this with persistent
//! JSON storage at `%APPDATA%/WindowsGotify/config.json` (AGENTS.md §5/§19/§20).
//!
//! AGENTS.md §20 — the placeholder token below is intentionally not a
//! real credential. Real auth fails, the UI falls back to mock data, and
//! nothing about the token ever reaches a log line.
use crate::gotify::ServerConfig;
pub struct AppConfig {
pub server: Option<ServerConfig>,
}
impl AppConfig {
/// Hardcoded single-server placeholder. Returns `None` here means
/// "no server configured" — but for now Phase 3 always returns `Some`
/// so the UI wiring is exercised on every start.
pub fn placeholder() -> Self {
Self {
server: Some(ServerConfig::new(
"http://192.168.50.103:50334",
"placeholder-token-do-not-log",
)),
}
}
}
+1 -1
View File
@@ -11,4 +11,4 @@ pub mod api;
pub mod model;
pub use api::GotifyApi;
pub use model::{GotifyApplication, GotifyHealth, GotifyMessage};
pub use model::{GotifyApplication, GotifyHealth, GotifyMessage, ServerConfig};
+64 -13
View File
@@ -1,12 +1,15 @@
//! Windows Gotify Client (Rust) — core library.
//!
//! Phase 2 surface:
//! Phase 3 surface:
//! - [`config`]: placeholder AppConfig (Phase 5 swaps for persistence)
//! - [`error`]: AppError / AppResult
//! - [`gotify`]: blocking REST client (health / applications / messages)
//! - [`logging`]: tracing-subscriber init
//! - [`models`]: Mock message feed (Phase 3 replaces with live Gotify data)
//! - [`models`]: mock feed + Gotify→Slint adapter
//! - [`storage`]: `%APPDATA%/WindowsGotify/state.json` (Phase 2 C3)
//! - [`run`]: builds the MainWindow and runs its event loop
pub mod config;
pub mod error;
pub mod gotify;
pub mod logging;
@@ -32,7 +35,7 @@ pub fn run() -> error::AppResult<()> {
let ui = MainWindow::new().map_err(|e| error::AppError::Ui(e.to_string()))?;
// Phase 2 C3: load persisted state and apply it before `run()`.
// ── Load persisted window geometry (Phase 2 C3) ───────────────
let initial_state = storage::load_state();
tracing::info!(
width = initial_state.last_window_width,
@@ -45,7 +48,30 @@ pub fn run() -> error::AppResult<()> {
initial_state.last_window_height.max(0) as f32,
)));
// Phase 2 C2 callbacks.
// ── Phase 3 C2: try the real Gotify server, fall back to mock ──
let cfg = config::AppConfig::placeholder();
let (messages_model, status_text, is_connected) = match cfg.server.as_ref() {
Some(server) => match models::try_fetch_real_messages(&server.base_url, &server.token) {
Some((model, text)) => {
tracing::info!(server = %server.base_url, "connected to real Gotify");
(model, text, true)
}
None => {
tracing::warn!(server = %server.base_url, "real Gotify unreachable; using mock data");
let text = format!("Disconnected · mock feed · {}", server.base_url);
(models::mock_messages_model(), text, false)
}
},
None => {
tracing::info!("no server configured; using mock data");
(models::mock_messages_model(), "No server configured · mock feed".to_string(), false)
}
};
ui.set_messages(messages_model);
ui.set_status_text(status_text.into());
ui.set_is_connected(is_connected);
// ── Phase 2 C2 callbacks ───────────────────────────────────────
ui.on_test_notification_clicked(|| {
tracing::info!("empty-state: Test Notification clicked (stub)");
});
@@ -53,7 +79,39 @@ pub fn run() -> error::AppResult<()> {
tracing::debug!(idx, "message card clicked");
});
// Phase 2 C3 callbacks.
// ── Phase 3 C2: Settings → Connection → Test button ───────────
ui.on_test_connection_requested({
let ui_handle = ui.as_weak();
move || {
// Re-run the same fetch path; UI already shows "Disconnected"
// so this is the only way the user can poke the server.
tracing::info!("settings: Test Connection clicked");
let Some(server) = config::AppConfig::placeholder().server else {
return;
};
match models::try_fetch_real_messages(&server.base_url, &server.token) {
Some((model, text)) => {
if let Some(ui) = ui_handle.upgrade() {
ui.set_messages(model);
ui.set_status_text(text.into());
ui.set_is_connected(true);
}
tracing::info!("Test Connection: ok");
}
None => {
if let Some(ui) = ui_handle.upgrade() {
ui.set_status_text(
format!("Disconnected · mock feed · {}", server.base_url).into(),
);
ui.set_is_connected(false);
}
tracing::warn!("Test Connection: failed");
}
}
}
});
// ── Phase 2 C3 callbacks ───────────────────────────────────────
let state_for_save: Rc<RefCell<AppState>> = Rc::new(RefCell::new(initial_state));
ui.on_about_clicked({
let ui_handle = ui.as_weak();
@@ -65,17 +123,11 @@ pub fn run() -> error::AppResult<()> {
}
});
// Phase 2 C2 message feed (mock).
ui.set_messages(models::mock_messages_model());
// Hook a real close-requested callback on the Slint window so we save
// state right before the window is destroyed. Slint 1.18's `Window` has
// a `close-requested` callback that fires on OS-close.
// ── Phase 2 C3: save state on OS-close ────────────────────────
ui.window().on_close_requested({
let state_for_save = state_for_save.clone();
let ui_handle = ui.as_weak();
move || {
// Capture final size from the live window before saving.
if let Some(ui) = ui_handle.upgrade() {
let size = ui.window().size();
let mut state = state_for_save.borrow_mut();
@@ -91,7 +143,6 @@ pub fn run() -> error::AppResult<()> {
"saved state.json on close"
);
}
// Allow the window to hide (default CloseRequestResponse behaviour).
slint::CloseRequestResponse::HideWindow
}
});
+82
View File
@@ -11,9 +11,11 @@
use std::time::{SystemTime, UNIX_EPOCH};
use chrono::DateTime;
use slint::{ModelRc, VecModel};
use crate::MessageItem;
use crate::gotify::{GotifyApplication, GotifyMessage};
/// Wall-clock now, as Unix seconds. Falls back to 0 if the system clock is
/// somehow pre-1970, which should never happen on a real Windows install.
@@ -153,4 +155,84 @@ pub fn mock_messages() -> Vec<MessageItem> {
/// Wrap a `Vec<MessageItem>` in a Slint model ready for `MainWindow.set_messages`.
pub fn mock_messages_model() -> ModelRc<MessageItem> {
ModelRc::from(std::rc::Rc::new(VecModel::from(mock_messages())))
}
/// Try to parse a Gotify RFC3339 timestamp; fall back to 0 on failure.
fn gotify_date_to_unix(date: &str) -> i32 {
DateTime::parse_from_rfc3339(date)
.map(|dt| dt.timestamp() as i32)
.unwrap_or(0)
}
/// Convert one Gotify wire message + its resolved application name into the
/// Slint-facing `MessageItem`. `app_name` is resolved separately from the
/// application list (caller joins).
pub fn gotify_message_to_item(
msg: &GotifyMessage,
apps_by_id: &std::collections::HashMap<i64, GotifyApplication>,
) -> MessageItem {
let app_name = apps_by_id
.get(&msg.appid)
.map(|a| a.name.clone())
.unwrap_or_else(|| format!("App #{}", msg.appid));
let unix = gotify_date_to_unix(&msg.date);
MessageItem {
id: msg.id as i32,
app_id: msg.appid as i32,
app_name: app_name.into(),
title: msg.title.clone().into(),
body: msg.message.clone().into(),
priority: map_slint(msg.priority),
timestamp_unix: unix,
relative_time: format_relative(unix as i64, now_unix()).into(),
has_click_url: false,
click_url: "".into(),
}
}
/// Convert a slice of wire messages + applications into Slint items.
pub fn gotify_messages_to_items(
msgs: &[GotifyMessage],
apps: &[GotifyApplication],
) -> Vec<MessageItem> {
let apps_by_id: std::collections::HashMap<i64, GotifyApplication> =
apps.iter().map(|a| (a.id, a.clone())).collect();
msgs.iter().map(|m| gotify_message_to_item(m, &apps_by_id)).collect()
}
/// Map Gotify numeric priority to the Slint `Priority` enum. Gotify uses
/// 0..10 (0=min, 10=max). Slint's enum has 4 buckets (§7).
fn map_slint(p: i32) -> crate::Priority {
match p {
x if x <= 0 => crate::Priority::Low,
x if x <= 3 => crate::Priority::Normal,
x if x <= 7 => crate::Priority::High,
_ => crate::Priority::Max,
}
}
/// Attempt to load real Gotify data and convert it to a Slint model.
/// Returns `None` on any error — caller falls back to mock data.
pub fn try_fetch_real_messages(
base_url: &str,
token: &str,
) -> Option<(ModelRc<MessageItem>, String)> {
use crate::gotify::GotifyApi;
let api = GotifyApi::new(crate::gotify::ServerConfig::new(
base_url.to_string(),
token.to_string(),
))
.ok()?;
let health = api.health().ok()?;
if health.health != "green" {
return None;
}
let apps = api.applications().ok()?;
let msgs = api.messages(50).ok()?;
let items = gotify_messages_to_items(&msgs, &apps);
Some((
ModelRc::from(std::rc::Rc::new(VecModel::from(items))),
format!("{} · {} messages", api.base_url(), msgs.len()),
))
}