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:
parent
01820bf5ea
commit
9e083e164a
37
.github/workflows/rust-release.yml
vendored
37
.github/workflows/rust-release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,12 +1020,14 @@ 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(
|
||||
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<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
|
||||
|
|
|
|||
|
|
@ -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)?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue