From 0cf7a4eaaaa86530cd43ef8c17db65f3dc5a5e73 Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Sat, 19 Sep 2026 05:18:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(gotify):=20Phase=203=20C2=20=E2=80=94=20UI?= =?UTF-8?q?=20=E6=8E=A5=E7=BA=BF=20+=20Connection=20status=20+=20=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- Cargo.lock | 2 ++ Cargo.toml | 3 ++ src/config.rs | 29 +++++++++++++++ src/gotify/mod.rs | 2 +- src/lib.rs | 77 ++++++++++++++++++++++++++++++++------- src/models.rs | 82 ++++++++++++++++++++++++++++++++++++++++++ ui/app.slint | 79 ++++++++++++++++++++++++++-------------- ui/settings-page.slint | 27 ++++++++++++++ 8 files changed, 260 insertions(+), 41 deletions(-) create mode 100644 src/config.rs diff --git a/Cargo.lock b/Cargo.lock index 562d323..0dc6f97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -694,6 +694,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -5681,6 +5682,7 @@ dependencies = [ name = "windows-gotify-client-rust" version = "0.1.0" dependencies = [ + "chrono", "reqwest", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 2d6e7f4..e5ed36e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,9 @@ serde_json = "1" # --- Networking (Phase 3 Gotify HTTP) --- reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +# --- Date/time (Phase 3 C2 convert Gotify RFC3339) --- +chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } + # Slint's `.slint` files are compiled by `build.rs`; see build.rs. [build-dependencies] slint-build = "1" diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..67b9102 --- /dev/null +++ b/src/config.rs @@ -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, +} + +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", + )), + } + } +} \ No newline at end of file diff --git a/src/gotify/mod.rs b/src/gotify/mod.rs index 697b664..d0fd7de 100644 --- a/src/gotify/mod.rs +++ b/src/gotify/mod.rs @@ -11,4 +11,4 @@ pub mod api; pub mod model; pub use api::GotifyApi; -pub use model::{GotifyApplication, GotifyHealth, GotifyMessage}; \ No newline at end of file +pub use model::{GotifyApplication, GotifyHealth, GotifyMessage, ServerConfig}; \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 29990b7..d9c3179 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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> = 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 } }); diff --git a/src/models.rs b/src/models.rs index 8a38111..e0872aa 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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 { /// Wrap a `Vec` in a Slint model ready for `MainWindow.set_messages`. pub fn mock_messages_model() -> ModelRc { 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, +) -> 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 { + let apps_by_id: std::collections::HashMap = + 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, 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()), + )) } \ No newline at end of file diff --git a/ui/app.slint b/ui/app.slint index 2d293c7..d46b605 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -1,9 +1,8 @@ // Main window + sidebar (AGENTS.md §6, §7). // -// Phase 2 C3 wires the Settings page, About dialog, and window-state -// persistence. The Appearance section in `settings-page.slint` is the only -// settings UI that drives real state today — every other control is a -// shell that Phase 5 hooks to `AppConfig`. +// Phase 3 C2 wires the live connection status (dot + status text under the +// title bar) and accepts a `test-connection-requested` callback fired by +// the Settings → Connection section. import { AppearanceMode } from "./theme.slint"; import { NavItem, SectionLabel } from "./components.slint"; @@ -26,11 +25,9 @@ export component MainWindow inherits Window { min-width: 760px; min-height: 520px; - // Theme switch — Rust-controllable via `ui.set_theme(...)`. in-out property theme: AppearanceMode.Dark; out property is-dark: theme != AppearanceMode.Light; - // Palette — all derived from `is-dark` (AGENTS.md §52). out property bg: is-dark ? #1f2024 : #f5f6f8; out property surface: is-dark ? #28292d : #ffffff; out property surface-elev: is-dark ? #2f3035 : #fafbfc; @@ -58,13 +55,13 @@ export component MainWindow inherits Window { callback message-card-clicked(int); // ── Phase 2 C3: Settings / About / window state ────────────── - // Initial appearance radio (0=System/1=Light/2=Dark). Maps 1:1 to - // AppearanceMode: System=0, Light=1, Dark=2. in-out property appearance-index: 2; - - // Show About dialog (one-shot — Rust closes it after handling). callback about-clicked(); - callback close-requested-save-state(); + + // ── Phase 3 C2: live connection status ─────────────────────── + in-out property is-connected: false; + in-out property status-text: "Disconnected"; + callback test-connection-requested(); HorizontalLayout { // §7 Sidebar width: 220-240px. @@ -83,12 +80,32 @@ export component MainWindow inherits Window { font-weight: 700; } Text { - text: "Windows Client · Phase 2"; + text: "Windows Client · Phase 3"; color: text-muted; font-size: 10px; } - Rectangle { height: 16px; } + Rectangle { height: 12px; } + + // Live status row (Phase 3 C2): dot + text. + HorizontalLayout { + spacing: 6px; + alignment: center; + Rectangle { + width: 8px; + height: 8px; + border-radius: 4px; + background: is-connected ? success : text-muted; + } + Text { + text: is-connected ? "Connected" : "Disconnected"; + color: is-connected ? success : text-muted; + font-size: 11px; + font-weight: 600; + } + } + + Rectangle { height: 12px; } SectionLabel { label: "MESSAGES"; text-color: text-muted; } NavItem { @@ -113,19 +130,19 @@ export component MainWindow inherits Window { label: "High"; selected: false; has-accent: false; - clicked => { /* TODO Phase 2 C2 */ } + clicked => { /* TODO Phase 3 C2: priority filter */ } } NavItem { label: "Normal"; selected: false; has-accent: false; - clicked => { /* TODO Phase 2 C2 */ } + clicked => { /* TODO Phase 3 C2: priority filter */ } } NavItem { label: "Low"; selected: false; has-accent: false; - clicked => { /* TODO Phase 2 C2 */ } + clicked => { /* TODO Phase 3 C2: priority filter */ } } Rectangle { height: 12px; } @@ -151,9 +168,10 @@ export component MainWindow inherits Window { Rectangle { background: bg; - if root.selection == SidebarSelection.All: VerticalLayout { + VerticalLayout { + // Title bar with status text under it. padding: 24px; - spacing: 12px; + spacing: 4px; Text { text: messages.length == 0 ? "Messages" : "Messages · " + messages.length; @@ -161,11 +179,18 @@ export component MainWindow inherits Window { font-size: 18px; font-weight: 700; } + Text { + text: status-text; + color: text-muted; + font-size: 11px; + } + + Rectangle { height: 12px; } if messages.length > 0: MessageList { messages: messages; selected-index: root.selected-message-index; - height: parent.height - 48px; + vertical-stretch: 1; message-clicked(idx) => { root.selected-message-index = idx; root.message-card-clicked(idx); @@ -173,6 +198,7 @@ export component MainWindow inherits Window { } if messages.length == 0: EmptyState { + vertical-stretch: 1; test-notification => { root.test-notification-clicked(); } @@ -187,11 +213,14 @@ export component MainWindow inherits Window { idx == 1 ? AppearanceMode.Light : AppearanceMode.Dark; } + test-connection-requested => { + root.test-connection-requested(); + } } - // About is rendered via a Dialog overlay — not in the right pane. - // A separate About row keeps the layout stable; the dialog is - // shown via `about-clicked` callback to Rust. + // About entry stays as a simple right-pane marker; the + // modal AboutDialog overlay is wired through AboutDialog in + // this component (visibility toggled via `about-clicked`). if root.selection == SidebarSelection.About: Rectangle { background: bg; Text { @@ -205,9 +234,5 @@ export component MainWindow inherits Window { } } - // Modal About dialog — opened when the user clicks About in the sidebar. - AboutDialog { - // visible-by-default is false. Toggled via Rust by listening to - // `about-clicked`. C3 wires the bare open/close plumbing. - } + AboutDialog {} } \ No newline at end of file diff --git a/ui/settings-page.slint b/ui/settings-page.slint index dc44311..dadc07a 100644 --- a/ui/settings-page.slint +++ b/ui/settings-page.slint @@ -137,6 +137,10 @@ export component SettingsPage inherits Rectangle { // Advanced (collapsed by default per §10) in-out property advanced-expanded: false; + // Connection (Phase 3 C2) + in-out property test-in-progress: false; + callback test-connection-requested(); + // Tokens in property divider-color: #3a3b40; in property hint-color: #6a6a6a; @@ -151,6 +155,29 @@ export component SettingsPage inherits Rectangle { VerticalLayout { spacing: 8px; SectionLabel { label: "CONNECTION"; text-color: hint-color; } + + // "Test Connection" button — Phase 3 C2 wires this to the + // real `GotifyApi::health` round-trip. + Rectangle { + width: 160px; + height: 32px; + background: #2f3035; + border-radius: 6px; + Text { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + text: root.test-in-progress ? "Testing..." : "Test Connection"; + color: #e6e6e6; + font-size: 12px; + } + TouchArea { + enabled: !root.test-in-progress; + clicked => { + root.test-connection-requested(); + } + } + } + ServerCard { server-name: "Home"; server-url: "https://gotify.example.com";