diff --git a/AGENTS.md b/AGENTS.md index 8cb5f0f..28b1e04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,4 +5,5 @@ - **vz-smoke.sh 的 pkill 治標可升級為斷言**:helper stdin-EOF 自我了結(vz.rs + vz-helper/main.swift)合併後,`scripts/vz-smoke.sh` 收尾的 `pkill -f chefer-vz-helper`(目前在主 checkout 未 commit 的變更中)可改成「等 ~10s 斷言無 chefer-vz-helper 殘留」,讓實機一鍵驗證直接覆蓋這個修復。 - **runtime 單發 SIGINT 下 helper 收攤有 ~5s 延遲**:runtime 的 ctrlc handler 等 5 秒才 `exit(130)`,stdin EOF 要到行程死亡才發生(實測 SIGINT→helper 收攤約 6s;SIGTERM/SIGKILL 即時)。若要即時收攤,可讓 vz 後端把 helper stdin 寫端交給訊號處理路徑、收訊號時主動關閉。屬優化,非正確性問題。 - **whp 防孤兒修復待實機驗證**:Job Object(KILL_ON_JOB_CLOSE)+ helper stdin-EOF 自我了結(`crates/vmm-backend/src/whp.rs` + `crates/whp-helper/src/main.rs`)已完成、機制各有 CI 單元測試,但「單殺 runtime 後 helper/VM 不殘留」的端到端行為需實體 Windows(WHP)驗證:以 `CHEFER_BACKEND=whp` 跑一個 bundle → `taskkill /F /PID `(TerminateProcess,孤兒化的可靠重現法;原始問題本身也尚未在實機重現過)→ 確認 `chefer-whp-helper` 行程數秒內消失、無殘留。通過後刪除本項。 +- **vz bridge 出網(pasta→apk)於 2026-07-12 實測不通,疑點待查**:HiDPI e2e 時 guest 內 `apk add`(`network: bridge`)連續 10+ 次「temporary error」(`dl-cdn.alpinelinux.org` main/community 皆然),同時 host 直連同 URL HTTP 200;pasta 有起、netns 正常、resolv.conf 注入路徑(exec.rs)在。可能是本次網路環境(Wi-Fi/DNS)因素,也可能是 container-DNS 改動(PR #127,僅 WHP 實機驗證)合併後 vz 側回歸——f9f705b 驗證 vz bridge 出網是在該合併**之前**。建議:在正常網路下重跑 `vz-smoke.sh --gui`(xclock 的 apk 步驟即覆蓋),通則屬環境因素刪本項;仍失敗則進 guest 看 `/etc/resolv.conf` 與 pasta DNS 轉發。 - **branch protection 的必要檢查名稱失效,每個 PR 都得 admin bypass**:main 的 required status checks 含一條 `whp-helper (windows compile-check)`(無後綴),但該 CI job 從建立起就是 matrix(`.github/workflows/ci.yml`),實際回報名稱是 `whp-helper (windows compile-check) (x86_64-pc-windows-msvc)` 與 `(aarch64-pc-windows-msvc)`——這條 context 永遠不會被滿足,所有 PR 的 merge state 都是 BLOCKED,只能靠 owner `--admin`/網頁勾「bypass」合併(2026-07-11 PR #126 實測確認)。修法(需 repo 管理權限,Settings → Branches → main 的 required status checks,或 `gh api` PATCH `branches/main/protection/required_status_checks`):把無後綴那條換成上述兩條帶 target 後綴的。修好後刪除本項。 diff --git a/README.md b/README.md index 6fff2de..f28b6d5 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:`) | ✅ | ✅ | ✅ verified | | **Host port mapping — TCP** (`"host:guest"`, host≠guest proxied) | ✅ | ✅ verified | ✅ verified — the vz backend relays `127.0.0.1:host → :guest` itself (the cross-platform runtime proxy is skipped on macOS) | | **Host port mapping — UDP** | ✅ | ✅ verified — Chefer relays via the VM IP + an in-VM eth0→loopback bridge (WSL2's own forwarding is TCP-only) | ✅ verified (same VM-IP relay + in-VM bridge) | -| **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 now triggers a **true guest re-modeset**: the in-guest resize watcher follows the virtio-gpu display change (drm hotplug → re-probe → wlr-output-management; needs the cage ≥ 0.2.0 GUI overlay) — the chain is verified end-to-end on the vz backend, WHP re-validation pending; older overlays fall back to scaling the picture) | ✅ validated on real Apple Silicon — `VZVirtualMachineView` window + the same in-guest `cage` path as WHP (compositor up 8/8 boots); clipboard verified (text both ways; PNG guest→host byte-exact, host→guest wire-confirmed). Resolution: the **initial** guest mode matches the window's *pixel* size (Retina 2× backing; `CHEFER_VZ_GUI_SIZE` honored — verified 1280×800→2560×1600 and 1024×600→2048×1200), and live resize **re-modesets the guest** with dynamic resolution opted in (`CHEFER_VZ_DYNAMIC_RESOLUTION=1`, macOS 14+, cage ≥ 0.2.0 overlay — verified on real hardware: `xdpyinfo` followed 2560×1600 → 2200×1400); the default stays view-scaling (Retina-pixel modes halve Linux UI sizes; no HiDPI output scale yet). Interactive HID / close-window remain hand-checks (`scripts/vz-smoke.sh --gui`). Caveat: the clipboard channel is sometimes silently delayed — suspected macOS Local Network privacy gating of the ad-hoc-signed helper | +| **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 now triggers a **true guest re-modeset**: the in-guest resize watcher follows the virtio-gpu display change (drm hotplug → re-probe → wlr-output-management; needs the cage ≥ 0.2.0 GUI overlay) — the chain is verified end-to-end on the vz backend, WHP re-validation pending; older overlays fall back to scaling the picture) | ✅ validated on real Apple Silicon — `VZVirtualMachineView` window + the same in-guest `cage` path as WHP (compositor up 8/8 boots); clipboard verified (text both ways; PNG guest→host byte-exact, host→guest wire-confirmed). Resolution: the **initial** guest mode matches the window's *pixel* size (Retina 2× backing; `CHEFER_VZ_GUI_SIZE` honored — verified 1280×800→2560×1600 and 1024×600→2048×1200), and live resize **re-modesets the guest** with dynamic resolution opted in (`CHEFER_VZ_DYNAMIC_RESOLUTION=1`, macOS 14+, cage ≥ 0.2.0 overlay — verified on real hardware: `xdpyinfo` followed 2560×1600 → 2200×1400); true dynamic resolution is now the **default** on macOS 14+ — the guest re-modesets on window resize and applies the host's HiDPI output scale (pushed via kernel cmdline `chefer.gui_scale`) so cursor/UI sizes stay correct on Retina; verified on real hardware including hand-checks (cursor size, pointer mapping, upscale quality; `xdpyinfo` reports logical points 1280×800 → 1100×700 across a live resize). `CHEFER_VZ_DYNAMIC_RESOLUTION=0` (or macOS 13) falls back to view-scaling. Interactive HID / close-window remain hand-checks (`scripts/vz-smoke.sh --gui`). Caveat: the clipboard channel is sometimes silently delayed — suspected macOS Local Network privacy gating of the ad-hoc-signed helper | | **`crash: fail_fast`** (any non-zero exit tears down the app, code propagated) | ✅ | ✅ verified | ✅ verified (exit 7 propagated; interface-service exit-0 teardown too) | | **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 | ✅ (same real-root guest path as WHP) | @@ -264,5 +264,5 @@ Honest list of what doesn't work (yet): - [x] **`depends_on` health checks** (wait-until-ready), not just start order — a service with `healthcheck` is polled inside its container namespaces until healthy before later services start; services without one are considered ready once spawned. - [x] **Rootless Linux support for chown/gosu images** via `newuidmap` + `/etc/subuid` delegation — when the host has the `uidmap` tools and subuid/subgid ranges for the user (most distros ship both), rootless services get a full uid-range mapping (rootless-Podman-style) and images that `chown`/`gosu` to a dedicated uid (official redis/postgres) run as-is; verified end-to-end with the official `redis` image as a non-root user. Falls back to the old single-uid map when delegation is unavailable. (The root backends — WSL2 / macOS VM / native-root — never needed this.) - [x] **macOS VZ boot validated on real Apple Silicon** *(2026-07, Apple Silicon / macOS 26)* — `scripts/vz-smoke.sh` fully green on bare metal: VM boot + virtiofs + guest-agent, exit-code propagation, service supervision, host→guest **TCP and UDP** port relays, multi-service `depends_on` + in-app `127.0.0.1` networking, `persist_path` write-back across restarts, and default-`bridge` outbound (pasta → VZ NAT, DNS included). Real-hardware fixes that came out of it: tar-mode restore on extraction (bundled agents lost their exec bit), an arm64-appliance initramfs that shipped an x86-64 busybox (cross-arch `busybox-static` now fetched at build), the runtime port proxy double-binding against the vz relay, and Docker-parity `/etc/resolv.conf` injection into service containers (without it, bridge outbound had connectivity but no DNS). Note: VZ is virtualization, not emulation — guest arch must match host (Apple Silicon → `linux/arm64`, Intel → `linux/amd64`). The backend still requires `CHEFER_VZ_EXPERIMENTAL=1` — flipping it default-on is a separate decision. -- [x] **GUI apps on macOS** — *validated on real Apple Silicon (2026-07).* The in-guest half (kiosk `cage` + Xwayland on a virtio-gpu scanout) is shared with WHP; `chefer-vz-helper --gui` shows the VM in an AppKit window via `VZVirtualMachineView` (1280×800 default, `CHEFER_VZ_GUI_SIZE=WxH` override) with USB keyboard/absolute-pointer devices. Real-hardware results: the compositor came up **8/8 GUI boots** (the `WLR_LIBINPUT_NO_DEVICES` boot-race fix holds on VZ); X11 clients connect and render; the host↔guest **clipboard** works (text verified both ways; PNG guest→host byte-exact on `NSPasteboard`, host→guest wire-confirmed). Resolution: `automaticallyReconfiguresDisplay` (macOS 14+) does push the window's *pixel* size to the guest — the **initial** mode matches the window's Retina backing exactly (a 1280×800-point window yields a 2560×1600 guest mode; `CHEFER_VZ_GUI_SIZE=1024x600` yields 2048×1200) — and live resize now **re-modesets the guest**: `cage` doesn't follow mode changes by itself, so the guest agent's resize watcher listens for the drm hotplug, re-probes the new preferred mode, and applies it over wlr-output-management (needs the cage ≥ 0.2.0 overlay from the current `build-gui-overlay.sh`; verified end-to-end on real hardware with `CHEFER_VZ_DYNAMIC_RESOLUTION=1`: `xdpyinfo` followed 2560×1600 → 2200×1400). The **default** stays view-scaling — Retina-pixel modes halve Linux UI sizes until the compositor gains HiDPI output scale; the opt-in flips it to true dynamic resolution. Interactive checks that still need a human hand (`scripts/vz-smoke.sh --gui` checklist): typing/clicking into the guest, close-window-ends-app. Known caveat: the clipboard channel (helper → `:55381` over VZ NAT) sometimes sits in a silent `.waiting` state for up to a minute or never connects — suspected macOS Local Network privacy gating of the ad-hoc-signed helper; needs a stable signing identity and a visible hint in the helper. Design in [docs/DESIGN.md](docs/DESIGN.md). +- [x] **GUI apps on macOS** — *validated on real Apple Silicon (2026-07).* The in-guest half (kiosk `cage` + Xwayland on a virtio-gpu scanout) is shared with WHP; `chefer-vz-helper --gui` shows the VM in an AppKit window via `VZVirtualMachineView` (1280×800 default, `CHEFER_VZ_GUI_SIZE=WxH` override) with USB keyboard/absolute-pointer devices. Real-hardware results: the compositor came up **8/8 GUI boots** (the `WLR_LIBINPUT_NO_DEVICES` boot-race fix holds on VZ); X11 clients connect and render; the host↔guest **clipboard** works (text verified both ways; PNG guest→host byte-exact on `NSPasteboard`, host→guest wire-confirmed). Resolution: `automaticallyReconfiguresDisplay` (macOS 14+) does push the window's *pixel* size to the guest — the **initial** mode matches the window's Retina backing exactly (a 1280×800-point window yields a 2560×1600 guest mode; `CHEFER_VZ_GUI_SIZE=1024x600` yields 2048×1200) — and live resize now **re-modesets the guest**: `cage` doesn't follow mode changes by itself, so the guest agent's resize watcher listens for the drm hotplug, re-probes the new preferred mode, and applies it over wlr-output-management (needs the cage ≥ 0.2.0 overlay from the current `build-gui-overlay.sh`; verified end-to-end on real hardware with `CHEFER_VZ_DYNAMIC_RESOLUTION=1`: `xdpyinfo` followed 2560×1600 → 2200×1400). True dynamic resolution is now the **default** on macOS 14+: the guest re-modesets on window resize and applies the host's HiDPI output scale (the helper pushes the screen's `backingScaleFactor` via kernel cmdline `chefer.gui_scale`; the resize watcher sets it at startup and on every re-modeset), so Retina-pixel modes no longer halve Linux UI sizes — verified end-to-end on real hardware (boot `output scale -> 2`; `xdpyinfo` logical 1280×800 → 1100×700 across a live resize, never the physical pixel sizes; hand-checks passed: cursor/UI size, pointer mapping, upscale quality). `CHEFER_VZ_DYNAMIC_RESOLUTION=0` (or macOS 13, or an old cage <0.2.0 overlay where the guest can't re-modeset) falls back to view-scaling with the aspect lock. Interactive checks that still need a human hand (`scripts/vz-smoke.sh --gui` checklist): typing/clicking into the guest, close-window-ends-app. Known caveat: the clipboard channel (helper → `:55381` over VZ NAT) sometimes sits in a silent `.waiting` state for up to a minute or never connects — suspected macOS Local Network privacy gating of the ad-hoc-signed helper; needs a stable signing identity and a visible hint in the helper. Design in [docs/DESIGN.md](docs/DESIGN.md). - [x] **GPU access / CUDA compute** — *opt-in `gpu: true`; validated end-to-end on both WSL2 and native NVIDIA Linux.* Set `gpu: true` on a service and Chefer binds the host's render/compute device nodes into that service's container — on **native Linux** `/dev/nvidia*` + `/dev/dri`; on the **Windows WSL2 backend** `/dev/dxg` + a bind of the host `/usr/lib/wsl/lib` **and `/usr/lib/wsl/drivers`** (its host-version-matched `libdxcore`/`libcuda`/… ) → CUDA/DirectML/OpenCL/Vulkan work with nothing extra in the image. The same passthrough also covers **NVENC/NVDEC video** and OpenCL/ROCm compute; **GL/Vulkan *rendering* does not work in a headless container** (see the native note below). **Validated end-to-end on real WSL2** (GeForce GT 1030) — the container sees `/dev/dxg` + `/usr/lib/wsl/lib` + `/usr/lib/wsl/drivers`, runs `nvidia-smi` with live telemetry, and executes a `nvcc`-built CUDA `vectorAdd` on the GPU (`sm_61`, 1048576 elems, 0 mismatches); binding `/usr/lib/wsl/lib` alone is not enough — its `libcuda`/`libnvidia-ml` are loader shims that dlopen the real driver from `/usr/lib/wsl/drivers`, so both must be bound (else `nvidia-smi` reports "Driver Not Loaded"). `/dev/dri` is bound when present (absent on some WSL kernels; CUDA uses `/dev/dxg`). It is a **clear startup error** on the non-WSL Windows (WHP) micro-VM and the macOS VM — a bare WHP/VZ VM can't tap Microsoft/Apple GPU paravirtualization, so GPU compute stays **native-Linux + WSL2 only**. **Native Linux** — host-driver-version-matched userspace lib injection (à la the NVIDIA Container Toolkit) reads the driver version from `/proc/driver/nvidia/version` and binds the matching NVIDIA driver libs (`libcuda`/`libnvidia-*`/`libnvcuvid`/`libnvoptix`/GL, matching `nvidia-container-cli list`) into `/run/chefer-nvidia` (versioned + real DT_SONAME + `.so`) plus `LD_LIBRARY_PATH`. **Validated on a GeForce RTX 4070** (driver 570.211.01): a `nvcc`-built CUDA vectorAdd from a stock `nvidia/cuda` image that ships no libcuda ran on the GPU (`sm_89`, 1048576 elems, 0 mismatches), and `ffmpeg -c:v h264_nvenc` encoded in-container (NVENC, exit 0). **OpenGL/Vulkan *rendering* does not work in a headless container**, though: NVIDIA's Vulkan ICD (`libGLX_nvidia` → `libnvidia-glcore`) needs a real graphics/X environment (it references Xorg's `ErrorF` and glibc-2.34-removed `__malloc_hook`), so it falls back to `llvmpipe` — and `docker --gpus all` with `NVIDIA_DRIVER_CAPABILITIES=all` (the nvidia-container-toolkit reference) fails identically on the same headless host, so this is a host/driver limitation, not a Chefer one (Chefer's GUI path renders via X11/Wayland socket passthrough + software rasterization instead). No path to Apple NPU / CoreML from a Linux guest. diff --git a/crates/guest-agent/src/resize.rs b/crates/guest-agent/src/resize.rs index 3d9c732..f781b22 100644 --- a/crates/guest-agent/src/resize.rs +++ b/crates/guest-agent/src/resize.rs @@ -19,6 +19,14 @@ //! 模式清單**(wlroots 不會為已連線 connector 更新 wlr_output 的 mode list)。 //! cage commit 新模式 → wlr_output_layout 變更 → view 重排滿版,Xwayland root 同步。 //! +//! **HiDPI(`chefer.gui_scale=`)**:vz 動態解析度模式推進 guest 的是 Retina **實體像素** +//! 尺寸,Linux guest 無 HiDPI 概念 → UI/游標縮半。host(vz-helper)把開機當下螢幕的 +//! backingScaleFactor 附進 kernel cmdline,本 watcher 據此對輸出設 wlr-output-management +//! 的 **output scale**:啟動時先補設一次(開機初始模式就已是像素尺寸、之後未必再有 drm +//! 事件),此後每次換模式一併重設。邏輯尺寸回到「點」數;X11 surface 由 compositor 放大 +//! (預期視覺等同預設 view-scaling,但比例自由、無 letterbox、游標尺寸正常——待實機驗證)。 +//! 未附此參數(WHP、vz 預設模式)→ 完全不帶 `--scale`,行為與既往相同。 +//! //! **為何不是 replug**:曾試「connector 回報 disabled→enabled」模擬拔插,cage 會拆輸出 //! → 介面 app 退出 → fail_fast 收掉整個 app(見 roadmap M8-d),故必須走「connector //! 保持連線、只換模式」的本路線。等 wlroots 0.20 + 對應 cage 進 Alpine 後,cage 會自己 @@ -73,13 +81,21 @@ pub fn maybe_start() -> Option { return None; } }; + let scale = read_cmdline_gui_scale(); let stop = Arc::new(AtomicBool::new(false)); let stop_c = stop.clone(); let handle = std::thread::Builder::new() .name("chefer-resize".into()) - .spawn(move || run(sock, stop_c)) + .spawn(move || run(sock, stop_c, scale)) .ok()?; - crate::gui::note("resize: following host window size (drm uevent -> wlr-output-management)"); + match scale { + Some(s) => crate::gui::note(&format!( + "resize: following host window size (drm uevent -> wlr-output-management); HiDPI output scale {s}" + )), + None => crate::gui::note( + "resize: following host window size (drm uevent -> wlr-output-management)", + ), + } Some(ResizeWatcher { stop, handle: Some(handle), @@ -87,7 +103,15 @@ pub fn maybe_start() -> Option { } /// 主迴圈:等 drm change → debounce → probe 新 preferred mode → wlr-randr 套用。 -fn run(sock: OwnedFd, stop: Arc) { +fn run(sock: OwnedFd, stop: Arc, scale: Option) { + // HiDPI:開機初始模式就已是 Retina 像素尺寸(vz 於開機即推入、cage 啟動即選中), + // 之後未必再有 drm 事件——啟動時就對現行輸出補設 output scale 一次。 + if let Some(s) = scale { + match apply_scale(s) { + Ok(output) => crate::gui::note(&format!("resize: {output} output scale -> {s}")), + Err(e) => crate::gui::note(&format!("resize: failed to set output scale {s}: {e}")), + } + } // 最近一次成功套用的尺寸:同尺寸不重設(避免無謂 re-modeset 閃爍)。 let mut last: Option<(u16, u16)> = None; let mut failures = 0u32; @@ -115,11 +139,16 @@ fn run(sock: OwnedFd, stop: Arc) { if last == Some((w, h)) { continue; } - match apply_mode(w, h) { + match apply_mode(w, h, scale) { Ok(output) => { last = Some((w, h)); failures = 0; - crate::gui::note(&format!("resize: {output} -> {w}x{h}")); + match scale { + Some(s) => { + crate::gui::note(&format!("resize: {output} -> {w}x{h} (scale {s})")); + } + None => crate::gui::note(&format!("resize: {output} -> {w}x{h}")), + } } Err(e) => { failures += 1; @@ -136,6 +165,25 @@ fn run(sock: OwnedFd, stop: Arc) { } } +// ---- HiDPI output scale(kernel cmdline)---- + +/// 從 `/proc/cmdline` 取 host 附上的 `chefer.gui_scale=`(vz 動態解析度模式限定, +/// 見 vz-helper/main.swift;同 clipboard.rs 讀 clip_token 的模式)。無或無效 → None。 +fn read_cmdline_gui_scale() -> Option { + parse_gui_scale(&std::fs::read_to_string("/proc/cmdline").ok()?) +} + +/// 解析 `chefer.gui_scale=` 值。僅接受 (1.0, 4.0] 的有限值:1 = 不需縮放(省略 +/// `--scale`,與未附參數完全同路徑);範圍外視為 host 端錯誤——寧可不縮放,也不把 +/// 0/負值/NaN 這類會直接毀掉版面的值餵給 compositor。 +fn parse_gui_scale(cmdline: &str) -> Option { + let v = cmdline + .split_whitespace() + .find_map(|tok| tok.strip_prefix("chefer.gui_scale="))?; + let s: f64 = v.parse().ok()?; + (s.is_finite() && s > 1.0 && s <= 4.0).then_some(s) +} + // ---- kernel uevent(netlink)---- /// 開 `NETLINK_KOBJECT_UEVENT` socket 聽 kernel 原生 uevent 廣播(group 1)。 @@ -417,24 +465,55 @@ fn pick_mode(modes: &[DrmModeModeinfo]) -> Option<(u16, u16)> { // ---- 套用(wlr-randr → wlr-output-management → cage)---- -/// 以 custom mode 要求 cage 換到 `w`×`h`(refresh 交給 compositor 預設)。 -/// 回傳套用到的 output 名。 -fn apply_mode(w: u16, h: u16) -> Result { - // output 名以 compositor 回報為準(kiosk 單輸出;virtio-gpu 下慣例是 Virtual-1, - // 但不硬編——從 wlr-randr 列表拿第一個)。 +/// 從 compositor 取輸出名。output 名以 compositor 回報為準(kiosk 單輸出; +/// virtio-gpu 下慣例是 Virtual-1,但不硬編——從 wlr-randr 列表拿第一個)。 +fn current_output_name() -> Result { let (stdout, stderr, ok) = run_wlr_randr(&[])?; if !ok { return Err(format!("wlr-randr list failed: {}", stderr.trim())); } - let name = first_output_name(&stdout).ok_or("wlr-randr reported no outputs")?; - let (_, stderr, ok) = - run_wlr_randr(&["--output", &name, "--custom-mode", &format!("{w}x{h}")])?; + first_output_name(&stdout).ok_or_else(|| "wlr-randr reported no outputs".to_string()) +} + +/// 以 custom mode 要求 cage 換到 `w`×`h`(refresh 交給 compositor 預設);有 HiDPI +/// scale 時同一次 apply 一併重設。回傳套用到的 output 名。 +fn apply_mode(w: u16, h: u16, scale: Option) -> Result { + let name = current_output_name()?; + let args = mode_args(&name, w, h, scale); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let (_, stderr, ok) = run_wlr_randr(&args)?; if !ok { return Err(format!("wlr-randr --custom-mode failed: {}", stderr.trim())); } Ok(name) } +/// 只設 output scale、模式不動(watcher 啟動時對開機初始模式補設用)。回傳 output 名。 +fn apply_scale(scale: f64) -> Result { + let name = current_output_name()?; + let (_, stderr, ok) = run_wlr_randr(&["--output", &name, "--scale", &format!("{scale}")])?; + if !ok { + return Err(format!("wlr-randr --scale failed: {}", stderr.trim())); + } + Ok(name) +} + +/// 組「換模式(有 scale 一併設)」的 wlr-randr 引數。`scale=None` → 不帶 `--scale`, +/// 與既往完全相同(未附 `chefer.gui_scale` 的路徑:WHP、vz 預設模式)。 +fn mode_args(output: &str, w: u16, h: u16, scale: Option) -> Vec { + let mut args = vec![ + "--output".to_string(), + output.to_string(), + "--custom-mode".to_string(), + format!("{w}x{h}"), + ]; + if let Some(s) = scale { + args.push("--scale".to_string()); + args.push(format!("{s}")); + } + args +} + /// 跑 wlr-randr 並回收 (stdout, stderr, 成功與否)。 /// /// **不能用 `Command::output()`**:supervisor 以 `waitpid(ANY)` 監督服務子行程,會把 @@ -540,6 +619,54 @@ mod tests { assert_eq!(pick_mode(&[m(0, 0, 0)]), None); } + #[test] + fn gui_scale_parse() { + // 標準 Retina(2×)與非整數 scale。 + assert_eq!(parse_gui_scale("quiet chefer.gui_scale=2 ro"), Some(2.0)); + assert_eq!(parse_gui_scale("chefer.gui_scale=1.5"), Some(1.5)); + // 1 = 不需縮放 → None(走與未附參數完全相同的路徑)。 + assert_eq!(parse_gui_scale("chefer.gui_scale=1"), None); + assert_eq!(parse_gui_scale("chefer.gui_scale=1.0"), None); + // 無參數 / 空值 / 垃圾 → None。 + assert_eq!(parse_gui_scale("quiet ro"), None); + assert_eq!(parse_gui_scale("chefer.gui_scale="), None); + assert_eq!(parse_gui_scale("chefer.gui_scale=abc"), None); + // 會毀版面的值一律不採:0、負、NaN、inf、超過 4。 + assert_eq!(parse_gui_scale("chefer.gui_scale=0"), None); + assert_eq!(parse_gui_scale("chefer.gui_scale=-2"), None); + assert_eq!(parse_gui_scale("chefer.gui_scale=nan"), None); + assert_eq!(parse_gui_scale("chefer.gui_scale=inf"), None); + assert_eq!(parse_gui_scale("chefer.gui_scale=8"), None); + // 與其他 chefer.* cmdline 參數共存。 + assert_eq!( + parse_gui_scale("chefer.clip_token=abc chefer.gui_scale=2 chefer.clip_port=40000"), + Some(2.0) + ); + } + + #[test] + fn mode_args_scale_flag() { + // 無 scale:與既往完全相同的四個引數(WHP / vz 預設模式不受影響)。 + assert_eq!( + mode_args("Virtual-1", 2200, 1400, None), + ["--output", "Virtual-1", "--custom-mode", "2200x1400"] + ); + // 有 scale:同一次 apply 一併帶 --scale(模式與 scale 同 commit)。 + assert_eq!( + mode_args("Virtual-1", 2200, 1400, Some(2.0)), + [ + "--output", + "Virtual-1", + "--custom-mode", + "2200x1400", + "--scale", + "2" + ] + ); + // 非整數 scale 維持小數表示(wlr-randr 以 C locale 解析,小數點必為 '.')。 + assert_eq!(mode_args("X", 100, 50, Some(1.5))[5], "1.5"); + } + #[test] fn output_name_from_wlr_randr_listing() { let listing = "Virtual-1 \"Red Hat, Inc. (Virtual-1)\"\n Physical size: 0x0 mm\n Enabled: yes\n Modes:\n 1280x800 px, 60.000000 Hz (preferred, current)\n"; diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 4314363..3f68950 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -319,10 +319,10 @@ pub fn run_app(ctx: &AppRunContext) -> anyhow::Result; // 取第一個 Avai - **Host(vz 後端,`#[cfg(target_os = "macos")]`)——已實作於 `vz-helper/main.swift` 的 `--gui` 模式**:VM 組態加 `VZVirtioGraphicsDeviceConfiguration`(一個 scanout,預設 1280×800、`CHEFER_VZ_GUI_SIZE=WxH` 覆寫)+ `VZUSBKeyboardConfiguration` + `VZUSBScreenCoordinatePointingDeviceConfiguration`(絕對座標,同 WHP 的 tablet 選擇);開一個 AppKit 視窗(標題 = app 名,`--gui-title`)承載 `VZVirtualMachineView` 綁該 VM(VM 綁主 queue、`NSApplication` 事件迴圈)→ 顯示 guest framebuffer,VZ 自動把鍵鼠 HID 轉進 guest。**關窗 = app 結束**(與 WHP 同語意;先試 `vm.stop`、2 秒保底退出,runtime 對「helper 乾淨退出且無 exit 標記」回 0)。vz.rs 依 manifest 有 gui/both 服務傳 `--gui`/`--gui-title`(`vz_util::helper_args`,含單元測試)。GUI 模式下 console 經 helper 內部 pipe tee 直通 stdout(runtime 標記解析不變)並掃 `CHEFER_GUEST_IP` 以啟動剪貼簿;非 gui 維持 headless 原路徑(console 直寫 stdout)。 - **生命週期 / 輸入**:`cage` 內 app 結束 → 沿用 guest-agent 既有「介面服務結束=收掉 app」→ VM 關、視窗關。鍵鼠走 VZ→USB HID→evdev→cage,免額外處理。 - **剪貼簿(host↔guest,vz 與 whp 共用設計)——vz host 端已實作(`ClipboardHost`,`vz-helper/main.swift`)**:**不走 vsock**(whp 得再寫一個 virtio-vsock 裝置模型,成本高)——改走**既有網路通道**:guest-agent 起剪貼簿同步服務(guest 側以 wl-clipboard 對接 cage 的 Wayland 剪貼簿;與 WHP 完全共用 `clipboard.rs`),host 側經該後端既有的 host→guest 通道連入(vz:**直連 guest IP:55381**(NAT 可達,IP 自 console `CHEFER_GUEST_IP` 標記);whp:helper 的 `[::1]` 轉發),雙向同步。安全:以 **kernel cmdline 帶入的隨機 token** 握手(vz helper 以 `SecRandomCopyBytes` 產生、附 `chefer.clip_token`/`chefer.clip_port`),未帶 token 的連線一律拒絕。線協定與 WHP 同版:token 行 + ``(kind 0=text、1=PNG,上限 32MiB);macOS 端 NSPasteboard 讀寫 `.png`(現代 app)與 `.tiff`(傳統 app,`NSBitmapImageRep` 轉換——對應 whp 的 "PNG"/CF_DIB 雙格式),回音以 changeCount + (kind,payload) 記憶抑制。**`.waiting` 可見性(實機發現後補)**:NWConnection 連 guest:55381 偶發卡在 `.waiting` 數十秒或永不成功(同一時間 shell 的 `nc` 可秒連),疑為 macOS「區域網路」隱私權對 ad-hoc 簽章 helper 的閘門;此狀態原本落在 stateUpdateHandler 的 default 分支完全靜默。現在 helper 對 `.waiting` 印一次性 stderr 提示(引導到 系統設定 → 隱私權與安全性 → 區域網路 放行)並在 3 秒未 ready 時棄連重連(新連線常可立即成功);`build-vz-helper.sh` 另支援 `CHEFER_CODESIGN_IDENTITY` 以穩定簽章身分讓 TCC 記住授權(預設仍 ad-hoc)。 - - **分期**:① vz 後端先點亮(非 GUI)✅ → ② appliance 加 virtio-gpu + cage + Xwayland,先在 **QEMU** 驗 GUI app 能算繪 ✅ → ③ 真 Mac 接 `VZVirtualMachineView` + HID —— ✅ **實體 Apple Silicon 驗證通過(2026-07)**:視窗顯示、X11 client 連線算繪、compositor 8/8 次開機全部就緒(`WLR_LIBINPUT_NO_DEVICES` boot-race 修復在 vz 上成立);鍵鼠實際輸入(VZ 原生 HID 注入)與關窗語意仍屬人工檢核項 → ④ 剪貼簿/縮放 —— ✅ **剪貼簿實機驗證**:文字雙向、PNG guest→host(NSPasteboard 位元組相符)、PNG host→guest(線上送達)皆通過;**縮放(動態解析度)實測結論(兩段式)**:`automaticallyReconfiguresDisplay` 確實把視窗**像素**尺寸推進 guest——**開機時** guest 模式=視窗 Retina backing(1280×800 點視窗 → guest 2560×1600;`CHEFER_VZ_GUI_SIZE=1024x600` → 2048×1200);但**執行中拖拉視窗不會 re-modeset、只有畫面縮放**(實機驗證:拖拉全程 guest 維持 2560×1600)——與 WHP 相同的 guest 端限制:`cage` kiosk compositor 啟動後不跟隨模式變更——**guest 側跟隨已補上(vz/WHP 共用)**:guest-agent 的 **resize watcher(`resize.rs`)**監聽 drm uevent → `GETCONNECTOR` re-probe 取新 preferred mode → `wlr-randr --custom-mode` 經 **wlr-output-management** 要求 cage re-modeset(overlay 需 **cage ≥0.2.0**=Alpine 3.22,`build-gui-overlay.sh` 已升版並附 wlr-randr;舊 overlay 下 watcher 自動停用)。實機 e2e(Apple Silicon):`CHEFER_VZ_DYNAMIC_RESOLUTION=1` 下視窗縮放 → console `gui: resize: Virtual-1 -> 2200x1400` → guest 內 xdpyinfo dimensions 2560×1600 → 2200×1400 跟隨。**預設取捨(實測後定案)**:guest 不 re-modeset 的情況下開 `automaticallyReconfiguresDisplay` 反而讓 view 停止縮放(等一個不會來的 re-modeset,拖拉時畫面尺寸不同步),且它推給 guest 的是 Retina **實體像素**尺寸、Linux guest 無 HiDPI 概念 → UI/游標縮半——故預設 **scanout=視窗「點」數、關 `automaticallyReconfiguresDisplay`、由 view 等比縮放**(Retina 上 2× 放大略軟但尺寸正確、拖拉即時跟手;偏離長寬比會 letterbox),`CHEFER_VZ_DYNAMIC_RESOLUTION=1`(macOS 14+)可切回真動態解析度——**guest 端 re-modeset 已由 resize watcher 補上(見上),此 opt-in 現為全鏈可用**;翻預設仍 gated on **HiDPI output scale**(Retina 像素模式下 Linux guest 的 UI/游標縮半問題未解,需 compositor 端 output scale 支援)。helper 另提供 `CHEFER_VZ_GUI_TEST_RESIZE=<秒>:x` 測試鉤子(程式化 `setContentSize`,與拖拉同路徑),供 vz-smoke 自動驗證本鏈。macOS 13 無 `automaticallyReconfiguresDisplay` → 維持固定尺寸。實際打字點擊/關窗語意留人工檢核(`scripts/vz-smoke.sh --gui` 清單)。**已知問題(實機發現,均已補修)**:(a) helper 對 guest:55381 的剪貼簿連線偶發卡在 NWConnection 靜默 `.waiting`——已補一次性提示 + 3 秒棄連重連 + `CHEFER_CODESIGN_IDENTITY` 穩定簽章(見上「剪貼簿」段的 `.waiting` 可見性);(b) 對 runtime 行程單發 SIGINT/SIGTERM(非終端機 Ctrl+C 的整個 process group)時 helper/VM 會殘留(`vz-smoke.sh` 的 `kill -INT` 即觸發)——已補 stdin liveness 契約(helper 讀 stdin EOF 自我了結,見上「Helper 生命週期」;WHP 側另有 Job Object 雙保險,見 §6 whp 節)。 + - **分期**:① vz 後端先點亮(非 GUI)✅ → ② appliance 加 virtio-gpu + cage + Xwayland,先在 **QEMU** 驗 GUI app 能算繪 ✅ → ③ 真 Mac 接 `VZVirtualMachineView` + HID —— ✅ **實體 Apple Silicon 驗證通過(2026-07)**:視窗顯示、X11 client 連線算繪、compositor 8/8 次開機全部就緒(`WLR_LIBINPUT_NO_DEVICES` boot-race 修復在 vz 上成立);鍵鼠實際輸入(VZ 原生 HID 注入)與關窗語意仍屬人工檢核項 → ④ 剪貼簿/縮放 —— ✅ **剪貼簿實機驗證**:文字雙向、PNG guest→host(NSPasteboard 位元組相符)、PNG host→guest(線上送達)皆通過;**縮放(動態解析度)實測結論(兩段式)**:`automaticallyReconfiguresDisplay` 確實把視窗**像素**尺寸推進 guest——**開機時** guest 模式=視窗 Retina backing(1280×800 點視窗 → guest 2560×1600;`CHEFER_VZ_GUI_SIZE=1024x600` → 2048×1200);但**執行中拖拉視窗不會 re-modeset、只有畫面縮放**(實機驗證:拖拉全程 guest 維持 2560×1600)——與 WHP 相同的 guest 端限制:`cage` kiosk compositor 啟動後不跟隨模式變更——**guest 側跟隨已補上(vz/WHP 共用)**:guest-agent 的 **resize watcher(`resize.rs`)**監聽 drm uevent → `GETCONNECTOR` re-probe 取新 preferred mode → `wlr-randr --custom-mode` 經 **wlr-output-management** 要求 cage re-modeset(overlay 需 **cage ≥0.2.0**=Alpine 3.22,`build-gui-overlay.sh` 已升版並附 wlr-randr;舊 overlay 下 watcher 自動停用)。實機 e2e(Apple Silicon):`CHEFER_VZ_DYNAMIC_RESOLUTION=1` 下視窗縮放 → console `gui: resize: Virtual-1 -> 2200x1400` → guest 內 xdpyinfo dimensions 2560×1600 → 2200×1400 跟隨。**預設取捨(實測後定案)**:guest 不 re-modeset 的情況下開 `automaticallyReconfiguresDisplay` 反而讓 view 停止縮放(等一個不會來的 re-modeset,拖拉時畫面尺寸不同步),且它推給 guest 的是 Retina **實體像素**尺寸、Linux guest 無 HiDPI 概念 → UI/游標縮半——故**當時**預設 scanout=視窗「點」數、關 `automaticallyReconfiguresDisplay`、由 view 等比縮放+鎖長寬比(Retina 上 2× 放大略軟但尺寸正確;此模式現保留為 `CHEFER_VZ_DYNAMIC_RESOLUTION=0`/macOS 13 的保底路徑——搭到舊 overlay(cage <0.2.0、guest 不 re-modeset)時仍可用);guest 端 re-modeset 由 resize watcher 補上(見上)後全鏈可用;翻預設原 gated on 的 **HiDPI output scale 已補上**:helper 於動態解析度模式把開機當下螢幕的 backingScaleFactor 以 **`chefer.gui_scale=`** 附進 kernel cmdline(同 clip_token 作法;視窗跨螢幕拖移的 scale 變化不追、恆用開機值),guest 的 resize watcher 啟動時即對現行輸出補設 output scale(開機初始模式已是 Retina 像素尺寸、之後未必再有 drm 事件),其後每次 `--custom-mode` 同一次 apply 一併重設(cage 0.2.0 的 wlr-output-management apply 走 `wlr_output_head_v1_state_apply`,支援 scale)——邏輯尺寸回到「點」數、游標/UI 尺寸正常,X11 surface 由 compositor 放大(預期視覺等同預設 view-scaling,但比例自由、無 letterbox)。**實機 e2e(Apple Silicon,2026-07-12)機器可驗項全過**:開機 console `gui: resize: Virtual-1 output scale -> 2` → guest 內 `xdpyinfo` 回報**邏輯**尺寸 1280×800(實體模式 2560×1600);`CHEFER_VZ_GUI_TEST_RESIZE` 縮放 1100×700pt → `gui: resize: Virtual-1 -> 2200x1400 (scale 2)` → `xdpyinfo` 跟隨 1100×700——cage 0.2.0 對 mode+scale 同次 apply 無誤。**人工目測項(游標/UI 尺寸、座標映射、Xwayland 放大畫質;拖拉中細黑邊確認為 debounce 前暫態)亦於 2026-07-12 實機通過 → 真動態解析度已翻為 macOS 14+ 預設**,`CHEFER_VZ_DYNAMIC_RESOLUTION=0` 退回 view 縮放保底(檢核清單見 `vz-smoke.sh --gui`)。helper 另提供 `CHEFER_VZ_GUI_TEST_RESIZE=<秒>:x` 測試鉤子(程式化 `setContentSize`,與拖拉同路徑),供 vz-smoke 自動驗證本鏈。macOS 13 無 `automaticallyReconfiguresDisplay` → 維持固定尺寸。實際打字點擊/關窗語意留人工檢核(`scripts/vz-smoke.sh --gui` 清單)。**已知問題(實機發現,均已補修)**:(a) helper 對 guest:55381 的剪貼簿連線偶發卡在 NWConnection 靜默 `.waiting`——已補一次性提示 + 3 秒棄連重連 + `CHEFER_CODESIGN_IDENTITY` 穩定簽章(見上「剪貼簿」段的 `.waiting` 可見性);(b) 對 runtime 行程單發 SIGINT/SIGTERM(非終端機 Ctrl+C 的整個 process group)時 helper/VM 會殘留(`vz-smoke.sh` 的 `kill -INT` 即觸發)——已補 stdin liveness 契約(helper 讀 stdin EOF 自我了結,見上「Helper 生命週期」;WHP 側另有 Job Object 雙保險,見 §6 whp 節)。 - **替代(不採用)**:X11 → 要求使用者裝 **XQuartz**、X11 經 vsock 轉發——較快但破壞零安裝、過 VM 邊界延遲高、XQuartz 老舊,故不採。 - - **GUI overlay 打包契約(vz 與 whp 共用)**:cage + Xwayland + Mesa(llvmpipe) 及其依賴閉包太肥(估數十 MB),**不放進基礎 initramfs**——做成獨立 kit 產物 **`chefer-gui-overlay-.sqfs`**(rootfs subtree 的 **squashfs**,zstd 壓縮;由 `scripts/build-gui-overlay.sh` 於容器內自 Alpine 套件以 `mksquashfs` 收集,release workflow 建置、`verify-release-kit` 檢查)。**squashfs 而非 tar.zst**:唯讀、可直接掛載、頁面按需載入 → guest 免每次開機解壓 200MB+。基底 **Alpine 3.22**:動態解析度要求 **cage ≥0.2.0**(wlr-output-management 協定;3.20 的 0.1.5 沒有),並附 **wlr-randr**(guest-agent resize watcher 對 cage 下 custom mode 的工具)。 + - **GUI overlay 打包契約(vz 與 whp 共用)**:cage + Xwayland + Mesa(llvmpipe) 及其依賴閉包太肥(估數十 MB),**不放進基礎 initramfs**——做成獨立 kit 產物 **`chefer-gui-overlay-.sqfs`**(rootfs subtree 的 **squashfs**,zstd 壓縮;由 `scripts/build-gui-overlay.sh` 於容器內自 Alpine 套件以 `mksquashfs` 收集,release workflow 建置、`verify-release-kit` 檢查)。**squashfs 而非 tar.zst**:唯讀、可直接掛載、頁面按需載入 → guest 免每次開機解壓 200MB+。基底 **Alpine 3.22**:動態解析度要求 **cage ≥0.2.0**(wlr-output-management 協定;3.20 的 0.1.5 沒有),並附 **wlr-randr**(guest-agent resize watcher 對 cage 下 custom mode/HiDPI output scale 的工具)。 - **嵌入規則**:app 有任一 `interface_mode: gui/both` 服務 **且** target 為 Windows 或 macOS(存在 VM 後端可能)→ chefer-pack 把 overlay 嵌入 bundle `vm/chefer-gui-overlay-.sqfs`;純 server app 與 Linux target **不揹此體積**。Windows target 就算最後跑在 wsl2(用不到 overlay)也要嵌——build 時無從得知執行時會落在哪個後端。kit 缺 overlay → build 印明確警告(GUI 在 WHP/vz 將降級並附補救方式),不擋 build。 - **kit 探索**:統一加在 `chefer_bundle::kit::find_gui_overlay(kit_dirs, arch)`,比照 guest-agent/pasta,不另起爐灶。`chefer upgrade` 整包換 kit → overlay 不會與 CLI 漂移;`inspect` 的 `vm/` 清單自然列出。 - **消費端**:guest-agent(`gui.rs`)見 bundle `vm/` 有 overlay squashfs → **唯讀掛載並以 overlayfs 把其中各 top-level 目錄(`/run`、`/tmp` 等虛擬/執行期目錄除外)疊上 tmpfs 根**(overlay 內容為高優先 lower、原內容為低優先 lower、可寫 upper 放 tmpfs),在既有 switch_root 之後;免複製、掛載瞬間完成、免每次開機解壓。kernel 需 `CONFIG_SQUASHFS + CONFIG_SQUASHFS_ZSTD`(+ 既有 `CONFIG_OVERLAY_FS`)。之後對 gui/both 服務以 `cage -- ` 包裝。舊 bundle / 無 overlay 而有 gui 服務 → WHP/vz 印可行動錯誤(說明需以含 GUI overlay 的 kit 重新打包),不得無聲黑屏。**bundle 自包含**(自帶對應 guest-agent + appliance kernel + overlay)→ tar.zst→squashfs 為乾淨切換、無需雙格式相容。 @@ -338,7 +338,7 @@ pub fn run_app(ctx: &AppRunContext) -> anyhow::Result; // 取第一個 Avai - ✅ **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 圖片剪貼簿。 - **動態解析度(實機驗證完成,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 側跟隨(不換 compositor、不 replug)**:guest-agent 的 resize watcher(`resize.rs`,vz/WHP 共用)監聽 drm uevent → `GETCONNECTOR` re-probe 取新 preferred mode → `wlr-randr --custom-mode` 經 **wlr-output-management** 要求 cage re-modeset——需 cage ≥0.2.0(overlay 已改 Alpine 3.22 基底並附 wlr-randr;wlroots 上游的 preferred-mode-change→`request_state` 通知仍是未合併 draft,故由 watcher 補),connector 全程保持連線、無 replug 的拆輸出問題;host 視窗拉伸(`user_sized` StretchDIBits)保留為舊 overlay/失敗時的保底。整條鏈已在 **vz 實機 e2e 驗證**(同一 watcher 路徑,見 §6 macOS 分期④);**WHP 實機回歸待跑**(驗法:拖拉視窗後 console 應出現 `gui: resize: Virtual-1 -> WxH`,trace 的 flush 尺寸改為新解析度)。裝置模型/橋接/EDID 更新/events_clear 皆有單元測試;DPI 縮放另列後續。 + **實機驗證(本機 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 側跟隨(不換 compositor、不 replug)**:guest-agent 的 resize watcher(`resize.rs`,vz/WHP 共用)監聽 drm uevent → `GETCONNECTOR` re-probe 取新 preferred mode → `wlr-randr --custom-mode` 經 **wlr-output-management** 要求 cage re-modeset——需 cage ≥0.2.0(overlay 已改 Alpine 3.22 基底並附 wlr-randr;wlroots 上游的 preferred-mode-change→`request_state` 通知仍是未合併 draft,故由 watcher 補),connector 全程保持連線、無 replug 的拆輸出問題;host 視窗拉伸(`user_sized` StretchDIBits)保留為舊 overlay/失敗時的保底。整條鏈已在 **vz 實機 e2e 驗證**(同一 watcher 路徑,見 §6 macOS 分期④);**WHP 實機回歸待跑**(驗法:拖拉視窗後 console 應出現 `gui: resize: Virtual-1 -> WxH`,trace 的 flush 尺寸改為新解析度)。裝置模型/橋接/EDID 更新/events_clear 皆有單元測試;DPI 縮放另列後續(guest 側機制已就緒:cmdline `chefer.gui_scale=` → watcher 設 output scale,見 §6 macOS 分期④——whp-helper 尚未附此參數,Windows 的 per-monitor 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 }` diff --git a/docs/HANDOFF-macos-vz.md b/docs/HANDOFF-macos-vz.md index 3719635..86a00f1 100644 --- a/docs/HANDOFF-macos-vz.md +++ b/docs/HANDOFF-macos-vz.md @@ -1,6 +1,6 @@ # Handoff:在 Mac 上驗證 macOS vz 後端 -> **✅ 狀態:驗證已完成(2026-07-11,Apple Silicon / macOS 26)。** 核心路徑(開機/exit code/TCP+UDP 埠轉發)、bridge 出網(pasta+DNS)、多服務/persist、GUI(顯示、剪貼簿文字雙向+PNG)全數通過;動態解析度實測為**兩段式**:開機時 guest 模式=視窗 Retina 像素尺寸(`CHEFER_VZ_GUI_SIZE` 生效),拖拉時 cage 不自行跟模式變更——**後續已由 guest-agent 的 resize watcher 補上 guest 側 re-modeset(`CHEFER_VZ_DYNAMIC_RESOLUTION=1` 下全鏈實測通過,見「發現」第 2 點;預設仍 view 縮放=HiDPI 取捨)**。結果已回填 README 與 DESIGN §6。實機揪出並修掉四顆 bug:extract 不還原 exec bit、release 的 aarch64 initramfs 內嵌 x86-64 busybox、runtime 埠代理與 vz relay 搶埠、容器缺 `/etc/resolv.conf`(bridge 出網通但 DNS 全掛)。仍待人工的互動項:實際打字點擊(HID)、拖拉縮放、關窗語意;兩個已知問題(剪貼簿連線偶發靜默 `.waiting`、對 runtime 單發 signal 會殘留 helper)見 DESIGN §6。以下原文保留供重跑參考,**過時處以「實測更正」標註**。 +> **✅ 狀態:驗證已完成(2026-07-11,Apple Silicon / macOS 26)。** 核心路徑(開機/exit code/TCP+UDP 埠轉發)、bridge 出網(pasta+DNS)、多服務/persist、GUI(顯示、剪貼簿文字雙向+PNG)全數通過;動態解析度實測為**兩段式**:開機時 guest 模式=視窗 Retina 像素尺寸(`CHEFER_VZ_GUI_SIZE` 生效),拖拉時 cage 不自行跟模式變更——**後續已由 guest-agent 的 resize watcher 補上 guest 側 re-modeset(全鏈實測通過,見「發現」第 2 點;HiDPI output scale 補上後已翻為 macOS 14+ 預設)**。結果已回填 README 與 DESIGN §6。實機揪出並修掉四顆 bug:extract 不還原 exec bit、release 的 aarch64 initramfs 內嵌 x86-64 busybox、runtime 埠代理與 vz relay 搶埠、容器缺 `/etc/resolv.conf`(bridge 出網通但 DNS 全掛)。仍待人工的互動項:實際打字點擊(HID)、拖拉縮放、關窗語意;兩個已知問題(剪貼簿連線偶發靜默 `.waiting`、對 runtime 單發 signal 會殘留 helper)見 DESIGN §6。以下原文保留供重跑參考,**過時處以「實測更正」標註**。 這份是給「拿到一台實體 Mac、要把 chefer 的 macOS(Virtualization.framework / vz)路徑收尾」的接手文件。程式碼側都寫完了,剩下的**只差在真 Mac 上跑一次驗證**——GitHub 的 macOS runner 是巢狀虛擬化、開不了 VZ guest,所以 CI 只能編譯+簽章,端到端一定要實機。 @@ -91,7 +91,7 @@ bash scripts/vz-smoke.sh --kit-dir kit --gui 1. **boot-race 已修,vz 應同樣受惠**:WHP 實機發現 GUI 約 1/3 間歇開機失敗,根因是 **cage/wlroots 的 libinput backend 在「還沒有輸入裝置」時 abort**(virtio-input 節點由 udev 非同步建立、cage 贏競態就見 0 裝置)。修法 `WLR_LIBINPUT_NO_DEVICES=1` 在 `guest-agent/src/gui.rs`,**vz GUI 走同一條 gui.rs**,所以理論上一起修好了——但**這點值得在 Mac 上確認**(vz 的輸入裝置由 VZ 提供,時序可能不同)。 -2. **動態解析度——guest 側 re-modeset 已補上(實機 e2e 通過)**:實測證實 cage 不會自行跟隨線上模式變更(vz 的 `automaticallyReconfiguresDisplay` 只解決 host→guest 這半段)。現由 guest-agent 的 **resize watcher(`resize.rs`)**補上:drm uevent → `GETCONNECTOR` re-probe → `wlr-randr --custom-mode`(wlr-output-management)要 cage 換模式,vz 與 WHP 共用。**前提:GUI overlay 需建自新版 `build-gui-overlay.sh`(Alpine 3.22:cage 0.2.0 + wlr-randr)**,vz-smoke 有 Docker 會自動現建。**vz 預設仍是 view 等比縮放**(HiDPI 取捨,見 DESIGN §6 ④);`CHEFER_VZ_DYNAMIC_RESOLUTION=1` 切真動態解析度,配 `CHEFER_VZ_GUI_TEST_RESIZE=20:1100x700` 可免拖拉自動驗證(console 應出現 `gui: resize: Virtual-1 -> 2200x1400`,guest 內 `xdpyinfo` dimensions 跟著變——已在 Apple Silicon 實測通過)。**WHP 實機回歸待跑**(WHP 無 opt-in 閘,拖拉即觸發)。 +2. **動態解析度——guest 側 re-modeset 已補上(實機 e2e 通過)**:實測證實 cage 不會自行跟隨線上模式變更(vz 的 `automaticallyReconfiguresDisplay` 只解決 host→guest 這半段)。現由 guest-agent 的 **resize watcher(`resize.rs`)**補上:drm uevent → `GETCONNECTOR` re-probe → `wlr-randr --custom-mode`(wlr-output-management)要 cage 換模式,vz 與 WHP 共用。**前提:GUI overlay 需建自新版 `build-gui-overlay.sh`(Alpine 3.22:cage 0.2.0 + wlr-randr)**,vz-smoke 有 Docker 會自動現建。**真動態解析度已翻為 macOS 14+ 預設(2026-07-12)**,`CHEFER_VZ_DYNAMIC_RESOLUTION=0` 退回 view 縮放保底(DESIGN §6 ④);配 `CHEFER_VZ_GUI_TEST_RESIZE=20:1100x700` 可免拖拉自動驗證(console 應出現 `gui: resize: Virtual-1 -> 2200x1400`,guest 內 `xdpyinfo` dimensions 跟著變——已在 Apple Silicon 實測通過)。**WHP 實機回歸待跑**(WHP 無 opt-in 閘,拖拉即觸發)。**HiDPI output scale 已接上(實機機器可驗項通過,2026-07-12)**:helper 於動態解析度模式把開機當下螢幕的 backingScaleFactor 以 `chefer.gui_scale=` 附進 kernel cmdline,watcher 開機補設一次 output scale、之後每次換模式的同一次 apply 一併帶 `--scale`。實機 e2e(xdpyinfo probe 容器 + `CHEFER_VZ_GUI_TEST_RESIZE=30:1100x700`):console 出現 `gui: resize: Virtual-1 output scale -> 2`,`xdpyinfo` 開機即回報**邏輯**尺寸 1280x800(實體 2560x1600);縮放後 `gui: resize: Virtual-1 -> 2200x1400 (scale 2)`、`xdpyinfo` 跟隨 1100x700。**人工目測(游標/UI 尺寸、座標映射(xeyes)、Xwayland 畫質;拖拉細黑邊確認為 debounce 前暫態)2026-07-12 亦通過 → 已翻為 macOS 14+ 預設**,`CHEFER_VZ_DYNAMIC_RESOLUTION=0` 退回 view 縮放保底(回歸清單見 `vz-smoke.sh --gui`)。 3. **guest-agent 在 VM 內的 stderr 預設看不到**(console 在 `quiet` 下只顯示高優先序 kmsg)。要在 guest 內除錯:寫 `/dev/kmsg`、優先序 `< 4`、且**整行一次 `write_all`**(每個 write() 是一筆 kmsg 記錄,`writeln!` 會拆開、訊息本體那筆沒前綴會被 `quiet` 擋掉)。gui.rs 的 `note()` 已用這招,且會把 cage 啟動失敗的 stderr 轉到 kmsg——**若 GUI 開不起來,先看 console 的 `[guest-agent] gui: cage: ...` 行**,那就是 wlroots 的真正錯誤。 diff --git a/scripts/vz-smoke.sh b/scripts/vz-smoke.sh index 12d9811..8160663 100644 --- a/scripts/vz-smoke.sh +++ b/scripts/vz-smoke.sh @@ -201,14 +201,20 @@ YML [ ] 滑鼠移動/點擊、鍵盤輸入進得了 guest(HID) [ ] host 複製文字 → guest 內可貼上;guest 複製 → host 可貼上(剪貼簿,含 PNG 圖) [ ] 關窗 → app 乾淨結束、行程退出(關窗 = app 結束語意) - [ ] 動態解析度(opt-in,macOS 14+;guest 側 re-modeset 由 guest-agent resize watcher - 提供,需上面 3b/5 的新 overlay)——本清單跑完後另跑: - CHEFER_VZ_EXPERIMENTAL=1 CHEFER_VZ_DYNAMIC_RESOLUTION=1 \ - CHEFER_VZ_GUI_TEST_RESIZE=20:1100x700 <同一個單檔> - 20 秒後視窗自動縮放(與手動拖拉同路徑),console 應出現 - 「gui: resize: Virtual-1 -> 2200x1400」(Retina 2x)且畫面重排非拉伸。 - (預設模式的拖拉=view 等比縮放、不 re-modeset——預設取捨見 DESIGN §6 ④: - Retina 像素模式下 Linux guest 無 HiDPI output scale,UI 會縮半。) + [ ] 動態解析度 + HiDPI scale(macOS 14+ **預設開**;guest 側 re-modeset 與 output + scale 由 guest-agent resize watcher 提供,需上面 3b/5 的新 overlay)——本清單 + 跑完後另跑: + CHEFER_VZ_EXPERIMENTAL=1 CHEFER_VZ_GUI_TEST_RESIZE=20:1100x700 <同一個單檔> + 開機後 console 應出現「gui: resize: Virtual-1 output scale -> 2」(Retina 2x; + helper 經 kernel cmdline chefer.gui_scale 傳入),20 秒後視窗自動縮放(與手動 + 拖拉同路徑),console 應出現「gui: resize: Virtual-1 -> 2200x1400 (scale 2)」 + 且畫面重排非拉伸。逐項回歸檢核(全部項目已於 2026-07-12 實機通過並翻為預設, + DESIGN §6 ④): + [ ] 游標與 UI 尺寸正常、非縮半(output scale 生效的直接證據) + [ ] guest 內 xdpyinfo 回報「邏輯」尺寸(開機 1280x800、縮放後 1100x700) + [ ] X11 app 畫質可接受(scale-1 X11 surface 由 compositor 放大)+點擊落點準確 + (CHEFER_VZ_DYNAMIC_RESOLUTION=0 或 macOS 13 退回 view 等比縮放+鎖長寬比—— + 保底路徑;搭舊 overlay(cage <0.2.0、guest 不 re-modeset)時亦建議如此。) CHECK CHEFER_VZ_EXPERIMENTAL=1 "$work/dist/vz-gui-smoke/vz-gui-smoke_${arch}-apple-darwin" --extract-dir "$work/extract-gui" note "GUI app 已結束(exit $?)——請依上方清單回填結果" diff --git a/vz-helper/main.swift b/vz-helper/main.swift index ee81a19..70e7d7e 100644 --- a/vz-helper/main.swift +++ b/vz-helper/main.swift @@ -111,6 +111,30 @@ if let token = clipToken { cmdline += " chefer.clip_token=\(token) chefer.clip_port=\(CLIP_PORT)" } +// 動態解析度:macOS 14+ **預設開**(實機驗證通過後翻預設,DESIGN §6 ④——guest 側 +// re-modeset 與 HiDPI output scale 均由 guest-agent resize watcher 提供)。 +// CHEFER_VZ_DYNAMIC_RESOLUTION=0 退回 view 等比縮放——保底用(例如手動搭配舊 overlay +// (cage <0.2.0,無 wlr-output-management)的 kit;正常 kit 的 overlay 與 helper 同版 +// 出貨不會有此組合)。macOS 13 無 automaticallyReconfiguresDisplay,恆走 view 縮放。 +let dynamicResolution: Bool = { + if #available(macOS 14.0, *) { + return ProcessInfo.processInfo.environment["CHEFER_VZ_DYNAMIC_RESOLUTION"] != "0" + } + return false +}() + +// HiDPI(動態解析度模式限定):automaticallyReconfiguresDisplay 推給 guest 的是 +// Retina「實體像素」尺寸,Linux guest 無 HiDPI 概念 → UI/游標縮半。把開機當下螢幕的 +// backingScaleFactor 附進 cmdline(同 clip_token 作法),guest-agent 的 resize watcher +// 據此對 cage 設 wlr-output-management 的 output scale——邏輯尺寸回到「點」數。 +// 視窗跨螢幕拖移造成的 scale 變化暫不追蹤(恆用開機值)。 +if #available(macOS 14.0, *), guiMode, dynamicResolution { + let scale = (NSScreen.main ?? NSScreen.screens.first)?.backingScaleFactor ?? 1.0 + if scale > 1.0 { + cmdline += " chefer.gui_scale=\(String(format: "%g", scale))" + } +} + // --- 組態 --- let bootLoader = VZLinuxBootLoader(kernelURL: URL(fileURLWithPath: kernelPath)) bootLoader.initialRamdiskURL = URL(fileURLWithPath: initramfsPath) @@ -174,18 +198,16 @@ config.memoryBalloonDevices = [VZVirtioTraditionalMemoryBalloonDeviceConfigurati // GUI:virtio-gpu 一個 scanout + USB 鍵盤 + 絕對座標指標(VZVirtualMachineView 自動把 // HID 事件轉進這些裝置;絕對座標同 WHP 的 tablet 選擇,避免滑鼠捕捉問題)。 // -// 動態解析度開關(實體 Mac 實測後的取捨):guest 的 cage kiosk compositor 啟動後 -// **不跟隨**模式變更(見 DESIGN §6 ④)——此時開 automaticallyReconfiguresDisplay 反而 -// 讓 view 停止縮放、等一個永遠不會來的 guest re-modeset,拖拉視窗時畫面尺寸不同步。 -// 且它推給 guest 的是 Retina **實體像素**尺寸(點 ×2),Linux guest 無 HiDPI 概念、 -// cage 也設不了 output scale → 游標與 UI 內容都只剩一半大(實測使用者直接抱怨)。 -// 故預設:scanout = 視窗「點」數(guest 看到 WxH 的螢幕、內容尺寸與一般視窗一致), -// automaticallyReconfiguresDisplay 關、由 view 縮放(Retina 上 2× 放大,略軟但尺寸正確; -// 拖拉視窗即時跟手)。等 guest compositor 支援 re-modeset + HiDPI output scale 後, -// 以 CHEFER_VZ_DYNAMIC_RESOLUTION=1 切回真動態解析度(macOS 14+)。 +// 動態解析度為 macOS 14+ 預設(實機驗證後定案,DESIGN §6 ④): +// automaticallyReconfiguresDisplay 把視窗 backing 像素尺寸推進 guest,guest-agent 的 +// resize watcher 讓 cage re-modeset 跟隨、並依上面的 chefer.gui_scale 設 output scale +// (Retina 實體像素模式下邏輯尺寸回到「點」數,游標/UI 尺寸正常)——比例自由、無 +// letterbox(拖拉中的細黑邊為 debounce 前的暫態)。實機檢核(游標/UI 尺寸、座標映射、 +// Xwayland 畫質、xdpyinfo 邏輯尺寸)2026-07 全數通過。 +// CHEFER_VZ_DYNAMIC_RESOLUTION=0(或 macOS 13)退回舊取捨:scanout=視窗「點」數、 +// automaticallyReconfiguresDisplay 關、view 等比縮放+鎖長寬比(Retina 上 2× 放大略軟 +// 但尺寸正確)——guest 不 re-modeset(如舊 overlay 的 cage <0.2.0)時的保底。 let (guiW, guiH) = guiSize() -let dynamicResolution = - ProcessInfo.processInfo.environment["CHEFER_VZ_DYNAMIC_RESOLUTION"] == "1" if guiMode { let gpu = VZVirtioGraphicsDeviceConfiguration() gpu.scanouts = [ @@ -510,11 +532,10 @@ if guiMode { view.virtualMachine = vm view.autoresizingMask = [.width, .height] - // 視窗以「點」開 guiW×guiH、scanout 同為 guiW×guiH → guest 內容尺寸與一般視窗 - // 一致(Retina 上由 view 2× 放大,略軟但游標/UI 大小正確)。預設 - // automaticallyReconfiguresDisplay **關**:view 縮放跟隨視窗(拖拉即時跟手)—— - // guest 的 cage 不跟模式變更,開了反而畫面尺寸不同步、且內容縮成一半(實測)。 - // CHEFER_VZ_DYNAMIC_RESOLUTION=1 + macOS 14+ 才走真動態解析度。 + // 視窗以「點」開 guiW×guiH(scanout 初值同尺寸)。動態解析度(macOS 14+ 預設): + // automaticallyReconfiguresDisplay 開機即把 backing 像素尺寸推進 guest、拖拉時 + // re-modeset 跟隨(guest 側 watcher + chefer.gui_scale,見上)。opt-out(=0 或 + // macOS 13):automaticallyReconfiguresDisplay 關、view 等比縮放+鎖長寬比。 let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: CGFloat(guiW), height: CGFloat(guiH)), styleMask: [.titled, .closable, .miniaturizable, .resizable], @@ -545,7 +566,8 @@ if guiMode { // view 尺寸變更後的下游與使用者拖拉完全相同(automaticallyReconfiguresDisplay → // VZ 對 guest virtio-gpu 重組態 → guest-agent resize watcher 讓 cage re-modeset), // 讓沒有 Accessibility 權限的自動化環境也能驗證整條鏈。W/H 為 point,Retina 下 - // guest 實際拿到的是像素尺寸(×backingScaleFactor)。 + // guest 實際拿到的是像素尺寸(×backingScaleFactor);guest 端 output scale + // (chefer.gui_scale)會把邏輯尺寸縮回點數(xdpyinfo 應回報 WxH 而非像素尺寸)。 if #available(macOS 14.0, *), dynamicResolution, let spec = ProcessInfo.processInfo.environment["CHEFER_VZ_GUI_TEST_RESIZE"] {