Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>`) | ✅ | ✅ | 🔧 |
| **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 | 🔧 |
Expand Down
47 changes: 13 additions & 34 deletions crates/whp-helper/src/gui_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 端持有的視窗把手。
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -263,36 +259,19 @@ 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;
unsafe { resize_client(hwnd, w, h) };
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
}
Expand Down Expand Up @@ -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);
}

Expand Down
30 changes: 26 additions & 4 deletions crates/whp-helper/src/virtio/gpu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -196,11 +203,23 @@ impl GpuDevice {
fn execute<M: GuestMemory>(&mut self, cmd: u32, req: &[u8], mem: &mut M) -> (u32, Vec<u8>) {
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);
Expand Down Expand Up @@ -661,7 +680,7 @@ mod tests {
assert!(!dev.request_display_size(20000, 600));
assert_eq!(dev.config_read(0, 4), 0);

// 真的改尺寸 → events_read 含 EVENT_DISPLAYdisplay 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(
Expand All @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,8 @@ pub fn run_app(ctx: &AppRunContext) -> anyhow::Result<i32>; // 取第一個 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<PathBuf>, pub keep_rootfs: bool, pub udp_bridge: bool }`
Expand Down
Loading