diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 82744ef2e..7893fc2dc 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -119,6 +119,17 @@ jobs: HAS_APPLE_CERTIFICATE: ${{ secrets.CSC_LINK != '' && secrets.CSC_KEY_PASSWORD != '' && secrets.APPLE_ID != '' && secrets.APPLE_TEAM_ID != '' && secrets.APPLE_APP_SPECIFIC_PASSWORD != '' && 'true' || 'false' }} HAS_WINDOWS_CERTIFICATE: ${{ secrets.WIN_CSC_LINK != '' && secrets.WIN_CSC_KEY_PASSWORD != '' && 'true' || 'false' }} TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} + # ANGLE runtime DLLs (libEGL / libGLESv2 / d3dcompiler_47) shipped + # in the Windows installer so glutin's EGL fallback can drive Skia + # on machines whose native WGL OpenGL is the GDI-generic 1.1 + # software renderer (no/old GPU driver, VMs, RDP) — those were the + # startup flash-exits. We don't build ANGLE from source (heavy, + # brittle); we lift the prebuilt, arch-matched DLLs out of an + # Electron release zip (BSD-licensed ANGLE + MS-redistributable + # d3dcompiler, published at the zip root for x64 + arm64). + # Bump freely; verify the tag exists at + # https://github.com/electron/electron/releases before changing. + ANGLE_ELECTRON_VERSION: v33.0.0 strategy: fail-fast: false matrix: @@ -396,6 +407,32 @@ jobs: } Split-Path $makensis | Out-File -FilePath $env:GITHUB_PATH -Append & $makensis /VERSION + - name: Stage ANGLE DLLs (windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + # Electron's win32 arch token matches our matrix arch (x64 / arm64). + $arch = "${{ matrix.arch }}" + $ver = "$env:ANGLE_ELECTRON_VERSION" + $url = "https://github.com/electron/electron/releases/download/$ver/electron-$ver-win32-$arch.zip" + $zip = Join-Path $env:RUNNER_TEMP "electron-$arch.zip" + $extract = Join-Path $env:RUNNER_TEMP "electron-$arch" + Write-Host "Fetching ANGLE DLLs from $url" + Invoke-WebRequest -Uri $url -OutFile $zip + Expand-Archive -Path $zip -DestinationPath $extract -Force + $dest = "$env:GITHUB_WORKSPACE\target\${{ matrix.target }}\release" + # d3dcompiler_47.dll ships in Win10/11 System32, but bundling it + # keeps ANGLE self-contained across all environments. + $dlls = @("libEGL.dll", "libGLESv2.dll", "d3dcompiler_47.dll") + foreach ($dll in $dlls) { + $src = Join-Path $extract $dll + if (-not (Test-Path $src)) { + throw "ANGLE DLL '$dll' not found in $url — the fallback would ship broken; failing the release." + } + Copy-Item -Path $src -Destination $dest -Force + Write-Host "Staged $dll -> $dest" + } - name: Package NSIS installer (windows) if: runner.os == 'Windows' shell: pwsh diff --git a/crates/op-host-desktop/src/app_handler.rs b/crates/op-host-desktop/src/app_handler.rs index 9573d2a7c..723cc3857 100644 --- a/crates/op-host-desktop/src/app_handler.rs +++ b/crates/op-host-desktop/src/app_handler.rs @@ -545,7 +545,7 @@ impl ApplicationHandler for DesktopApp { } } else { eprintln!( - "openpencil-desktop: ignored dropped file (not .op / .pen / .fig / .html): {}", + "openpencil-desktop: ignored dropped file (not .op / .pen / .fig / .html / .zip): {}", path.display() ); } @@ -1020,12 +1020,14 @@ impl ApplicationHandler for DesktopApp { WindowEvent::CursorMoved { position, .. } => { self.cursor_x = position.x as f32 / self.dpi; self.cursor_y = position.y as f32 / self.dpi; - let over_layer_panel = self.host.cursor_over_layer_panel( - self.cursor_x, - self.cursor_y, - self.viewport_width, - self.viewport_height, - ); + let model_picker_open = self.host.editor_state().editor_ui.chat_model_picker.open; + let over_layer_panel = !model_picker_open + && self.host.cursor_over_layer_panel( + self.cursor_x, + self.cursor_y, + self.viewport_width, + self.viewport_height, + ); if let Some(window) = self.window.as_ref() { // Borderless (Windows / Linux) windows have no OS-provided // edge-resize band — synthesize a resize cursor over the @@ -1316,8 +1318,8 @@ impl ApplicationHandler for DesktopApp { Some(figma_import_session::spawn(&mut self.host, path)); self.request_redraw(true); } - // User picked a saved page; same background - // session discipline as the Figma branch. + // User picked a saved page or ZIP project; same + // background session discipline as the Figma branch. op_host_services::doc_io::ActionOutcome::HtmlImportStarted(path) => { figma_import_session::cancel( &mut self.host, @@ -1589,6 +1591,34 @@ fn render_surface_not_ready(err: &SharedSkiaError) -> bool { ) } +/// Pop a native error dialog when the GL/Skia render context can't be +/// built, so a fatal graphics-init failure is visible instead of a +/// silent flash-exit. This matters most on Windows: the binary is a GUI +/// subsystem app (`windows_subsystem = "windows"`) with no attached +/// console, so `eprintln!` / tracing-to-stderr produce no output on a +/// double-click launch. Bilingual EN/中文 body — the failure happens +/// before the editor locale is meaningfully in play, and it must read +/// for the affected users (typically machines with a missing/old GPU +/// driver or a software-only OpenGL renderer). +fn show_gpu_init_error_dialog(err: &SharedSkiaError) { + let body = format!( + "OpenPencil could not initialize the graphics (OpenGL) backend and has to close.\n\ + 无法初始化图形 (OpenGL) 渲染引擎,程序即将退出。\n\n\ + This usually means the GPU driver is missing or too old, or the machine only \ + has a software renderer (common on virtual machines / remote desktop).\n\ + 通常是显卡驱动缺失或过旧,或运行在只有软件渲染的环境(虚拟机 / 远程桌面)。\n\n\ + Please update your graphics driver and try again.\n\ + 请更新显卡驱动后重试。\n\n\ + Details: {err}" + ); + rfd::MessageDialog::new() + .set_title("OpenPencil — Graphics initialization failed / 图形初始化失败") + .set_description(&body) + .set_level(rfd::MessageLevel::Error) + .set_buttons(rfd::MessageButtons::Ok) + .show(); +} + impl DesktopApp { fn refresh_host_clock(&mut self) { let now_ms = self.clock_start.elapsed().as_millis() as u64; @@ -1675,6 +1705,13 @@ impl DesktopApp { } Err(err) => { eprintln!("openpencil-desktop: SharedSkiaContext::new_desktop failed: {err}"); + // The GUI subsystem has no console (`windows_subsystem = + // "windows"`), so the `eprintln!` above goes nowhere on a + // double-click launch — the app would just flash and + // vanish. Surface the failure in a native dialog so the + // user sees a real reason (GPU driver / OpenGL) instead of + // a mystery crash. + show_gpu_init_error_dialog(&err); self.error = Some(err); event_loop.exit(); false diff --git a/crates/op-host-native/src/context/provider.rs b/crates/op-host-native/src/context/provider.rs index ae05ad883..800a42408 100644 --- a/crates/op-host-native/src/context/provider.rs +++ b/crates/op-host-native/src/context/provider.rs @@ -158,6 +158,41 @@ impl GlutinProvider { /// matches the window's pixel format and stencil bits. #[tracing::instrument(skip_all)] pub fn from_window(window: &winit::window::Window) -> ProviderResult { + Self::from_window_impl(window, None) + } + + /// Windows-only fallback constructor: force glutin onto the EGL + /// display path so it loads ANGLE's `libEGL.dll` (GL ES backed by + /// D3D11) instead of the native WGL ICD. + /// + /// `SharedSkiaContext::new_desktop` calls this ONLY after the primary + /// WGL attempt produced a context Skia's GL backend can't drive — + /// i.e. machines whose WGL exposes just the Microsoft GDI-generic + /// OpenGL 1.1 software renderer (no / stale GPU driver, VMs, RDP). + /// On those machines the earlier `WglThenEgl` preference never fell + /// through to EGL because WGL *did* create a (1.1) context + /// successfully; the failure only surfaces downstream in + /// `skia::direct_contexts::make_gl`. Forcing EGL here routes through + /// ANGLE instead, which reports a usable GL ES 3.0 context. + /// + /// Requires `libEGL.dll` + `libGLESv2.dll` shipped next to the + /// executable (glutin `LoadLibrary`s them from the exe directory); + /// if they are absent EGL display creation fails and this returns + /// `Err`, leaving the caller to surface the original WGL failure. + #[cfg(target_os = "windows")] + #[tracing::instrument(skip_all)] + pub fn from_window_forcing_egl(window: &winit::window::Window) -> ProviderResult { + Self::from_window_impl(window, Some(glutin::display::DisplayApiPreference::Egl)) + } + + /// Shared builder for [`from_window`] and (Windows) the EGL/ANGLE + /// fallback. `preference_override` bypasses [`pick_display_api`] so + /// the fallback can pin the display API. + #[tracing::instrument(skip_all)] + fn from_window_impl( + window: &winit::window::Window, + preference_override: Option, + ) -> ProviderResult { use glutin::config::ConfigTemplateBuilder; use glutin::context::ContextAttributesBuilder; use glutin::display::{Display, GetGlDisplay}; @@ -176,7 +211,9 @@ impl GlutinProvider { // Per-platform display preference (glutin-winit applies the same // matrix internally, but we go direct to avoid the sealed trait). - let preference = pick_display_api(raw_window_handle); + // A caller-supplied override wins so the Windows ANGLE fallback + // can pin EGL. + let preference = preference_override.unwrap_or_else(|| pick_display_api(raw_window_handle)); let gl_display = unsafe { Display::new(raw_display_handle, preference).map_err(ProviderError::from_error)? diff --git a/crates/op-host-native/src/context/shared.rs b/crates/op-host-native/src/context/shared.rs index 132332684..f8a45cc32 100644 --- a/crates/op-host-native/src/context/shared.rs +++ b/crates/op-host-native/src/context/shared.rs @@ -121,8 +121,60 @@ impl SharedSkiaContext { /// matches the window's framebuffer dimensions. #[tracing::instrument(skip_all)] pub fn new_desktop(window: &winit::window::Window) -> SharedSkiaResult { + // Primary path: the per-platform default display API (native WGL + // on Windows). Machines with a real GPU driver keep this exact + // render path — no behaviour change. let provider = GlutinProvider::from_window(window)?; - Self::new(provider) + match Self::new(provider) { + Ok(ctx) => Ok(ctx), + Err(err) => { + // On Windows a "successful" WGL context can still be the + // GDI-generic OpenGL 1.1 software renderer, which Skia's + // GL backend rejects (`make_gl` → None). The old + // `WglThenEgl` preference never caught this because WGL + // *did* create a context; the failure is downstream in + // Skia. Retry once forcing EGL so glutin routes through + // ANGLE (GL ES / D3D11), which those machines can drive. + // + // NOTE (unverified on Windows from this workstation): the + // HWND pixel format is set once by the first WGL surface; + // ANGLE's D3D11 backend does not consume the GL pixel + // format, so reusing the same window is expected to work, + // but must be smoke-tested on an affected machine. If it + // proves flaky the fallback is window re-creation. + #[cfg(target_os = "windows")] + { + if Self::gl_backend_unavailable(&err) { + tracing::warn!( + ?err, + "native WGL context cannot drive Skia; retrying via EGL/ANGLE" + ); + if let Ok(egl_provider) = GlutinProvider::from_window_forcing_egl(window) { + return Self::new(egl_provider); + } + tracing::warn!( + "EGL/ANGLE fallback unavailable (libEGL.dll missing?); \ + surfacing original WGL failure" + ); + } + } + Err(err) + } + } + } + + /// Whether a context-construction error stems from the GL backend + /// being unusable (as opposed to a transient not-ready surface), + /// making an EGL/ANGLE retry worthwhile. Windows-only — the retry + /// site is `#[cfg(target_os = "windows")]`. + #[cfg(target_os = "windows")] + fn gl_backend_unavailable(err: &SharedSkiaError) -> bool { + matches!( + err, + SharedSkiaError::GlInterface + | SharedSkiaError::DirectContext + | SharedSkiaError::Surface + ) } /// Begin a frame. Pure marker hook for now — chrome / canvas viewport diff --git a/scripts/package-windows.nsi b/scripts/package-windows.nsi index 400f26c4a..ec68d03fb 100644 --- a/scripts/package-windows.nsi +++ b/scripts/package-windows.nsi @@ -97,6 +97,21 @@ Section "OpenPencil" SecMain File "${BIN_DIR}\${CLI_NAME}" File "/oname=openpencil.ico" "${ICON_FILE}" + ; ANGLE fallback DLLs (libEGL.dll + libGLESv2.dll, optionally + ; d3dcompiler_47.dll). Installed next to the exe so glutin's EGL path + ; loads them when the native WGL OpenGL context can't drive Skia — the + ; machines that were flash-exiting on startup (no/old GPU driver, + ; software-only OpenGL, VMs, RDP). See + ; `SharedSkiaContext::new_desktop` for the fallback wiring. + ; + ; `/nonfatal`: the release/CI build must stage these DLLs into + ; ${BIN_DIR} (matching the target arch) before running makensis. Until + ; that step exists the installer still builds (just without the + ; fallback), so packaging never hard-breaks on a missing DLL. + File /nonfatal "${BIN_DIR}\libEGL.dll" + File /nonfatal "${BIN_DIR}\libGLESv2.dll" + File /nonfatal "${BIN_DIR}\d3dcompiler_47.dll" + WriteUninstaller "$INSTDIR\Uninstall.exe" WriteRegStr HKLM "${REG_APP_KEY}" "InstallDir" "$INSTDIR"