fix(desktop): recover from unusable native GL on Windows startup

Some Windows 11 machines flash-exit on launch: their WGL OpenGL is the
GDI-generic 1.1 software renderer (no/old GPU driver, VMs, RDP), which
Skia's GL backend rejects. The old WglThenEgl preference never fell
through to EGL because WGL *did* create a (1.1) context; the failure
only surfaced downstream in make_gl, after which new_desktop merely
eprintln'd (invisible under windows_subsystem="windows") and exited —
a silent crash. Not all machines: ones with a real GPU driver get
GL 3.0+ and work, which is why only some users saw it.

- native: new_desktop retries once via forced EGL (ANGLE) when the
  native context can't drive Skia; machines that already work keep the
  WGL path byte-for-byte unchanged.
- desktop: pop a native error dialog on GL-init failure instead of a
  silent exit, so a GPU/driver problem is visible not mysterious.
- packaging/CI: ship arch-matched ANGLE runtime DLLs (libEGL /
  libGLESv2 / d3dcompiler_47) next to the exe, staged from an Electron
  release zip, so glutin's EGL fallback can load them.
This commit is contained in:
Kayshen-X 2026-07-21 21:15:23 +08:00
parent 01820bf5ea
commit 9e083e164a
5 changed files with 189 additions and 11 deletions

View file

@ -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

View file

@ -545,7 +545,7 @@ impl ApplicationHandler<DesktopEvent> 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,7 +1020,9 @@ impl ApplicationHandler<DesktopEvent> 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(
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,
@ -1316,8 +1318,8 @@ impl ApplicationHandler<DesktopEvent> 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

View file

@ -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> {
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> {
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<glutin::display::DisplayApiPreference>,
) -> ProviderResult<Self> {
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)?

View file

@ -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<Self> {
// 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

View file

@ -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"