From 37aa8a4ed46f613bdca7e55c5f756948f89fdbfa Mon Sep 17 00:00:00 2001 From: TimLai666 Date: Fri, 10 Jul 2026 11:05:14 +0800 Subject: [PATCH] fix(whp-gui): window resize scales the picture (cage keeps native mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-hardware validation of #118's WHP dynamic-resolution path on this box (gui-demo/xeyes, CHEFER_WHP_TRACE_GPU=1) showed the device→interrupt→guest half works — resizing to 1024x845 produced, in order: `requesting guest mode 1024x845` → guest `GET_DISPLAY_INFO/GET_EDID -> 1024x845` → `events_clear 0x1`. But `cage` (a fixed-mode kiosk compositor) does NOT re-modeset on an online mode change: the driver re-reads display info, userspace ignores it, and scanout flushes stay at the native 1280x800. Forcing it via a disconnect→reconnect "replug" (report scanout disabled, then re-enabled) does make cage react — by tearing down the output, which exits the interface app and fail_fast kills the whole app. So a live guest re-modeset is not achievable with cage. Ship the honest, stable behavior instead: the host window resizes freely and the picture scales (StretchDIBits). gui_window replaces the pending-resize/timeout machinery (which assumed the guest would re-modeset and briefly paused resize-to-content) with a one-way `user_sized` latch: once the user resizes, resize-to-content is disabled permanently so the window never snaps back; absolute pointer coords still map by window ratio, so input stays correct. The device-side config-change signal is kept and unit-tested — it is correct and future-compatible: swapping cage for a mode-following compositor enables true re-modeset with no device changes. Added CHEFER_WHP_TRACE_GPU traces on GET_DISPLAY_INFO / GET_EDID / events_clear for this kind of diagnosis. whp-helper 120 unit tests green locally (Windows-native build); the resize test now asserts the scanout stays enabled (never simulate disconnect). DESIGN/README updated to the validated behavior. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- crates/whp-helper/src/gui_window.rs | 47 ++++++++--------------------- crates/whp-helper/src/virtio/gpu.rs | 30 +++++++++++++++--- docs/DESIGN.md | 3 +- 4 files changed, 42 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 3efd0c7..95c92b6 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ you can "cook" your containerized application into a portable single-file app, m | **Internal networking** (services reach each other via `127.0.0.1:`) | ✅ | ✅ | 🔧 | | **Host port mapping — TCP** (`"host:guest"`, host≠guest proxied) | ✅ | ✅ verified | 🔧 | | **Host port mapping — UDP** | ✅ | ✅ verified — Chefer relays via the VM IP + an in-VM eth0→loopback bridge (WSL2's own forwarding is TCP-only) | 🔧 | -| **GUI services** | ✅ X11 / Wayland socket passthrough | ✅ WSL2 via WSLg ([gui-demo](examples/gui-demo)); **✅ WHP (no WSL)** — bundle-embedded `cage` compositor on a virtio-gpu scanout shown in a native window, with keyboard/mouse + bidirectional clipboard (text validated bidirectionally on real hardware; PNG images via the `PNG` format / `CF_DIB`, host read/convert path verified via `--clip-selftest`) | 🔧 host side written (`--gui`: `VZVirtualMachineView` window + HID + NSPasteboard clipboard, same guest path as WHP); compiles/signs on CI, real-Mac VZ validation pending | +| **GUI services** | ✅ X11 / Wayland socket passthrough | ✅ WSL2 via WSLg ([gui-demo](examples/gui-demo)); **✅ WHP (no WSL)** — bundle-embedded `cage` compositor on a virtio-gpu scanout shown in a **resizable** native window, with keyboard/mouse + bidirectional clipboard (text validated bidirectionally on real hardware; PNG images via the `PNG` format / `CF_DIB`, host read/convert path verified via `--clip-selftest`; resizing the window scales the picture — the resolution-change signal reaches the guest driver, verified on real hardware, but the `cage` kiosk compositor keeps its native mode, so there's no true guest re-modeset) | 🔧 host side written (`--gui`: `VZVirtualMachineView` window + HID + NSPasteboard clipboard, same guest path as WHP); compiles/signs on CI, real-Mac VZ validation pending | | **`crash: fail_fast`** (any non-zero exit tears down the app, code propagated) | ✅ | ✅ verified | 🔧 | | **Data-dir migration** (`old_names`) | ✅ | ✅ | 🔧 | | **Official chown/gosu images** (redis, postgres, …) | ✅ as root (no userns); ✅ rootless via `/etc/subuid` + `newuidmap` (falls back to single-uid without them) | ✅ verified — distro runs as real root, runs official redis/postgres as-is | 🔧 | diff --git a/crates/whp-helper/src/gui_window.rs b/crates/whp-helper/src/gui_window.rs index 88bcd65..92856bd 100644 --- a/crates/whp-helper/src/gui_window.rs +++ b/crates/whp-helper/src/gui_window.rs @@ -45,9 +45,6 @@ use crate::virtio::input::{ const REPAINT_TIMER_ID: usize = 1; /// 重繪輪詢間隔(ms):~60fps 上限,實際只在 framebuffer 有新 generation 時真的重畫。 const REPAINT_TIMER_MS: u32 = 16; -/// 動態解析度:等 guest 換到請求尺寸的上限(WM_TIMER tick 數;16ms × 250 ≈ 4s)。 -/// 逾時(guest 不理/夾住請求)→ 放棄 pending,恢復 resize-to-content 跟隨 guest 實際尺寸。 -const PENDING_RESIZE_TICKS: u32 = 250; /// 視窗執行緒與 VM 端共用的狀態(存入 GWLP_USERDATA)。 struct WindowState { @@ -69,11 +66,11 @@ struct WindowState { in_sizemove: bool, /// 我們自己的 resize-to-content SetWindowPos 造成的 WM_SIZE 要忽略(防回饋迴圈)。 programmatic_resize: bool, - /// 已對 guest 發出、尚未看到對應 scanout 尺寸的請求;期間暫停 resize-to-content - ///(否則 guest 換模式前的舊尺寸 frame 會把視窗又改回去)。 - pending_resize: Option<(u32, u32)>, - /// pending 的剩餘等待 tick(歸零 = 逾時放棄)。 - pending_ticks: u32, + /// 使用者手動改過視窗尺寸 → 尺寸主導權移交使用者:**永久停用 resize-to-content**。 + /// guest 若跟上請求(會跟模式的 compositor)幀尺寸=視窗 → 自然 1:1;不跟(cage 這種 + /// 固定模式 kiosk,實機驗證)→ 以 StretchDIBits 縮放顯示(abs 座標按視窗比例映射, + /// 輸入仍正確),視窗不彈回。 + user_sized: bool, } /// VM 端持有的視窗把手。 @@ -193,8 +190,7 @@ fn window_thread( snap_h: 0, in_sizemove: false, programmatic_resize: false, - pending_resize: None, - pending_ticks: 0, + user_sized: false, }); let state_ptr = Box::into_raw(state); @@ -263,23 +259,12 @@ unsafe extern "system" fn wnd_proc( state.snap_w = w; state.snap_h = h; state.last_gen = generation; - // 動態解析度 pending:等 guest 換到請求尺寸期間,暫停 resize-to-content - //(此刻進來的仍是舊尺寸 frame,跟隨它會把視窗改回去 = 回饋迴圈)。 - // guest 到位(frame 尺寸 = 請求)或逾時(guest 不理/夾住)→ 解除 pending。 - if let Some((pw, ph)) = state.pending_resize { - state.pending_ticks = state.pending_ticks.saturating_sub(1); - if (w, h) == (pw, ph) || state.pending_ticks == 0 { - state.pending_resize = None; - } - } // guest 選定的 scanout 解析度可能 ≠ 視窗大小(cage/wlroots 挑的模式)。 // 視窗 client 區跟著調整為 guest 實際解析度 → 1:1 blit、不放大失真; // 同時讓 abs 座標映射(用 state.width/height)與可見畫面一致。 - if state.pending_resize.is_none() - && (w, h) != (state.width, state.height) - && w > 0 - && h > 0 - { + // **使用者手動改過尺寸(user_sized)後永久停用**——尺寸主導權在使用者, + // guest 沒跟上就以縮放顯示,絕不把視窗改回去。 + if !state.user_sized && (w, h) != (state.width, state.height) && w > 0 && h > 0 { state.width = w; state.height = h; state.programmatic_resize = true; @@ -287,12 +272,6 @@ unsafe extern "system" fn wnd_proc( state.programmatic_resize = false; } unsafe { InvalidateRect(hwnd, std::ptr::null(), 0) }; - } else if state.pending_resize.is_some() { - // 無新 frame 也要倒數(guest 可能完全沒送新畫面)。 - state.pending_ticks = state.pending_ticks.saturating_sub(1); - if state.pending_ticks == 0 { - state.pending_resize = None; - } } 0 } @@ -425,12 +404,12 @@ unsafe fn user_resize_request(hwnd: HWND, state: &mut WindowState) { if (w, h) == (state.width, state.height) { return; // 沒變(含我們自己 resize-to-content 引出的 WM_SIZE)→ 不請求 } - // 立刻讓 blit 目標與 abs 座標映射跟上新 client 尺寸(過渡期舊 frame 以拉伸顯示, - // 座標按「視窗內比例 = guest 螢幕比例」映射仍正確)。 + // 尺寸主導權移交使用者:blit 目標與 abs 座標映射跟上新 client 尺寸(guest 沒跟上時 + // 舊尺寸 frame 以拉伸顯示,座標按「視窗內比例 = guest 螢幕比例」映射仍正確), + // 並永久停用 resize-to-content(見 WindowState::user_sized)。 state.width = w; state.height = h; - state.pending_resize = Some((w, h)); - state.pending_ticks = PENDING_RESIZE_TICKS; + state.user_sized = true; state.resize.request(w, h); } diff --git a/crates/whp-helper/src/virtio/gpu.rs b/crates/whp-helper/src/virtio/gpu.rs index 2dc0d0b..87ca1e3 100644 --- a/crates/whp-helper/src/virtio/gpu.rs +++ b/crates/whp-helper/src/virtio/gpu.rs @@ -103,14 +103,21 @@ impl GpuDevice { /// bit 寫來清除,virtio-gpu spec §5.7.4)。其餘 offset 唯讀、忽略。 pub fn config_write(&mut self, offset: u64, value: u32) { if offset == 4 { + // 低頻(config 事件 ack);trace 供動態解析度實機診斷。 + if std::env::var_os("CHEFER_WHP_TRACE_GPU").is_some() { + eprintln!("[whp-gpu] guest events_clear {value:#x}"); + } self.events_read &= !value; } } /// 動態解析度(DESIGN §6「WHP GUI」動態解析度):host 視窗被使用者改尺寸後呼叫。 /// 更新 scanout 0 的顯示尺寸(GET_DISPLAY_INFO / GET_EDID 隨之回新值)並置 - /// EVENT_DISPLAY——呼叫端接著發 config-change 中斷,guest driver 重讀 display info、 - /// DRM hotplug → cage/wlroots 以新偏好模式 re-modeset(新的 SET_SCANOUT/FLUSH 進來)。 + /// EVENT_DISPLAY——呼叫端接著發 config-change 中斷,guest driver 重讀 display info + /// 與 EDID。**會跟模式的 guest compositor** 據此 re-modeset;cage 這種固定模式 kiosk + /// 不會(實機驗證:driver 有重抓、userspace 不動;host 視窗端以縮放顯示因應——見 + /// gui_window。曾試過「回報 disabled→enabled」的 replug 模擬拔插:connector 短暫 + /// disconnected 會讓 cage 拆輸出 → 介面 app 隨之退出 → fail_fast 收掉整個 app,故不採)。 /// 尺寸不變或荒謬(0 / >16384)→ false(呼叫端不需發中斷)。 pub fn request_display_size(&mut self, width: u32, height: u32) -> bool { if width == 0 || height == 0 || width > 16384 || height > 16384 { @@ -196,11 +203,23 @@ impl GpuDevice { fn execute(&mut self, cmd: u32, req: &[u8], mem: &mut M) -> (u32, Vec) { match cmd { VIRTIO_GPU_CMD_GET_DISPLAY_INFO => { + // 低頻命令(probe / config 事件後);trace 供動態解析度實機診斷。 + if std::env::var_os("CHEFER_WHP_TRACE_GPU").is_some() { + eprintln!( + "[whp-gpu] guest GET_DISPLAY_INFO -> {}x{}", + self.width, self.height + ); + } (VIRTIO_GPU_RESP_OK_DISPLAY_INFO, self.display_info_payload()) } // GET_EDID:回一份描述 width×height 偏好模式的 EDID,讓 guest 的 DRM connector // 取得正確偏好解析度(否則 cage/wlroots 無 EDID 時挑 640×480)。 - VIRTIO_GPU_CMD_GET_EDID => (VIRTIO_GPU_RESP_OK_EDID, self.edid_payload()), + VIRTIO_GPU_CMD_GET_EDID => { + if std::env::var_os("CHEFER_WHP_TRACE_GPU").is_some() { + eprintln!("[whp-gpu] guest GET_EDID -> {}x{}", self.width, self.height); + } + (VIRTIO_GPU_RESP_OK_EDID, self.edid_payload()) + } VIRTIO_GPU_CMD_RESOURCE_CREATE_2D => self.cmd_create_2d(req), VIRTIO_GPU_CMD_RESOURCE_UNREF => { let res_id = u32_at(req, CTRL_HDR_LEN); @@ -661,7 +680,7 @@ mod tests { assert!(!dev.request_display_size(20000, 600)); assert_eq!(dev.config_read(0, 4), 0); - // 真的改尺寸 → events_read 含 EVENT_DISPLAY,display info / EDID 回新值。 + // 真的改尺寸 → EVENT_DISPLAY 置位、display info(保持 enabled)與 EDID 回新值。 assert!(dev.request_display_size(1024, 768)); assert_eq!(dev.config_read(0, 4), VIRTIO_GPU_EVENT_DISPLAY); let resp = run_cmd( @@ -672,6 +691,9 @@ mod tests { ); assert_eq!(u32_at(&resp, CTRL_HDR_LEN + 8), 1024); assert_eq!(u32_at(&resp, CTRL_HDR_LEN + 12), 768); + // scanout 0 全程 enabled——絕不能模擬拔除(實機驗證:connector 短暫 disconnected + // 會讓 cage 拆輸出 → 介面 app 退出 → fail_fast 收掉整個 app)。 + assert_eq!(u32_at(&resp, CTRL_HDR_LEN + 16), 1, "scanout stays enabled"); let resp = run_cmd(&mut dev, &mut mem, &hdr(VIRTIO_GPU_CMD_GET_EDID), 2048); let dt = &resp[CTRL_HDR_LEN + 8 + 54..CTRL_HDR_LEN + 8 + 72]; assert_eq!((dt[2] as u32) | (((dt[4] >> 4) as u32) << 8), 1024); diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 8e53dce..9d5fe39 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -334,7 +334,8 @@ pub fn run_app(ctx: &AppRunContext) -> anyhow::Result; // 取第一個 Avai - ✅ **鍵鼠輸入實機驗證達成(M8-e 前半)**:Win32 訊息 → `SharedInput` → VM loop drain → virtio-input eventq → guest 全鏈實機驗證。以 `CHEFER_WHP_TRACE_INPUT` 追蹤:對「Chefer」視窗送滑鼠移動與鍵盤(`SetCursorPos`/`keybd_event`),trace 顯示事件確實填入 guest eventq——tablet(base 0xD0000A00/IRQ4)收滑鼠、keyboard(0xD0000800/IRQ3)收按鍵。guest 端 virtio-input driver 已 bind(M8-d 見 queue setup),cage/libinput 消費。 - ✅ **M8-e 剪貼簿(實機達成,雙向)**:host↔guest 文字剪貼簿同步,走**既有網路通道 + cmdline token**(不做 vsock)。guest 端 `guest-agent/src/clipboard.rs`(compositor 就緒後起,在 root netns `0.0.0.0:55381` 監聽;`wl-paste` 輪詢偵測 cage 剪貼簿變更→送、收到→`wl-copy`);host 端 `whp-helper/src/clipboard_host.rs`(`#[cfg(windows)]`,連 `[::1]:55381`(helper 對此埠加了 host→guest 轉發,經 smoltcp 到 guest eth0)、送 token、輪詢 Windows clipboard(`OpenClipboard`/`GetClipboardData`/`SetClipboardData` CF_UNICODETEXT))。安全:token 由 helper 以 `BCryptGenRandom` 產生、cmdline `chefer.clip_token=` 傳 guest,連線先驗 token(防同機其他程序連 localhost 轉發埠偷讀/注入)。**回音抑制**:套用對端內容後記住,本地 watcher 看到相同就不回送。**文字與 PNG 圖片皆同步**——線協定每則加 1-byte kind(`0`=`text/plain` UTF-8、`1`=`image/png` 原始 PNG bytes;兩端隨 bundle 同版出貨,故可直接改格式免雙軌)。圖片:guest 用 `wl-copy/paste --type image/png`;host 同時讀寫 Windows 註冊的 `"PNG"` 剪貼簿格式(現代 app 如瀏覽器,PNG bytes 直通、免 codec)與 **CF_DIB**(傳統 app 如小畫家/剪取工具/Office,`whp-helper/src/dib.rs` 以 `png` codec 做 CF_DIB↔PNG 轉換,含往返/方向/header 單元測試)。**實機驗證(squashfs 快開機 GUI kit + `CHEFER_CLIP_TRACE`)**:文字雙向皆通——guest app `wl-copy GUESTCLIP_HELLO`→host(Windows 剪貼簿確實變該值);host `Set-Clipboard`→guest(trace `sent to guest (kind 0)`)。圖片的 **host 端讀取+轉換路徑以 `chefer-whp-helper --clip-selftest` 驗證**(Windows `SetImage` 放 CF_BITMAP→系統合成 CF_DIB→`get_clipboard_any` 讀→`dib.rs` 轉 PNG,回 `kind=1 (image/png)`);guest 端套用走與已驗文字完全相同的 `wl-copy` 機制。DIB↔PNG 另有往返/方向/header 單元測試。 - **首版範圍**:鍵盤+滑鼠+預設解析度(1280×800,env 可覆寫)+ 文字與 PNG 圖片剪貼簿。 - - **動態解析度(已實作,實機驗證待跑)**:host 視窗改為可調大小(THICKFRAME + 最大化);使用者拖拉結束(`WM_EXITSIZEMOVE`)或最大化/還原(`WM_SIZE`)時,以最終 client 尺寸經 `SharedResize`(latest-wins,`gui_bridge`)通知 VM loop → `GpuDevice::request_display_size` 更新 display info/EDID 並置 config `events_read` 的 `EVENT_DISPLAY` → `Mmio::signal_config`(interrupt status bit1 + ConfigGeneration bump)注入 IRQ → guest virtio-gpu driver 重讀 display info、發 DRM hotplug → cage/wlroots 以新偏好模式 re-modeset(新 SET_SCANOUT/FLUSH 進來)。**防回饋迴圈**:請求後進 pending(暫停 resize-to-content,否則舊尺寸 frame 會把視窗改回去),guest 到位或 4s 逾時解除;我們自己 resize-to-content 的 `WM_SIZE` 以 programmatic 旗標 + 「尺寸沒變不請求」雙重防護。driver 以 config space offset 4 的 events_clear 清事件(`GpuDevice::config_write`)。裝置模型/橋接/EDID 更新皆有單元測試;DPI 縮放另列後續。 + - **動態解析度(實機驗證完成,WHP)**:host 視窗改為可調大小(THICKFRAME + 最大化);使用者拖拉結束(`WM_EXITSIZEMOVE`)或最大化/還原(`WM_SIZE`)時,以最終 client 尺寸經 `SharedResize`(latest-wins,`gui_bridge`)通知 VM loop → `GpuDevice::request_display_size` 更新 display info/EDID 並置 config `events_read` 的 `EVENT_DISPLAY` → `Mmio::signal_config`(interrupt status bit1 + ConfigGeneration bump)注入 IRQ → guest virtio-gpu driver 收 config 中斷、重讀 display info 與 EDID(driver 以 config offset 4 的 events_clear 清事件,`GpuDevice::config_write`)。 + **實機驗證(本機 WHP,gui-demo/xeyes,`CHEFER_WHP_TRACE_GPU=1`)結論**:拖拉視窗到 1024×845 → trace 依序出現 `requesting guest mode 1024x845` → guest `GET_DISPLAY_INFO/GET_EDID -> 1024x845` → `events_clear 0x1`,證明**裝置→中斷→guest driver 重讀**整條路徑正確運作。但 **`cage`(kiosk 固定模式 compositor)不因線上模式清單變更而 re-modeset**——driver 有重抓、userspace 不動,scanout flush 維持原生 1280×800。故 WHP 的實務行為是 **host 視窗自由縮放、畫面以 `StretchDIBits` 拉伸顯示**(`user_sized`:使用者一改尺寸即永久停用 resize-to-content,視窗不彈回;abs 座標按視窗比例映射,輸入仍正確),guest 維持原生模式。**曾試「回報 disabled→enabled」replug 模擬拔插逼 cage 換模式:connector 短暫 disconnected 會讓 cage 拆輸出 → 介面 app 退出 → fail_fast 收掉整個 app,故不採。** 真正的 guest re-modeset 需要「會跟隨線上模式變更」的 compositor(非 cage);裝置/中斷側已就緒,換 compositor 即可啟用。裝置模型/橋接/EDID 更新/events_clear 皆有單元測試;DPI 縮放另列後續。 ### guest-agent(lib + bin;Linux 專屬邏輯 cfg 隔離) - lib:`pub struct RunConfig { pub bundle_dir: PathBuf, pub data_dir: PathBuf, pub cache_dir: Option, pub keep_rootfs: bool, pub udp_bridge: bool }`