diff --git a/README.md b/README.md index 59a454e..6fff2de 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 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) | ✅ 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), but **live drag-resize only scales the picture** — same `cage` kiosk-compositor limitation as WHP (the compositor doesn't follow mode changes after startup; verified on real hardware: guest stayed 2560×1600 across window drags). 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); 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 | | **`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) — but **live drag-resize only scales the picture**, same as WHP: the `cage` kiosk compositor doesn't follow mode changes after startup (verified on real hardware — guest stayed 2560×1600 across drags; the fix would be guest-side, e.g. teaching the compositor to re-modeset on virtio-gpu hotplug). 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). 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] **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/clipboard.rs b/crates/guest-agent/src/clipboard.rs index 9e3a3be..a96a0d6 100644 --- a/crates/guest-agent/src/clipboard.rs +++ b/crates/guest-agent/src/clipboard.rs @@ -332,9 +332,15 @@ fn wl_copy_kind(kind: u8, data: &[u8]) { } } -/// 在 PATH 找執行檔(overlay 解壓後的 /usr/bin 等)。 -fn which(bin: &str) -> Option { - let path = std::env::var_os("PATH")?; +/// 在 PATH 找執行檔(overlay 解壓後的 /usr/bin 等)。resize.rs 亦共用。 +/// +/// PATH 未設時退回標準路徑:appliance init 舊版沒 export PATH(kernel 給 PID 1 的環境 +/// 本來就沒有),guest-agent 會拿不到——別因此把剪貼簿/動態解析度整組靜默停用。 +/// 執行時 `Command::new(裸名)` 的 execvp 在無 PATH 下也用類似的預設路徑,行為一致。 +pub(crate) fn which(bin: &str) -> Option { + let path = std::env::var_os("PATH") + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| "/usr/local/bin:/bin:/usr/bin:/sbin:/usr/sbin".into()); for dir in std::env::split_paths(&path) { let cand = dir.join(bin); if cand.is_file() { diff --git a/crates/guest-agent/src/gui.rs b/crates/guest-agent/src/gui.rs index d955841..170599b 100644 --- a/crates/guest-agent/src/gui.rs +++ b/crates/guest-agent/src/gui.rs @@ -14,6 +14,8 @@ //! `$XDG_RUNTIME_DIR/wayland-0`(與 Xwayland 的 `/tmp/.X11-unix/X0`)出現。 //! 4. 設定 `XDG_RUNTIME_DIR`/`WAYLAND_DISPLAY`/`DISPLAY` 供 exec.rs 既有的 //! GUI socket bind 邏輯把 socket 掛進服務容器。 +//! 5. 啟動 host↔guest 剪貼簿同步(clipboard.rs)與動態解析度跟隨(resize.rs—— +//! host 調整視窗 → guest re-modeset,cage 本身不跟隨線上模式變更)。 //! //! 觸發條件(`maybe_start`):appliance init 設 `CHEFER_VM_GUI=1` **且** manifest //! 有 gui/both 服務。overlay 缺失 → 硬錯誤(依契約不得無聲黑屏)。 @@ -29,7 +31,7 @@ use nix::mount::{MsFlags, mount}; /// 記一行 GUI 診斷。同時走 stderr(原生 Linux / 有接 console 的後端可見)與 **`/dev/kmsg`** /// ——WHP 的序列 console 在 `quiet` 下不顯示 guest-agent 自己的 stderr,但高優先序 kmsg 會 /// 出現在 console(同 appliance init 的 `report()`),故 WHP GUI 的啟動診斷才看得到。best-effort。 -fn note(msg: &str) { +pub(crate) fn note(msg: &str) { eprintln!("[guest-agent] gui: {msg}"); if let Ok(mut f) = std::fs::OpenOptions::new().write(true).open("/dev/kmsg") { use std::io::Write as _; @@ -47,18 +49,21 @@ const GUI_RUNTIME_DIR: &str = "/run/chefer-gui"; /// 等 compositor socket 出現的上限。llvmpipe 首次啟動慢,放寬一點。 const SOCKET_WAIT: Duration = Duration::from_secs(20); -/// 存活中的 GUI 環境;Drop 時收掉 clipboard / cage / seatd / udevd。 +/// 存活中的 GUI 環境;Drop 時收掉 resize/clipboard / cage / seatd / udevd。 pub struct GuiSession { cage: Child, seatd: Option, udevd: Option, /// host↔guest 剪貼簿同步(有 cmdline token 且 wl-clipboard 可用時;見 clipboard.rs)。 clipboard: Option, + /// 動態解析度跟隨(wlr-randr 可用時;見 resize.rs)。 + resize: Option, } impl Drop for GuiSession { fn drop(&mut self) { - // 先收剪貼簿同步(背景執行緒),再收 compositor。 + // 先收背景執行緒(解析度跟隨、剪貼簿同步),再收 compositor。 + self.resize.take(); self.clipboard.take(); let _ = self.cage.kill(); let _ = self.cage.wait(); @@ -111,6 +116,7 @@ pub fn maybe_start(bundle_dir: &Path, manifest: &Manifest) -> Result Result, + handle: Option>, +} + +impl Drop for ResizeWatcher { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(h) = self.handle.take() { + let _ = h.join(); + } + } +} + +/// 需要時啟動解析度跟隨。回 None 表示不適用:overlay 沒有 wlr-randr(舊 kit)或 +/// netlink socket 開不起來。呼叫端須先設好 `WAYLAND_DISPLAY`/`XDG_RUNTIME_DIR` +/// (wlr-randr 子行程繼承本行程環境)。 +pub fn maybe_start() -> Option { + if crate::clipboard::which("wlr-randr").is_none() { + crate::gui::note( + "resize: wlr-randr not found in the GUI overlay; dynamic resolution disabled \ + (repack with a kit built from a newer build-gui-overlay.sh)", + ); + return None; + } + let sock = match open_uevent_socket() { + Ok(s) => s, + Err(e) => { + crate::gui::note(&format!( + "resize: cannot listen for kernel uevents ({e}); dynamic resolution disabled" + )); + return None; + } + }; + 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)) + .ok()?; + crate::gui::note("resize: following host window size (drm uevent -> wlr-output-management)"); + Some(ResizeWatcher { + stop, + handle: Some(handle), + }) +} + +/// 主迴圈:等 drm change → debounce → probe 新 preferred mode → wlr-randr 套用。 +fn run(sock: OwnedFd, stop: Arc) { + // 最近一次成功套用的尺寸:同尺寸不重設(避免無謂 re-modeset 閃爍)。 + let mut last: Option<(u16, u16)> = None; + let mut failures = 0u32; + let mut buf = [0u8; 8192]; + while !stop.load(Ordering::Relaxed) { + match recv_uevent(&sock, &mut buf) { + RecvOutcome::Quiet => continue, + RecvOutcome::DrmChange => {} + } + // debounce:等到 DEBOUNCE 內沒有新的 drm 事件才動作(拖拉中 host 連發)。 + let mut last_event = Instant::now(); + while last_event.elapsed() < DEBOUNCE { + if stop.load(Ordering::Relaxed) { + return; + } + if matches!(recv_uevent(&sock, &mut buf), RecvOutcome::DrmChange) { + last_event = Instant::now(); + } + } + + let Some((w, h)) = probe_preferred_size() else { + crate::gui::note("resize: drm change event, but no connected connector with modes"); + continue; + }; + if last == Some((w, h)) { + continue; + } + match apply_mode(w, h) { + Ok(output) => { + last = Some((w, h)); + failures = 0; + crate::gui::note(&format!("resize: {output} -> {w}x{h}")); + } + Err(e) => { + failures += 1; + crate::gui::note(&format!("resize: failed to apply {w}x{h}: {e}")); + if failures >= MAX_FAILURES { + crate::gui::note( + "resize: giving up (wlr-randr keeps failing; is the overlay's cage \ + older than 0.2.0, i.e. without wlr-output-management?)", + ); + return; + } + } + } + } +} + +// ---- kernel uevent(netlink)---- + +/// 開 `NETLINK_KOBJECT_UEVENT` socket 聽 kernel 原生 uevent 廣播(group 1)。 +/// udevd 的轉播在 group 2 且是 libudev 私有格式——直聽 kernel 不依賴 udevd 存活。 +fn open_uevent_socket() -> std::io::Result { + let fd = unsafe { + libc::socket( + libc::AF_NETLINK, + libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, + libc::NETLINK_KOBJECT_UEVENT, + ) + }; + if fd < 0 { + return Err(std::io::Error::last_os_error()); + } + let fd = unsafe { OwnedFd::from_raw_fd(fd) }; + let mut addr: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; + addr.nl_family = libc::AF_NETLINK as libc::sa_family_t; + addr.nl_pid = 0; // 0 = kernel 自派唯一 id(勿填 getpid:一個行程可能開多個 netlink socket) + addr.nl_groups = 1; // kernel uevent 廣播群組 + let r = unsafe { + libc::bind( + fd.as_raw_fd(), + std::ptr::from_ref(&addr).cast::(), + std::mem::size_of::() as libc::socklen_t, + ) + }; + if r < 0 { + return Err(std::io::Error::last_os_error()); + } + // recv timeout 讓 stop 旗標能中止,同時充當 debounce 迴圈步長。 + // tv_usec 的欄位型別在 musl 1.2 改為 64-bit(libc 的 suseconds_t 別名已標 deprecated) + // ——以 `as _` 交給推導,兩邊都對。 + let tv = libc::timeval { + tv_sec: 0, + tv_usec: RECV_TIMEOUT.subsec_micros() as _, + }; + let r = unsafe { + libc::setsockopt( + fd.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_RCVTIMEO, + std::ptr::from_ref(&tv).cast(), + std::mem::size_of::() as libc::socklen_t, + ) + }; + if r < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(fd) +} + +enum RecvOutcome { + /// 逾時、非 drm 事件、或可忽略的錯誤。 + Quiet, + /// 收到 drm 子系統的 change 事件(或 ENOBUFS 溢位——可能漏了事件,保守當作有)。 + DrmChange, +} + +/// 收一則 uevent 並分類。 +fn recv_uevent(sock: &OwnedFd, buf: &mut [u8]) -> RecvOutcome { + let n = unsafe { + libc::recv( + sock.as_raw_fd(), + buf.as_mut_ptr().cast::(), + buf.len(), + 0, + ) + }; + if n < 0 { + // ENOBUFS:kernel 端佇列溢位(我們聽的是全系統 uevent)——期間可能漏掉 drm + // 事件,保守觸發一次 probe(同尺寸抑制讓誤觸發無害)。 + if std::io::Error::last_os_error().raw_os_error() == Some(libc::ENOBUFS) { + return RecvOutcome::DrmChange; + } + return RecvOutcome::Quiet; // EAGAIN(逾時)/EINTR + } + if is_drm_change(&buf[..n as usize]) { + RecvOutcome::DrmChange + } else { + RecvOutcome::Quiet + } +} + +/// kernel uevent 格式:`ACTION@DEVPATH\0KEY=VAL\0…`。判斷是否 drm 子系統的 change 事件 +/// (virtio-gpu 的 display info 變更走 `drm_kms_helper_hotplug_event` → change uevent)。 +fn is_drm_change(msg: &[u8]) -> bool { + let mut parts = msg.split(|b| *b == 0); + // 首段是 header;udevd group 2 轉播的 "libudev" magic 開頭在此自然不匹配。 + let Some(header) = parts.next() else { + return false; + }; + if !header.starts_with(b"change@") { + return false; + } + parts.any(|p| p == b"SUBSYSTEM=drm") +} + +// ---- DRM probe(raw ioctl,同 gui.rs loop 裝置的作法,免引依賴)---- + +/// linux/drm_mode.h `struct drm_mode_card_res`。 +#[repr(C)] +#[derive(Clone, Copy, Default)] +struct DrmModeCardRes { + fb_id_ptr: u64, + crtc_id_ptr: u64, + connector_id_ptr: u64, + encoder_id_ptr: u64, + count_fbs: u32, + count_crtcs: u32, + count_connectors: u32, + count_encoders: u32, + min_width: u32, + max_width: u32, + min_height: u32, + max_height: u32, +} + +/// linux/drm_mode.h `struct drm_mode_modeinfo`(68 bytes)。 +#[repr(C)] +#[derive(Clone, Copy, Default)] +struct DrmModeModeinfo { + clock: u32, + hdisplay: u16, + hsync_start: u16, + hsync_end: u16, + htotal: u16, + hskew: u16, + vdisplay: u16, + vsync_start: u16, + vsync_end: u16, + vtotal: u16, + vscan: u16, + vrefresh: u32, + flags: u32, + typ: u32, + name: [u8; 32], +} + +/// linux/drm_mode.h `struct drm_mode_get_connector`(80 bytes)。 +#[repr(C)] +#[derive(Clone, Copy, Default)] +struct DrmModeGetConnector { + encoders_ptr: u64, + modes_ptr: u64, + props_ptr: u64, + prop_values_ptr: u64, + count_modes: u32, + count_props: u32, + count_encoders: u32, + encoder_id: u32, + connector_id: u32, + connector_type: u32, + connector_type_id: u32, + connection: u32, + mm_width: u32, + mm_height: u32, + subpixel: u32, + pad: u32, +} + +/// `_IOWR('d', nr, T)`:dir(READ|WRITE)=3 << 30 | sizeof(T) << 16 | 'd'(0x64) << 8 | nr。 +const fn drm_iowr(nr: u64, size: usize) -> u64 { + (3u64 << 30) | ((size as u64) << 16) | (0x64 << 8) | nr +} +const DRM_IOCTL_MODE_GETRESOURCES: u64 = drm_iowr(0xA0, std::mem::size_of::()); +const DRM_IOCTL_MODE_GETCONNECTOR: u64 = drm_iowr(0xA7, std::mem::size_of::()); +/// drm_mode.h `DRM_MODE_CONNECTED`。 +const DRM_MODE_CONNECTED: u32 = 1; +/// drm_mode.h `DRM_MODE_TYPE_PREFERRED`。 +const DRM_MODE_TYPE_PREFERRED: u32 = 1 << 3; + +/// EINTR/EAGAIN 重試的 ioctl 薄封裝(libdrm 慣例:probe 期間被訊號打斷很常見)。 +fn drm_ioctl(fd: RawFd, req: u64, val: &mut T) -> std::io::Result<()> { + loop { + let r = unsafe { libc::ioctl(fd, req as _, std::ptr::from_mut(val)) }; + if r == 0 { + return Ok(()); + } + let err = std::io::Error::last_os_error(); + match err.raw_os_error() { + Some(libc::EINTR) | Some(libc::EAGAIN) => continue, + _ => return Err(err), + } + } +} + +/// 對 `/dev/dri/card*` 逐一 probe,回傳第一個「已連線 connector」的 preferred mode 尺寸 +/// (無 preferred 旗標就取第一個 mode)。VM 內單 GPU 單 scanout,第一個命中即答案。 +fn probe_preferred_size() -> Option<(u16, u16)> { + for n in 0..8 { + let Ok(card) = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(format!("/dev/dri/card{n}")) + else { + continue; + }; + if let Some(size) = probe_card(card.as_raw_fd()) { + return Some(size); + } + } + None +} + +/// 一張卡:GETRESOURCES 列 connector → 逐一 GETCONNECTOR。 +/// GETCONNECTOR 的 **count_modes=0 首呼叫**會讓 kernel 對 connector 重新 probe—— +/// virtio-gpu 藉此把 host 剛推來的新尺寸建成新的 preferred mode;第二呼叫帶緩衝區 +/// 取回清單(不再 probe)。 +/// +/// 兩段式呼叫之間清單可能又變(再一次 hotplug):此時 kernel **只回報新數量、不複製** +/// ——緩衝區會是全零。照 libdrm 慣例「數量對不上就整段重來」(上限 3 次)。 +fn probe_card(fd: RawFd) -> Option<(u16, u16)> { + 'retry: for _ in 0..3 { + let mut res = DrmModeCardRes::default(); + drm_ioctl(fd, DRM_IOCTL_MODE_GETRESOURCES, &mut res).ok()?; + if res.count_connectors == 0 { + return None; + } + let mut ids = vec![0u32; res.count_connectors as usize]; + let mut res2 = DrmModeCardRes { + connector_id_ptr: ids.as_mut_ptr() as u64, + count_connectors: ids.len() as u32, + ..Default::default() + }; + drm_ioctl(fd, DRM_IOCTL_MODE_GETRESOURCES, &mut res2).ok()?; + if (res2.count_connectors as usize) > ids.len() { + continue 'retry; // 清單變長,kernel 沒複製 + } + ids.truncate(res2.count_connectors as usize); + + for &conn_id in &ids { + // 首呼叫:count_modes=0 → kernel probe 並回報數量與連線狀態。 + let mut conn = DrmModeGetConnector { + connector_id: conn_id, + ..Default::default() + }; + if drm_ioctl(fd, DRM_IOCTL_MODE_GETCONNECTOR, &mut conn).is_err() { + continue; + } + if conn.connection != DRM_MODE_CONNECTED || conn.count_modes == 0 { + continue; + } + // 第二呼叫:只帶 modes 緩衝區(props/encoders 給 0 = 不取,也不再 probe)。 + let mut modes = vec![DrmModeModeinfo::default(); conn.count_modes as usize]; + let mut conn2 = DrmModeGetConnector { + connector_id: conn_id, + modes_ptr: modes.as_mut_ptr() as u64, + count_modes: modes.len() as u32, + ..Default::default() + }; + if drm_ioctl(fd, DRM_IOCTL_MODE_GETCONNECTOR, &mut conn2).is_err() { + continue; + } + if (conn2.count_modes as usize) > modes.len() { + continue 'retry; // 兩次呼叫間又 hotplug,kernel 沒複製 + } + modes.truncate(conn2.count_modes as usize); + if let Some(size) = pick_mode(&modes) { + return Some(size); + } + } + return None; + } + None +} + +/// 取 preferred mode(無則第一個可用者)的 (寬, 高)。零尺寸的項目不採 +/// (守住「把 0x0 交給 wlr-randr」的下游災難)。 +fn pick_mode(modes: &[DrmModeModeinfo]) -> Option<(u16, u16)> { + let usable = |m: &&DrmModeModeinfo| m.hdisplay > 0 && m.vdisplay > 0; + modes + .iter() + .filter(usable) + .find(|m| m.typ & DRM_MODE_TYPE_PREFERRED != 0) + .or_else(|| modes.iter().find(usable)) + .map(|m| (m.hdisplay, m.vdisplay)) +} + +// ---- 套用(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 列表拿第一個)。 + 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}")])?; + if !ok { + return Err(format!("wlr-randr --custom-mode failed: {}", stderr.trim())); + } + Ok(name) +} + +/// 跑 wlr-randr 並回收 (stdout, stderr, 成功與否)。 +/// +/// **不能用 `Command::output()`**:supervisor 以 `waitpid(ANY)` 監督服務子行程,會把 +/// 本執行緒 spawn 的 wlr-randr 也搶先收屍——`output()` 內部的 wait 便得 **ECHILD** +/// 而誤報失敗(實機 e2e 抓到:模式已套用、卻記為 failed)。改為自己讀完 stdout/stderr +/// (EOF = 子行程已結束),wait 遇 ECHILD 時以「stderr 是否為空」判定成敗——wlr-randr +/// 失敗必在 stderr 留話(協定缺失、參數錯等),成功則安靜。 +fn run_wlr_randr(args: &[&str]) -> Result<(String, String, bool), String> { + use std::io::Read as _; + let mut child = Command::new("wlr-randr") + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| format!("wlr-randr: {e}"))?; + // 輸出量極小(遠低於 pipe 緩衝),先讀完 stdout 再讀 stderr 不會互相塞住。 + let mut stdout = String::new(); + if let Some(mut out) = child.stdout.take() { + let _ = out.read_to_string(&mut stdout); + } + let mut stderr = String::new(); + if let Some(mut err) = child.stderr.take() { + let _ = err.read_to_string(&mut stderr); + } + let ok = match child.wait() { + Ok(status) => status.success(), + // ECHILD:supervisor 已代收——子行程確定跑完(streams 已 EOF),以 stderr 判定。 + Err(e) if e.raw_os_error() == Some(libc::ECHILD) => stderr.trim().is_empty(), + Err(e) => return Err(format!("wlr-randr wait: {e}")), + }; + Ok((stdout, stderr, ok)) +} + +/// 從 `wlr-randr` 列表輸出取第一個 output 名(頂格行的第一個 token; +/// 縮排行是該 output 的屬性)。 +fn first_output_name(list: &str) -> Option { + list.lines() + .find(|l| !l.is_empty() && !l.starts_with(char::is_whitespace)) + .and_then(|l| l.split_whitespace().next()) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ioctl_codes_match_kernel_abi() { + // 已知常數(linux/drm.h 展開值)——同時守住 struct 大小/佈局不被改壞。 + assert_eq!(DRM_IOCTL_MODE_GETRESOURCES, 0xC040_64A0); + assert_eq!(DRM_IOCTL_MODE_GETCONNECTOR, 0xC050_64A7); + assert_eq!(std::mem::size_of::(), 68); + } + + #[test] + fn uevent_classify() { + // kernel 原生格式:header\0KEY=VAL\0… + let drm_change = + b"change@/devices/pci0000:00/0000:00:01.0/virtio0/drm/card0\0ACTION=change\0DEVPATH=/devices/pci0000:00/0000:00:01.0/virtio0/drm/card0\0SUBSYSTEM=drm\0HOTPLUG=1\0DEVNAME=dri/card0\0"; + assert!(is_drm_change(drm_change)); + // 其他 subsystem 的 change 不觸發。 + assert!(!is_drm_change( + b"change@/devices/virtual/block/loop0\0ACTION=change\0SUBSYSTEM=block\0" + )); + // drm 的 add(開機初始化)不觸發——cage 啟動時自己會拿 preferred mode。 + assert!(!is_drm_change( + b"add@/devices/pci0000:00/0000:00:01.0/virtio0/drm/card0\0ACTION=add\0SUBSYSTEM=drm\0" + )); + // udevd group 2 轉播的 libudev magic header 不匹配。 + assert!(!is_drm_change(b"libudev\0\0\0\x01\0SUBSYSTEM=drm\0")); + assert!(!is_drm_change(b"")); + } + + #[test] + fn preferred_mode_wins_else_first() { + let m = |w: u16, h: u16, typ: u32| DrmModeModeinfo { + hdisplay: w, + vdisplay: h, + typ, + ..Default::default() + }; + // preferred 不在第一個也要選中。 + assert_eq!( + pick_mode(&[ + m(1280, 800, 0), + m(1024, 845, DRM_MODE_TYPE_PREFERRED), + m(640, 480, 0) + ]), + Some((1024, 845)) + ); + // 無 preferred → 第一個。 + assert_eq!( + pick_mode(&[m(800, 600, 0), m(640, 480, 0)]), + Some((800, 600)) + ); + assert_eq!(pick_mode(&[]), None); + // 零尺寸(two-call 競態下沒被複製到的殘留)不採——寧可退第一個可用者或 None。 + assert_eq!( + pick_mode(&[m(0, 0, DRM_MODE_TYPE_PREFERRED), m(1280, 800, 0)]), + Some((1280, 800)) + ); + assert_eq!(pick_mode(&[m(0, 0, 0)]), None); + } + + #[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"; + assert_eq!(first_output_name(listing).as_deref(), Some("Virtual-1")); + // 空輸出(協定沒實作時 wlr-randr 直接錯誤,但守住 None 路徑)。 + assert_eq!(first_output_name(""), None); + assert_eq!(first_output_name(" indented only\n"), None); + } +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f91f290..4314363 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 側,例如讓 compositor 對 virtio-gpu hotplug 換 preferred mode;host 端已無事可做)。**預設取捨(實測後定案)**: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 compositor 支援 re-modeset + HiDPI output scale 再翻預設。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× 放大略軟但尺寸正確、拖拉即時跟手;偏離長寬比會 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 節)。 - **替代(不採用)**: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+。 + - **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 的工具)。 - **嵌入規則**: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 re-modeset 需要「會跟隨線上模式變更」的 compositor(非 cage);裝置/中斷側已就緒,換 compositor 即可啟用。裝置模型/橋接/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-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 cb558e2..3719635 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` 生效),但拖拉時只縮放不 re-modeset(cage 啟動後不跟模式變更,與 WHP 同一 guest 端限制)。結果已回填 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(`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。以下原文保留供重跑參考,**過時處以「實測更正」標註**。 這份是給「拿到一台實體 Mac、要把 chefer 的 macOS(Virtualization.framework / vz)路徑收尾」的接手文件。程式碼側都寫完了,剩下的**只差在真 Mac 上跑一次驗證**——GitHub 的 macOS runner 是巢狀虛擬化、開不了 VZ guest,所以 CI 只能編譯+簽章,端到端一定要實機。 @@ -45,7 +45,7 @@ roadmap 上其餘純硬體項(與 Mac 無關,不在本 handoff):AMD/Inte 腳本只需要 kit 裡這幾個檔(`` = Apple Silicon 為 `aarch64`、Intel Mac 為 `x86_64`): - `chefer-vmlinuz-`、`chefer-initramfs-`(appliance) -- `chefer-gui-overlay-.sqfs`(僅 `--gui` 需要) +- `chefer-gui-overlay-.sqfs`(僅 `--gui` 需要;動態解析度要 **cage ≥0.2.0**=新版 `build-gui-overlay.sh`(Alpine 3.22)建的) - `pasta-`(選用;`--gui` 的 bridge 出網要它,缺了會降級成 internal) ~~**這幾個本輪都沒改**,所以最快是抓 [latest release](https://github.com/TimLai666/chefer/releases/latest) 的 **apple-darwin kit**~~ **實測更正(2026-07)**:≤ v0.4.0 的 release kit **不能**直接用——(a) `chefer-initramfs-aarch64` 內嵌 x86-64 busybox(交叉建置 bug,guest 直接 ENOEXEC panic;已修 `scripts/appliance/build-inside-container.sh`);(b) kernel 缺 `SQUASHFS`/`BLK_DEV_LOOP`(GUI overlay 掛不起來);(c) overlay 是舊 `.tar.zst` 格式(現行 `.sqfs`)。請用 Docker(OrbStack 亦可)從當前原始碼重建 appliance + overlay(指令見下);`pasta-` 可沿用 release kit。 @@ -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. **動態解析度——vz 可能比 WHP 更好,值得實測**:WHP 實測發現 **cage 不會因線上模式變更 re-modeset**,所以 WHP 是「視窗縮放、guest 維持原生解析度」。但 **vz 走的是 `VZVirtualMachineView.automaticallyReconfiguresDisplay`(macOS 14+)**,由 VZ 直接對 guest 的 virtio-gpu 重設組態——這條路**可能真的會 re-modeset**(不像 WHP 只能縮放)。**請在 Mac 上拖拉視窗、看 guest 畫面是「換解析度」還是「拉伸」**,結論回填 DESIGN/README。 +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 閘,拖拉即觸發)。 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/appliance/init b/scripts/appliance/init index 0411882..b99a990 100644 --- a/scripts/appliance/init +++ b/scripts/appliance/init @@ -1,7 +1,11 @@ #!/bin/busybox sh set -eu -PATH=/bin:/sbin:/usr/bin:/usr/sbin +# 必須 export:kernel 給 PID 1 的環境沒有 PATH,不 export 的話 guest-agent 及其子行程 +# 拿不到 PATH——它以 PATH 尋找 GUI overlay 的工具(wl-copy/wlr-randr),沒 PATH 會全部 +# 「not found」而靜默停用剪貼簿與動態解析度(實機 vz 抓到;guest-agent 的 which() 另有 +# 預設路徑退路,讓舊 initramfs 也能運作)。 +export PATH=/bin:/sbin:/usr/bin:/usr/sbin /bin/busybox --install -s /bin >/dev/null 2>&1 || true mountpoint() { diff --git a/scripts/build-gui-overlay.sh b/scripts/build-gui-overlay.sh index 3cd8f3b..a742ad2 100644 --- a/scripts/build-gui-overlay.sh +++ b/scripts/build-gui-overlay.sh @@ -37,12 +37,14 @@ usage() { '用法:scripts/build-gui-overlay.sh --arch [--out ]' \ '' \ '環境變數:' \ -' CHEFER_GUI_CONTAINER 建置容器映像(預設:alpine:3.20;決定 overlay 內套件版本)' >&2 +' CHEFER_GUI_CONTAINER 建置容器映像(預設:alpine:3.22;決定 overlay 內套件版本)' >&2 } main() { local arch="" out_dir="" - local container="${CHEFER_GUI_CONTAINER:-alpine:3.20}" + # 至少 3.21(cage ≥ 0.2.0):動態解析度靠 cage 的 wlr-output-management 協定 + # (guest-agent resize.rs 以 wlr-randr 對它下 custom mode),3.20 的 cage 0.1.5 沒有。 + local container="${CHEFER_GUI_CONTAINER:-alpine:3.22}" while [[ $# -gt 0 ]]; do case "$1" in @@ -68,13 +70,14 @@ main() { # 在容器內:apk --root 安裝到乾淨 staging(自動帶入依賴閉包,含 musl/busybox 基底) # → 精簡(文件/快取)→ tar.zst 輸出。 # 套件選擇(缺一 cage 就起不來,實測依據見 docs/whp-virtio-roadmap.md M8): - # cage kiosk Wayland compositor(wlroots) + # cage kiosk Wayland compositor(wlroots);≥0.2.0 才有 wlr-output-management # xwayland 讓 X11 app 也能顯示(cage 內建整合) # mesa-dri-gallium llvmpipe 軟算繪(VM 無 GPU) # eudev wlroots 的 DRM/libinput 裝置探索需要 udev # xkeyboard-config libxkbcommon 載入鍵盤配置(缺了 cage 啟動即失敗) # wl-clipboard M8-e 剪貼簿同步的 guest 端工具 # seatd seat 管理 daemon(Alpine 的 libseat 沒編 builtin backend,實測必要) + # wlr-randr 動態解析度:guest-agent(resize.rs)對 cage 下 custom mode docker run --rm --platform "$platform" \ -e OUT_ARCH="$arch" \ -v "$out_dir:/out" \ @@ -86,7 +89,8 @@ main() { apk add --root "$root" --initdb --no-cache \ --keys-dir /etc/apk/keys \ --repositories-file /etc/apk/repositories \ - cage xwayland mesa-dri-gallium eudev xkeyboard-config wl-clipboard seatd >/dev/null + cage xwayland mesa-dri-gallium eudev xkeyboard-config wl-clipboard seatd \ + wlr-randr >/dev/null # Xwayland 的 access control 連「同 uid 的本機連線」都要求 cookie(實測 xhost 也被拒, # 而 wlroots 啟動 Xwayland 不帶 -auth、也無法傳額外參數)→ 以 shim 讓 cage 啟動的 # Xwayland 一律帶 -ac 關閉 access control。安全邊界是 micro-VM 本身:VM 內只有 diff --git a/scripts/vz-smoke.sh b/scripts/vz-smoke.sh index 9e906c6..12d9811 100644 --- a/scripts/vz-smoke.sh +++ b/scripts/vz-smoke.sh @@ -41,8 +41,9 @@ done kit_dir="${kit_dir:-${CHEFER_KIT_DIR:-$root/kit}}" [[ -f "$kit_dir/chefer-vmlinuz-$arch" && -f "$kit_dir/chefer-initramfs-$arch" ]] \ || die "kit 缺 macOS appliance($kit_dir/chefer-vmlinuz-$arch / chefer-initramfs-$arch);用 release 的 kit/ 或先跑 scripts/build-appliance.sh" -if [[ "$gui" == 1 && ! -f "$kit_dir/chefer-gui-overlay-$arch.sqfs" ]]; then - die "--gui 需要 kit 內有 chefer-gui-overlay-$arch.sqfs(release kit 已附)" +if [[ "$gui" == 1 && ! -f "$kit_dir/chefer-gui-overlay-$arch.sqfs" ]] \ + && ! command -v docker >/dev/null 2>&1; then + die "--gui 需要 kit 內有 chefer-gui-overlay-$arch.sqfs(release kit 已附),或裝 Docker 讓腳本現建" fi work="$root/dist/vz-smoke" @@ -87,7 +88,19 @@ else cp "$kit_dir/guest-agent-$arch" "$smoke_kit/" 2>/dev/null \ || die "kit 缺 guest-agent-$arch(vz bundle 內嵌用;release kit 已附),且無 cross 可自建" fi -[[ $gui == 1 ]] && cp "$kit_dir/chefer-gui-overlay-$arch.sqfs" "$smoke_kit/" +if [[ $gui == 1 ]]; then + # 動態解析度需要新 overlay(cage ≥0.2.0 + wlr-randr,Alpine 3.22)——舊 release kit 的 + # overlay(cage 0.1.5)沒有 wlr-output-management。有 Docker 就照當前腳本現建, + # 否則沿用 kit 的並提醒該檢核項可能失敗。 + if command -v docker >/dev/null 2>&1; then + note "3b/5 現建 GUI overlay(當前 build-gui-overlay.sh:cage 0.2.0 + wlr-randr)" + bash "$root/scripts/build-gui-overlay.sh" --arch "$arch" --out "$smoke_kit" + else + cp "$kit_dir/chefer-gui-overlay-$arch.sqfs" "$smoke_kit/" + echo "vz-smoke: warning: 無 docker,沿用 kit 的 GUI overlay——若它建自舊版腳本(cage 0.1.x)," >&2 + echo " 「動態解析度」檢核會失敗(console 出現 resize: giving up 或 wlr-randr not found)。" >&2 + fi +fi [[ -f "$kit_dir/pasta-$arch" ]] && cp "$kit_dir/pasta-$arch" "$smoke_kit/" || true platform="linux/$([[ "$arch" == aarch64 ]] && echo arm64 || echo amd64)" @@ -188,9 +201,14 @@ YML [ ] 滑鼠移動/點擊、鍵盤輸入進得了 guest(HID) [ ] host 複製文字 → guest 內可貼上;guest 複製 → host 可貼上(剪貼簿,含 PNG 圖) [ ] 關窗 → app 乾淨結束、行程退出(關窗 = app 結束語意) - (動態解析度已實測收斂,不必再驗:開機時 guest 模式=視窗 Retina 像素尺寸、 - CHEFER_VZ_GUI_SIZE 生效;拖拉時只縮放不 re-modeset——cage 啟動後不跟模式變更, - 與 WHP 同一 guest 端限制,見 DESIGN §6。) + [ ] 動態解析度(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 會縮半。) 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 a5582ce..ee81a19 100644 --- a/vz-helper/main.swift +++ b/vz-helper/main.swift @@ -540,6 +540,31 @@ if guiMode { window.makeKeyAndOrderFront(nil) app.activate(ignoringOtherApps: true) + // 測試鉤子(vz-smoke 自動驗證動態解析度用;僅動態解析度模式有意義): + // CHEFER_VZ_GUI_TEST_RESIZE="<秒>:x" → N 秒後程式化改視窗 content 尺寸。 + // view 尺寸變更後的下游與使用者拖拉完全相同(automaticallyReconfiguresDisplay → + // VZ 對 guest virtio-gpu 重組態 → guest-agent resize watcher 讓 cage re-modeset), + // 讓沒有 Accessibility 權限的自動化環境也能驗證整條鏈。W/H 為 point,Retina 下 + // guest 實際拿到的是像素尺寸(×backingScaleFactor)。 + if #available(macOS 14.0, *), dynamicResolution, + let spec = ProcessInfo.processInfo.environment["CHEFER_VZ_GUI_TEST_RESIZE"] + { + let parts = spec.split(separator: ":", maxSplits: 1) + let dims = parts.count == 2 ? parts[1].lowercased().split(separator: "x") : [] + if parts.count == 2, let delay = Double(parts[0]), + dims.count == 2, let w = Int(dims[0]), let h = Int(dims[1]) + { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + ewrite("chefer-vz-helper: test resize -> \(w)x\(h)\n") + window.setContentSize(NSSize(width: w, height: h)) + } + } else { + ewrite( + "chefer-vz-helper: ignoring invalid CHEFER_VZ_GUI_TEST_RESIZE '\(spec)' (want :x)\n" + ) + } + } + // console tee:pipe → stdout 直通 + 掃 CHEFER_GUEST_IP(首見即起剪貼簿同步)。 var clipboardHost: ClipboardHost? = nil let teePipe = consolePipe! // guiMode 下必建