KennethCheng
|
a091e6839a
|
fix(ui): Phase 7 C5 — 移除假按钮 + 真实托盘图标
用户报告设置页很多按钮无反应 + 关闭后托盘看不到图标。两处问题:\n\n1. ServerCard 占位按钮无回调:\n- server-card.slint:去掉了 toggled/edit-clicked/remove-clicked 三个回调以及 enabled 切换、Edit / Remove 按钮;变成纯展示卡(状态点 + name + url + 当前标记)\n- settings-page.slint:去掉硬编码的 Home / NAS 占位,改用 active-server-name / active-server-url / active-server-enabled 三个属性(MainWindow → AppConfig 实时填充);空时显示 '(未配置服务器)' 空态提示\n- app.slint:MainWindow 加三个属性 + 转发给 SettingsPage\n- lib.rs:startup 阶段把活跃服务器信息塞进 UI;测试连接成功/失败时更新 enabled 状态\n\n2. 托盘图标看不见:\n- tray.rs:原本 hIcon 写 null,Win10+ 上 Shell_NotifyIconW(NIM_ADD, NULL hIcon) 实际渲染为空白或很弱的小方块。改用 LoadIconW(NULL, IDI_APPLICATION) 加载 Windows 自带的应用程序图标(无需嵌入 .ico 资源,主题感知)。show_toast / Drop 的 hIcon 维持 null(让 NIM_MODIFY/NIM_DELETE 不动原有图标)。AGENTS.md §53 标记真 .ico 资源为 V2 项。
|
2026-09-19 20:40:33 +08:00 |
|
KennethCheng
|
383bb404a9
|
fix(ui): Phase 7 C4 — 卡片间距修复 + 全 UI 翻译为简体中文
两个问题修一处:\n\n1. 布局 bug:filter 切到 Max/High/Normal/Low 后可见卡片之间留出巨大空白。原因:Slint isible: false 在 for 循环里没真正折叠 layout 槽位,被隐藏的卡片仍占 76px 高 + 8px spacing。修法:把过滤从 Slint 侧搬到 Rust 侧,新增 filtered_model:UI 绑 filtered_model,shared_model 作为全量 canonical;切换过滤时 rebuild_filtered_model + WS drain 用 push_message_to_models 同步两表;测试连接 / tray restart 也都双表更新。\n\n2. UI 默认中文:sidebar(MESSAGES/SERVERS/FILTERS/APP → 消息/服务器/过滤/应用;All/Max/High/Normal/Low/Settings/About → 全部/最高/高/普通/低/设置/关于)、详情面板(Open link → 打开链接)、空态(You're all caught up → 暂时没有新消息;Test Notification → 发送测试通知)、设置页(5 个分组 + 所有 toggle + Appearance radio)、AboutDialog、托盘菜单(打开/暂停通知/设置.../重新连接/退出/新消息:N)、状态文本(Disconnected → 未连接;mock feed → 模拟数据;messages → 条消息)。\n\n新增 priority-filter-changed(int) 回调,sidebar 切过滤时通知 Rust 重建 filtered model。SharedModel/FilteredModel 同步逻辑:HTTP catch-up → WS drain push → 测试连接 → tray restart 都用 helper 函数维护一致性。
|
2026-09-19 20:27:15 +08:00 |
|
KennethCheng
|
d2de452c53
|
fix(ui): Phase 7 C3 修复 — AboutDialog 改用 Rectangle overlay + backdrop
原 AboutDialog inherits Slint Dialog(PopupWindow 子类)并 inline 实例化,没经过运行时 show(),close() 是空操作 → 用户点 Close 没反应、UI 卡死。\n- dialogs.slint:AboutDialog / ConfirmDialog 改为 inherits Rectangle,手写 backdrop(点击关闭)+ content card(Close 按钮调 close-dialog 回调)\n- app.slint:新增 show-about bool 属性;Sidebar About 点击同时触发 about-clicked + show-about=true;最外层 if root.show-about: AboutDialog 渲染\n- 删掉 selection==About 的右栏 marker(现在 About 是模态,不再需要)
|
2026-09-19 19:40:16 +08:00 |
|
KennethCheng
|
cd67fe9e8c
|
chore: gitignore release/ 暂存目录(含上传用的重命名 EXE)
release/ 是上传到 Gitea release page 之前的暂存目录,canonical 产物在 Gitea(v0.1.0 资产 windows-gotify-client-rust-v0.1.0-x86_64-pc-windows-msvc.exe 已上传,download URL 在 LAN 上可达)。AGENTS.md §56 允许 release 里没二进制。
|
2026-09-19 19:33:36 +08:00 |
|
KennethCheng
|
c837115a0e
|
docs: Phase 8 — 性能基线 + 完整功能清单
在 Windows 11 x64 / Rust 1.98.1 / Slint 1.18 上实测:Release EXE 10.31 MB / 测试 22 passed / 启动后 RSS 153 MB / 空闲 CPU 0.15%。RSS 高于 AGENTS.md §33 的 50 MB 目标,原因是 Slint 1.18 默认 femtovg 后端的 GUI 堆占用约 120–150 MB;业务层只占很小一部分。README 记录所有 Phase 1–7 状态 + 已知限制。
|
2026-09-19 08:24:21 +08:00 |
|
KennethCheng
|
97becf5c4b
|
feat(ui): Phase 7 C3 — Priority 过滤 + 消息详情面板
- sidebar FILTERS 加 Max/High/Normal/Low 四档(dot 色与 priority 配色一致),点击切换 priority-filter(0=All, 1=Low, 2=Normal, 3=High, 4=Max)
- MessageList 加 priority-filter property,通过 visible 谓词按 Slint Priority enum 匹配;保留原 idx 让 selected-index 不随 filter 错位
- 右栏加 detail panel:app + priority dot、title、id、relative_time、body、has_click_url 时显示 Open link 按钮(复用 C2 的 message-card-clicked 走 url::open)
- detail panel 与消息列表水平分割,selected-message-index < 0 时自动隐藏;切换 Settings/About 时不显示
|
2026-09-19 08:23:01 +08:00 |
|
KennethCheng
|
301052698d
|
feat(gotify): Phase 7 C2 — Win32 Toast 通知 + URL 打开接线 + 单 VecModel
- tray.rs: 增加 show_toast 方法(PostMessageW + 堆分配 payload + WndProc 内 Shell_NotifyIconW NIM_MODIFY),遵守 NIM_MODIFY 必须 NIM_ADD 同线程的硬性要求
- WS drain 每条新消息根据 priority + cached desktop_notifications/notify_high/notify_max/paused atomics 决定是否弹 toast,icon_flags 按 High/Max 分别用 NIIF_WARNING/NIIF_ERROR
- on_message_card_clicked:点击含 has_click_url 的卡片时调 windows::url::open(已有 http/https scheme 严格校验)
- TrayState 增加 desktop_notifications/notify_high/notify_max/restart_requested 原子,Settings 切换实时同步,tray on_restart 只翻 restart_requested flag
- 修复 Phase 6 C3 隐藏 bug:Test Connection / tray Restart 用 set_messages(new_model) 会让 WS drain 推入旧 model。新增单例 Rc<VecModel>,统一改用 set_vec
- on_restart 改用 Arc<OnceLock<Arc<TrayState>>> 占位 + 设置,避免 tray 线程闭包捕获 Rc<VecModel>(!Send)
- ActiveConfig 派生 Clone(托盘初始化快照用)
|
2026-09-19 08:20:03 +08:00 |
|
KennethCheng
|
2614909c4c
|
feat(windows): Phase 7 C1 — 托盘菜单增强 + 单实例激活现有窗口
- 托盘菜单加 4 项:New messages: N(灰)/ Pause notifications(勾选态)/ Settings.../ Restart connection
- 单实例:第二次启动通过 RegisterWindowMessageW 发激活请求,托盘 WndProc 收后调用 on_open(show+reset counter);main.rs AlreadyRunning 路径调用 activate_existing_instance
- AppConfig 加 notifications_paused 字段(与 desktop_notifications 区分,tray 暂停持久化)
- WS drain 通过 Rc<RefCell<Option<Arc<TrayState>>>> 与托盘共享 new_messages 计数器
- WndProc 在调用 on_open 前自己清零计数器,免去回调侧的 RefCell 捕获(保持 Send+Sync)
|
2026-09-19 08:13:42 +08:00 |
|
KennethCheng
|
1df072356f
|
feat(windows): Phase 6 C3 — 系统托盘 + URL 打开
- src/windows/url.rs: open() 用 url::Url parse 校验 + scheme 严格
限制(只 http/https,拒绝 file:// / javascript: / data: / ftp /
自定义 scheme);通过 ShellExecuteW 调默认浏览器打开;§13
/ §38 安全约束
- src/windows/tray.rs:
· RegisterClassExW 注册隐藏 helper window class
· CreateWindowExW(WS_EX_TOOLWINDOW, 0×0) 创建不显示的
message-only helper window
· Shell_NotifyIconW NIM_ADD 注册托盘图标(默认 NULL icon,
Phase 7 polish 再换 .ico)
· dedicated thread 跑 GetMessageW / DispatchMessageW 标准
message loop
· WM_USER+1 uCallbackMessage 处理 tray 鼠标事件
· WM_LBUTTONDBLCLK = Open callback
· WM_RBUTTONUP = CreatePopupMenu + TrackPopupMenuEx 显示
Open / Quit 菜单
· WM_COMMAND 处理菜单 item IDM_OPEN / IDM_QUIT
· Drop 时 NIM_DELETE + DestroyWindow + thread.join() 干净退出
- src/lib.rs:
· #[cfg(windows)] 启动时建 TrayIcon,绑 on_open(show window)
/ on_quit(std::process::exit(0));失败时只 warn 不 abort
(托盘是便利功能,不致命)
· 引入 url::open;Phase 7 polish 时接到 message-card 点击
- src/windows/mod.rs: pub use tray + url
- Cargo.toml: windows-sys features 加 Win32_Graphics_Gdi
(WNDCLASSEXW 依赖)
避坑:
- LRESULT / WPARAM / LPARAM 在 windows-sys 0.59 是 type
alias,不是 struct — 不能 LRESULT(0) 构造;直接 0 返回
隐式 cast
- HMENU 是 *mut c_void;用 std::ptr::null_mut()(不是 null())
- NOTIFYICONDATAW_0 在 0.59 没 derive Default;用
unsafe { std::mem::zeroed() }
- std::mem::GUID::zeroed() 不存在;用 windows_sys::core::GUID
显式构造
- NIM_VERSION 常量 windows-sys 0.59 没 export;用字面 4u32
(NIM_VERSION = 4 in Win32 header)
- HWND (raw pointer) 不 Send;跨线程传递时转 usize
- Box<dyn Fn> 不能 derive Clone;用 Box + 唯一 move 到
TrayShared 不需要 Clone
- on_quit closure 不能捕获 Rc<RefCell<AppState>>(不是 Sync)
→ 简化:on_quit 直接 std::process::exit(0);之前 close
已经 save_state,丢失窗口只是 size 增量
- WNDCLASSEXW + RegisterClassExW 在 Win32_Graphics_Gdi
feature 后(hbrBackground 用 HBRUSH = GDI 类型)
验证:cargo build / build --release 都 exit 0,0 warning
- cargo test: 19 passed (新增 4 个 url rejection 测试 +
之前的 15 个); 0 failed
- debug 35.66 MB, subsystem = WINDOWS_CUI
- release 9.79 MB, subsystem = WINDOWS_GUI
- 托盘只能手测(CI 不能断言 WM_USER 消息);自动测试覆盖
URL 校验逻辑的拒绝路径
|
2026-09-19 07:28:49 +08:00 |
|
KennethCheng
|
e1622b5959
|
feat(windows): Phase 6 C2 — close-to-tray + Settings toggle 持久化
- ui/settings-page.slint: SettingsToggleRow 的 toggled callback
从 (bool) 改成 (string, bool) — 把 row 自己的 label + 新 value
一起交给 caller;row 内部先 value=!value 再 emit;value 改
in-out property 让内部可以 flip;SettingsPage 加 callback
toggle-persist(string, bool);7 个真 toggle 全部 wire 到
toggle-persist(Notifications 4 + Startup 3)
- ui/app.slint: MainWindow 加 in-out property ×7 +
setting-toggled(string, bool) callback;启动从 ActiveConfig
装载;SettingsPage section 把 7 个 property forward 给 page +
toggle-persist 转接到 root.setting-toggled
- src/lib.rs:
· 启动从 ActiveConfig 读 7 个 toggle 设到 MainWindow
· 新 on_setting_toggled 回调:load AppConfig → 按 label 改
对应字段 → save_config;start_with_windows 额外调
startup::set_enabled 写 HKCU Run(Phase 6 C1)
· on_close_requested 看 close_to_tray:true 时
window().hide() + KeepWindowShown;false 时保持原来的
HideWindow 行为
避坑:
- Slint 1.x callback 不能直接用 label 区分,必须 caller 传
string 参数;改造 (bool) → (string, bool)
- 设置 in-out property 让 row 内部能 flip value 然后 emit
新值(caller 收到的就是 post-flip 值)
- close_to_tray 在 UI MainWindow 上是 in-out property,Rust
端用 get_close_to_tray() 实时读,所以 toggle 立刻影响
close_requested 行为(不需要重启)
- on_close_requested 内部读 get_close_to_tray 要 upgrade()
MainWindow weak handle,捕获空时 fallback false(默认
close 而不是 hide)
验证:cargo build / build --release 都 exit 0,0 warning
- cargo test: 15 passed; 0 failed
- debug 35.64 MB, subsystem = WINDOWS_CUI
- release 9.78 MB, subsystem = WINDOWS_GUI
|
2026-09-19 07:14:35 +08:00 |
|
KennethCheng
|
ac5d0cb1d0
|
feat(windows): Phase 6 C1 — 单实例 mutex + HKCU Run 启动项
- src/windows/mod.rs: cfg(windows) 模块入口,pub use single_
instance + startup;其他 Phase 6 模块(C2 托盘 / C3 Toast / URL
打开)在后续 commit 加
- src/windows/single_instance.rs: CreateMutexExW("Local\\...")
→ mutex 名称 user-scope(多 RDP 会话各自能跑一个实例);第
二参 bInitialOwner=true 让 GetLastError() 可靠;返回 Acquired
/ AlreadyRunning enum
- src/windows/startup.rs: HKCU\Software\Microsoft\Windows\
CurrentVersion\Run\WindowsGotifyClientRust = 当前 exe 路径;set_
enabled / is_enabled 接口给 C2 Settings toggle 留口子;自定义
Win32Error 类型(windows-sys 0.59 没有 core::Error)
- src/lib.rs: 加 #[cfg(windows)] pub mod windows 入口
- src/main.rs: 启动前 enforce_single_instance() 检查;已有实
例直接 std::process::exit(0)(不抢前台窗口,留给 C2 tray
拿 hwnd 后再做)
- Cargo.toml: windows-sys 0.59 features 加 Win32 / Win32_Security
/ Win32_Foundation / Win32_System_Threading / Win32_System_
Registry / Win32_UI_WindowsAndMessaging / Win32_UI_Shell
避坑:
- windows-sys 0.59 没有 CreateMutexW(只是宏名 / 16-bit 兼容)
→ 用 CreateMutexExW
- HANDLE 不是 newtype struct,是裸 *mut c_void → 直接 as
isize,不要 .0
- CreateMutex[A/ExA/ExW/W] 全部 cfg-gated 在 Win32_Security feature
→ 必须开
- windows-sys 子 feature(Win32_System_Threading 等)依赖顶层
Win32 feature → 必须同时开
- std::mem::zeroed() 必须放 unsafe block 里
验证:cargo build / build --release 都 exit 0,0 warning
- cargo test: 15 passed; 0 failed
- debug 35.56 MB, subsystem = WINDOWS_CUI
- release 9.77 MB, subsystem = WINDOWS_GUI
- 实际功能(重复启动会 exit 0)只能手测;test 只能覆盖 JSON /
Rust 内部状态
|
2026-09-19 07:03:29 +08:00 |
|
KennethCheng
|
28524b4128
|
feat(storage): Phase 5 C2 — Theme + last_message_id 持久化
- src/storage/state.rs: AppState 加 last_theme (string "system" /
"light" / "dark") + last_message_id (i64);#[serde(default)]
让 legacy state.json(只有 window width/height)也能 forward-
compat parse;3 个 test(含 legacy round-trip)
- src/gotify/api.rs: messages() 加 since: Option<i64> 参数,
since>0 时拼 ?since=N&limit=M
- src/models.rs: try_fetch_initial_data 加 since 参数透传
- src/lib.rs: state_rc: Rc<RefCell<AppState>> 替代只读 initial_state
· 启动读 last_theme 应用到 MainWindow.theme + appearance_index
· HTTP catch-up 用 since=last_message_id;返回的 msgs 推
watermark
· WS drain closure 同步更新 last_message_id(共享 Rc 进 UI
线程 closure)
· on_close_requested 把整个 state clone 一份再 save(state_for_
save Rc 也被 drain 用,不能 hold borrow 同时 serialize)
· 新 callback appearance-changed-persist:Settings → Appearance
radio 改 theme 时立刻写 state.last_theme
- ui/app.slint: MainWindow 加 appearance-changed-persist callback;
SettingsPage 的 appearance-changed 调它
避坑:
- VecModel 不是 Send → last_message_id 维护必须放在 UI 线程的
timer drain closure 里(不是 WS worker),所以走 Rc<RefCell>
共享 + 不需要锁
- on_close_requested 之前用 state_for_save.borrow_mut() 修改然后
borrow() save——drain 也在 borrow_mut() 会死锁(同一 RefCell)。
改成"修改 borrow_mut + save clone",避免同时持有两个 borrow
- AppearanceMode::Dark 的 parse fallback:state.json 写错时不
锁死在 Light
- legacy state.json 没 last_theme / last_message_id 也能正常
parse + 用 default(serde(default) + 1 个测试覆盖)
验证:cargo build / build --release 都 exit 0,0 warning
- cargo test: 15 passed (6 storage unit + 6 api + 3 websocket); 0 failed
- debug 35.56 MB, subsystem = WINDOWS_CUI
- release 9.77 MB, subsystem = WINDOWS_GUI
|
2026-09-19 06:54:42 +08:00 |
|
KennethCheng
|
07317e2890
|
feat(storage): Phase 5 C1 — AppConfig 持久化到 config.json
- src/storage/config.rs: AppConfig + ServerEntry + load_config +
save_config,atomic write(tmp + rename + fsync);多 server
数据结构按 §21 落地但 V1 只 active 一个;3 个 #[cfg(test)]
round-trip + default + missing-field 测试
- src/storage/state.rs: 从 src/storage.rs 拆过来;atomic write + 2
个测试(default 960×680 + JSON round-trip)
- src/storage/mod.rs: pub use re-export 两组 API
- src/config.rs: 重构为 ActiveConfig thin wrapper,调
storage::load_config() 拿到第一个 selected_server;placeholder
hardcoded URL/token 在 §19/§20 意义上不再合适,删掉
- src/gotify/model.rs: ServerConfig 加 Serialize/Deserialize(要
写到 config.json 必须能 serialize;token 仍走 §20 不进日志)
- src/lib.rs: run() 启动用 ActiveConfig::load() 取代 placeholder;
on_test_connection_requested 同样改用 ActiveConfig::load()
- 删除 src/storage.rs(被 src/storage/{config,state}.rs 替代;
同样内容已迁过去)
避坑:
- ServerConfig 之前没 derive Serialize,所以 #[serde(flatten)]
嵌套到 ServerEntry 失败——加 derive 是必要的
- src/storage.rs 和 src/storage/mod.rs 不能并存,否则 Rust 报
ambiguous module;删旧文件就行
- AppConfig 多 vec + serde default 让旧 config 文件(字段缺失)
也能 forward-compat parse(3 个测试覆盖)
验证:cargo build / build --release 都 exit 0,0 warning
- cargo test: 14 passed (5 storage unit + 6 api + 3 websocket); 0 failed
- debug 35.54 MB, subsystem = WINDOWS_CUI
- release 9.76 MB, subsystem = WINDOWS_GUI
|
2026-09-19 06:46:16 +08:00 |
|
KennethCheng
|
0b766f496e
|
feat(gotify): Phase 4 C2 — WS worker + channel + UI timer drain
- src/lib.rs: HTTP catch-up 拿 apps+msgs → 保留 apps 给 WS worker →
spawn std::thread 跑 websocket::run_stream_loop;WS 帧 → MessageItem
→ std::sync::mpsc::channel → UI 线程 slint::Timer 100ms tick drain
→ shared Rc<VecModel<MessageItem>>::push
- src/models.rs: 拆 try_fetch_initial_data 返回 (apps, msgs, text)
原始 DTO,让 lib.rs 自己决定 model 包装时机;保留 try_fetch_real_messages
+ 新增 build_message_model 给 Settings → Test Connection 复用
避坑:
- VecModel 不是 Send,不能跨线程直接 push——必须 mpsc channel +
UI 线程 timer 中转(之前想用 invoke_from_event_loop 包装 Rc 失败:
Rc 不是 Send,closure 不满足 FnOnce + Send + 'static)
- mpsc::Receiver 在 std Rust 是 !Sync 但 Send;Timer::start 只要求
'static 不要求 Send(Timer callback 总在 UI 线程跑)—— 所以
closure 直接 capture rx 不需要 wrap
- channel 选 unbounded:WS 重连风暴期间 message 风暴,UI 100ms
drain 完全跟得上,不需要 backpressure
- drain_timer 必须持有到 run() 末尾否则被 drop(slint Timer drop
自动停止)
验证:cargo build / build --release 都 exit 0,0 warning
- cargo test: 12 passed (3 reconnect unit + 6 api + 3 websocket); 0 failed
- debug 35.46 MB, subsystem = WINDOWS_CUI
- release 9.75 MB, subsystem = WINDOWS_GUI(channel + timer drain +0.3 MB)
|
2026-09-19 06:41:20 +08:00 |
|
KennethCheng
|
319eeb44ae
|
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 持平)
|
2026-09-19 06:00:40 +08:00 |
|
KennethCheng
|
0cf7a4eaaa
|
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)
|
2026-09-19 05:18:26 +08:00 |
|
KennethCheng
|
c6ad3883f8
|
feat(gotify): Phase 3 C1 — Gotify REST API client
- src/gotify/api.rs: blocking reqwest 封装(§14);health / applications
/ messages 三个方法;Bearer token 用 HeaderValue::set_sensitive
标记,日志绝不泄露(§20);10s per-request timeout
- src/gotify/model.rs: GotifyHealth / GotifyApplication / GotifyMessage
DTO 镜像官方 wire schema;defaultPriority 加 rename(camelCase)
- src/gotify/mod.rs: re-export
- src/error.rs: 加 AppError::Gotify(String)
- Cargo.toml: reqwest 0.12 (blocking + json + rustls-tls) 从注释升级为真
依赖(AGENTS.md §44 模板里早就列了,Phase 1 当时推迟)
- tests/gotify_api.rs: 6 个 JSON 解析测试
避坑:
- reqwest 0.12 默认 log Bearer token;要 set_sensitive(true) 才行
- reqwest::Error 转 AppError 不能用返回 impl Fn 的 helper(path 是
&str 不是 'static)—— 改成每个调用点 inline closure
- Gotify 的 defaultPriority 是 camelCase,其他字段都是单字;
用 #[serde(rename = "defaultPriority")] 单点 rename 而不是全局
rename_all = "camelCase"(因为 Rust 端字段名是 snake_case)
验证:cargo build / build --release 都 exit 0,0 warning
- cargo test --test gotify_api: 6 passed; 0 failed
- debug 27.88 MB, subsystem = WINDOWS_CUI
- release 8.10 MB, subsystem = WINDOWS_GUI(reqwest 加进 release
LTO 后体积几乎没变——rustls 是大头但被 strip + LTO 吃掉了)
|
2026-09-19 04:57:44 +08:00 |
|
KennethCheng
|
680fbfa0ad
|
feat(ui): Phase 2 C3 — Settings 页 + Dialogs + 窗口状态保存
- src/storage.rs: AppState (last_window_width/height) + load/save
atomic write 到 %APPDATA%/WindowsGotify/state.json(§18)
- ui/settings-page.slint: 5 分组(Connection / Notifications / Startup
/ Appearance / Advanced),Appearance 实时切主题(System/Light/Dark
radio);Advanced 折叠
- ui/dialogs.slint: AboutDialog + ConfirmDialog(§54)
- ui/server-card.slint: 单 server 行(name/url/enabled toggle/edit/remove)
- ui/app.slint: settings 页切换、About 导航、theme 联动
- src/lib.rs: load_state → 启动时应用窗口尺寸;ui.window()
.on_close_requested 在 OS 关闭前 save_state;wire about/close
callbacks
- src/error.rs: 加 AppError::Storage(String) 变体
- Cargo.toml: 加 serde + serde_json
避坑:
- Slint 1.x 同文件内 export component 必须在引用前定义(sub-components
移到 SettingsPage 上面)
- Slint 不支持 padding 两值简写 (padding: 0 16px) → 用 padding-* 四属性
- Slint 不支持 Rectangle 内建 border-color 覆盖 → 命名 card-border
- Slint 不支持 alignment: right → 用 spacer + horizontal-stretch: 1
- 修了一处 .slint 文件被 PowerShell Set-Content GBK 损坏的问题(已
通过 write 工具 + UTF-8 no BOM 重写并清理 ui/ 所有 .slint)
验证:cargo build / build --release 都 exit 0,0 warning
- debug 27.55 MB, subsystem = WINDOWS_CUI
- release 8.10 MB, subsystem = WINDOWS_GUI
|
2026-09-19 04:47:54 +08:00 |
|
KennethCheng
|
11a122216b
|
feat(ui): Phase 2 C2 — Mock 消息模型 + Message list + Empty state
- ui/message-card.slint: export enum Priority + struct MessageItem +
MessageCard 组件(§7 优先级圆点 + 标题 + 应用·预览 + 相对时间 +
URL 指示)
- ui/message-list.slint: VerticalLayout + for 渲染 MessageCard
(避开 std-widgets 的 include_paths 配置需求)
- ui/empty-state.slint: §9 ✦ glyph(Path 自绘)+ "You're all caught up"
+ Test Notification 按钮
- ui/app.slint: messages.length 切换 list / empty-state;点击回
调到 MainWindow 顶层 callback
- src/models.rs: mock_messages() 生成 12 条覆盖 4 个 priority 的
假消息;format_relative() 输出 "2 min ago" / Yesterday 等
- src/lib.rs: ui.set_messages(mock_messages_model()) 装载;wire
on_test_notification_clicked / on_message_card_clicked
避坑:
- Slint 1.18 不允许覆盖 Rectangle 内建 border-color,命名用 card-border
- ModelRc::from 需要 Rc<VecModel<T>> 不是裸 VecModel
验证:cargo build / build --release 都 exit 0,0 warning
- debug 26.55 MB, subsystem = WINDOWS_CUI
- release 7.94 MB, subsystem = WINDOWS_GUI
|
2026-09-19 04:39:24 +08:00 |
|
KennethCheng
|
49cbd9e371
|
feat(ui): Phase 2 C1 — MainWindow + Sidebar + 颜色/主题系统
完成 AGENTS.md §57 Phase 2 的前 3 项:MainWindow 布局、Sidebar、Dark 主题
基础。剩余的 Message list / Empty state / Settings / Light-Switch /
Window state 留到 C2 / C3。
技术栈变化
----------
本次最大的工程变化是把 Slint UI 从 `slint!{}` macro 内嵌迁到独立
`.slint` 文件 + build.rs 编译:
ui/app.slint MainWindow + Sidebar 骨架(Phase 2 C1 完整实现)
ui/theme.slint export enum AppearanceMode { System, Light, Dark }
(不再有 export global Theme / Palette,见下文)
ui/components.slint NavItem / SectionLabel / Divider(Dark 默认颜色)
ui/message-card.slint 占位 stub(C2 实现)
ui/message-list.slint 占位 stub(C2 实现)
ui/empty-state.slint 占位 stub(C2 实现)
ui/settings-page.slint 占位 stub(C3 实现)
ui/server-card.slint 占位 stub(C3 实现)
ui/dialogs.slint 占位 stub(C3 实现)
build.rs slint_build::compile("ui/app.slint"),
把生成的 .rs 路径写入 SLINT_INCLUDE_GENERATED
src/lib.rs `slint::include_modules!()` 引入编译产物;
`MainWindow::new()` 拿 UI 实例;
`ui.set_theme(AppearanceMode::Dark)` 初始化
(C3 改成从 state.json 读 + 监听用户切换)
为什么不用 `include_slint!` —— 那是 `slint_interpreter` crate 提供的
API,不在 `slint` 1.18 主 crate 里。官方推荐的"build-time precompile"
路线是 `slint_build::compile()` + `slint::include_modules!()`,文档明确
写了这一步。
为什么不用 export global Palette / Theme
---------------------------------------
最初一版我把 Theme + Palette 都做成 `export global`,期望从 Rust 端
用 `ui.global::<Theme>().set_appearance(...)` 切换 Light/Dark。失败
了:slint-build 1.18 在生成 Rust 代码时把这两个 global 都 dead-code
消除了,generated 的 pub use 列表里只剩 `MainWindow` 和
`SidebarSelection`。原因——global 的所有 binding 在编译期都被简化为
常量(Theme.appearance 默认 Dark,Palette.background 绑定直接 inline 成
`#1f2024`),没有任何 dynamic 引用保留 global 的符号表。
解决方案:把 Theme + Palette 全部搬到 MainWindow 上作为 out properties,
由 `MainWindow.theme` 触发 binding 重新计算。Rust 端用
`ui.set_theme(AppearanceMode::Light)` 切。代价是 sidebar 的 NavItem
暂时只有 Dark 默认色(fixed `#2f3035` 等),Light theme 在 C3 通过
property forwarding 扩展 NavItem 时补齐——这是 C1 阶段明确接受的折衷。
这一节值得写进 commit 而不是悄悄改掉——后面任何看到代码的人都会好奇
"为什么 palette 不直接 export global?",是这个原因。下次有 Phase 3+ 的人
碰这块也省得踩同样的坑。
Slint 版本对齐
--------------
之前 commit 时钉过 `slint = "1"`(让 cargo 选最新 1.x 稳定版),现在
slint-build 1.18.0 引入 build-deps:`slint-build`, `i-slint-compiler` 等。
两者锁到同一个 1.18.0 版本是 Slint 自身的 forward-compat 约束
(slint-build 的 `compat-1-18` feature 在 build.rs 启动时强校验)。
MainWindow / Sidebar 布局细节
-----------------------------
按 AGENTS.md §6 / §7:
- 960×680 preferred,760×520 min —— Slint 1.18 不允许同时设
`width` + `min-width`,用 `preferred-width`/`preferred-height` 替代
(之前第一次 build 报 `Cannot specify both 'width' and 'min-width'`,
这里记录一下)。
- 232px sidebar 宽,含 AGENTS.md §7 列出的 6 个 section
(logo + All + Servers 占位 + Filters 三个 + Settings + About)
- SidebarSelection enum 驱动右侧 view-switcher,三个 view 都有占位
文字(Messages / Settings / About),C2 / C3 替换实际内容
- AGENTS.md §52 颜色 token 全部按 dark / light 两套列出,由
`MainWindow.is-dark` 派生;优先级颜色(§7 message card)也按
§7 Low/Normal/High/Max 映射
未做(按计划归到后续 commit)
------------------------------
C2:
- src/models.rs(MessageItem + Priority enum,AGENTS.md §34)
- src/mock.rs(生成 10 条 mock 消息)
- ui/message-card.slint 真实内容
- ui/message-list.slint 真实内容(ListView 接 Rust Model adapter)
- ui/empty-state.slint 真实内容(§9 "You're all caught up" + ✦ + Test button)
- main 区 if-switch 真正根据 list.is_empty 选 list / empty-state
C3:
- src/storage/* —— 持久化 state.json
- ui/settings-page.slint 真实内容(§10 五个分组 + Advanced 折叠)
- ui/server-card.slint 真实内容
- ui/dialogs.slint 真实内容(About + confirm)
- sidebar NavItem 接 palette forwarding,Light theme 真的换 sidebar 颜色
- 主题切到 Light/System 实时生效(System 跟随 Windows 主题)
验证
----
cargo build exit 0, 5.10s(增量)
cargo build --release exit 0(slint + skia + accesskit 全重编,约 8 分钟)
warnings 0
debug .exe 26.01 MB, subsystem = 3 (WINDOWS_CUI / console)
release .exe 7.87 MB, subsystem = 2 (WINDOWS_GUI)
Phase 2 C1 验收:能在 Windows 上打开 MainWindow(960×680、Dark、含完整
Sidebar),通过 release profile 的 WINDOWS_GUI subsystem 验证无 console。
Sidebar 切换 Settings/About 时右侧文案占位更新——C2/C3 替换为实际 view。
|
2026-09-19 04:34:06 +08:00 |
|
KennethCheng
|
b30b2f0e28
|
feat: 实现 Phase 1 — Bootstrap(Slint hello window + 编译验证通过)
完成 AGENTS.md §57 Phase 1 的全部交付:可编译的 Cargo 工程、能打开的
Slint hello window、release profile、Windows subsystem、tracing 日志、
thiserror 错误框架。`cargo build` 与 `cargo build --release` 均通过,
0 warning。
实现概览
--------
新增 / 修改的文件:
src/lib.rs lib crate 入口;`slint!{}` 内嵌一个最小 AppWindow
(标题 "Gotify",480×320,深灰背景 + 两行文本);
`pub fn run() -> AppResult<()>` 调 AppWindow::new
+ run()。
src/main.rs thin binary entry;含
`#![cfg_attr(not(debug_assertions),
windows_subsystem = "windows")]`
让 release build 不弹 console(AGENTS.md §43);
debug build 保留 console 让 tracing 日志可见。
src/error.rs `AppError` + `AppResult`,thiserror 2.0.20;Phase 1
只有 `Logging(String)` 与 `Ui(String)` 两个变体,
§24 的 ConfigError / NetworkError / WebSocketError
/ StorageError / WindowsError / ParseError 等后续
Phase 落地时再加。
src/logging.rs `init_logging()`:tracing-subscriber 0.3.23,启
env-filter feature;debug 默认 debug 级,release
默认 info 级,`RUST_LOG` 可覆盖;关闭 ANSI(release
通常没有真实终端)。符合 §25 的"默认不记录消息正文"
基线——具体红线靠代码 review 强制。
Cargo.toml 实际可编译的 manifest:[[bin]] 指向 src/main.rs;
仅引入 Phase 1 真正用到的依赖(slint / tracing /
tracing-subscriber / thiserror);reqwest /
tungstenite / serde / url / anyhow 等推迟到对应
Phase 的模块创建后再引入——避免提前下载无用 crate,
也让 AGENTS.md §44 的 "Cargo 依赖最小化原则" 落到
每一阶段的 commit 上。
Cargo.lock 已生成(564 个 transitive deps,含 slint 1.18.0、
skia-bindings、accesskit、parley、fontique 等)。
二进制工程,lock 文件进 git——保证可重现构建。
技术选型说明
------------
- Slint 版本选择:AGENTS.md §1 写的是 "Slint 1.17.x 或当前稳定兼容版
本"。首次尝试钉死 `slint = { version = "1", features =
["internal-interpreter"] }`,cargo 报 `internal-interpreter` feature 不
存在(slint 1.18.0 已移除该 feature,slint!{} macro 走 default feature
set 即可)。改为 `slint = "1"`,cargo 自动选到 1.18.0,与 Rust 1.98.1 +
MSVC 工具链编译通过。AGENTS.md §1 的措辞明确允许"当前稳定兼容版本",
本次选 1.18.0 不偏离约束。
- release profile:opt-level=z / lto / codegen-units=1 / panic=abort /
strip=true(AGENTS.md §42 模板)。Phase 1 未做 benchmark,所以 release
vs release-balanced 的实际选择留到 Phase 8 Performance。
- 没引 slint-build / 没建 build.rs:Phase 1 hello window 内嵌在
`slint!{}` macro 里,足以通过验收。Phase 2 切到独立 .slint 文件 +
`include_slint!()` 时再决定是否需要 build.rs。
验证结果
--------
cargo build exit 0, 5.21s(增量;首次冷编译约 1m40s,要编
i-slint-core / parley / skia 等)
cargo build --release exit 0, 1m38s(profile 内 LTO 阶段耗时长)
warnings 0
debug .exe 25.58 MB, PE subsystem = 3 (WINDOWS_CUI / console)
release .exe 7.82 MB, PE subsystem = 2 (WINDOWS_GUI)
AGENTS.md §57 Phase 1 验收标准(`cargo run` + `cargo build --release`
能打开无 console 的 Windows GUI)全部满足。PE subsystem 通过直接读
Optional Header 的 Subsystem 字段验证——而不是盲目信任 cfg_attr。
下一步
------
进 Phase 2 — UI Shell(MainWindow / Sidebar / Message list / Empty
state / Settings / Dark-Light-System 主题)。本阶段仍然只用 mock 消息,
不接网络。
遗留
----
- commit message 之前那条 root commit (`a3a6a7c`) subject 前有个 UTF-8
BOM (`\ufeff`),是 PowerShell 5.1 `Set-Content -Encoding UTF8` 默认带
BOM 造成的。本 commit 用 `[System.IO.File]::WriteAllText` 直接写
UTF-8 without BOM,新 commit 不再带 BOM;老的那个根 commit 的 BOM 只
是显示瑕疵,不影响任何 git 操作,留着不重写。
- 首次 cargo build 拉了 564 个 crate、走代理拉取成功(crates.io 直连也
通,HTTPS_PROXY 走 127.0.0.1:7890 是兜底)。
- target/ 1.4 GB 在 .gitignore 内,不会进 git。
- 用户的 .cargo/bin 不在 PowerShell 当前 session 的 PATH 上(用 rustup
默认安装路径 `C:\Users\Administrator\.cargo\bin`),后续 cargo 命令
脚本里临时加 PATH 即可,未修改用户全局环境。
|
2026-09-19 04:15:19 +08:00 |
|
KennethCheng
|
a3a6a7c91d
|
chore: 初始化 Windows-Gotify-Client-Rust 项目骨架
使用 Rust + Slint 重建一个轻量、现代、Windows 原生体验良好的 Gotify
桌面客户端。本次只搭目录骨架与占位文件,未进入 Phase 1(Bootstrap)。
本次提交内容
------------
- 保留 AGENTS.md(1363 行定制 Agent 实施计划,含 8 阶段路线图)
- 新建 Cargo.toml([package] 元数据 + AGENTS.md §44 依赖模板 + §42
release profile;无 [[bin]],等 Phase 1 才加 src/main.rs)
- 新建 README.md(项目导览,引用 AGENTS.md)
- 新建 .gitignore(Rust / Slint / Windows / IDE / 密钥)
- 新建 LICENSE(MIT 占位)
- 按 AGENTS.md §4 建好全部 10 个目录,每个空目录放 .gitkeep:
assets/ assets/fonts/
ui/ scripts/
src/ src/gotify/
src/storage/ src/windows/
src/services/ tests/
未提交(Phase 1 起才落地)
------------------------
- src/main.rs 及所有 .rs 业务代码
- ui/*.slint 视图
- scripts/build-release.ps1 等构建脚本
- Cargo.lock(当前 Cargo.toml 是占位、无可编译代码)
技术栈约束(来自 AGENTS.md §1 / §2)
------------------------------------
- Rust stable + Slint 1.17.x + Windows 10/11
- 显式禁用:Tauri / Wails / Electron / WebView2 / WPF / WinUI 3 / Node 运行时
- 单一主程序,无浏览器运行时依赖;UI 由 Slint 原生渲染
仓库
----
远程:https://gitea.kennethcheng.cc/KennethCheng/Windows-Gotify-Client-Rust
|
2026-09-19 04:02:51 +08:00 |
|