Forráskód Böngészése

added support for web wallpapers, prevented memory leaks

UwU 1 hónapja
szülő
commit
6252828192
42 módosított fájl, 1109 hozzáadás és 286 törlés
  1. 4 2
      CMakeLists.txt
  2. 44 5
      src/WallpaperEngine/Application/ApplicationContext.cpp
  3. 13 0
      src/WallpaperEngine/Application/ApplicationContext.h
  4. 198 28
      src/WallpaperEngine/Application/WallpaperApplication.cpp
  5. 13 1
      src/WallpaperEngine/Application/WallpaperApplication.h
  6. 0 5
      src/WallpaperEngine/Audio/AudioStream.cpp
  7. 14 1
      src/WallpaperEngine/Audio/Drivers/SDLAudioDriver.cpp
  8. 22 13
      src/WallpaperEngine/Render/CWallpaper.cpp
  9. 13 6
      src/WallpaperEngine/Render/CWallpaper.h
  10. 3 3
      src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.cpp
  11. 1 0
      src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.h
  12. 8 2
      src/WallpaperEngine/Render/Drivers/Output/X11Output.cpp
  13. 24 1
      src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.cpp
  14. 17 7
      src/WallpaperEngine/Render/Objects/CParticle.cpp
  15. 11 0
      src/WallpaperEngine/Render/Objects/CParticle.h
  16. 46 1
      src/WallpaperEngine/Render/Objects/Effects/CPass.cpp
  17. 8 2
      src/WallpaperEngine/Render/Objects/Effects/CPass.h
  18. 3 0
      src/WallpaperEngine/Render/Wallpapers/CVideo.cpp
  19. 3 0
      src/WallpaperEngine/Render/Wallpapers/CVideo.h
  20. 196 66
      src/WallpaperEngine/Render/Wallpapers/CWeb.cpp
  21. 25 15
      src/WallpaperEngine/Render/Wallpapers/CWeb.h
  22. 1 1
      src/WallpaperEngine/Scripting/Adapters/VectorAdapter.cpp
  23. 9 0
      src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.cpp
  24. 2 0
      src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.h
  25. 16 6
      src/WallpaperEngine/WebBrowser/CEF/BrowserApp.cpp
  26. 68 2
      src/WallpaperEngine/WebBrowser/CEF/BrowserClient.cpp
  27. 39 3
      src/WallpaperEngine/WebBrowser/CEF/BrowserClient.h
  28. 0 27
      src/WallpaperEngine/WebBrowser/CEF/RenderHandler.cpp
  29. 0 43
      src/WallpaperEngine/WebBrowser/CEF/RenderHandler.h
  30. 41 0
      src/WallpaperEngine/WebBrowser/CEF/SharedMemoryRenderHandler.cpp
  31. 31 0
      src/WallpaperEngine/WebBrowser/CEF/SharedMemoryRenderHandler.h
  32. 7 18
      src/WallpaperEngine/WebBrowser/CEF/SubprocessApp.cpp
  33. 0 2
      src/WallpaperEngine/WebBrowser/CEF/SubprocessApp.h
  34. 14 10
      src/WallpaperEngine/WebBrowser/CEF/WPSchemeHandler.cpp
  35. 2 1
      src/WallpaperEngine/WebBrowser/CEF/WPSchemeHandler.h
  36. 35 5
      src/WallpaperEngine/WebBrowser/CEF/WPSchemeHandlerFactory.cpp
  37. 10 9
      src/WallpaperEngine/WebBrowser/CEF/WPSchemeHandlerFactory.h
  38. 87 0
      src/WallpaperEngine/WebBrowser/IPC/WebHostSharedMemory.cpp
  39. 68 0
      src/WallpaperEngine/WebBrowser/IPC/WebHostSharedMemory.h
  40. 1 0
      src/WallpaperEngine/WebBrowser/WebBrowserContext.cpp
  41. 4 1
      src/WallpaperEngine/WebBrowser/WebBrowserContext.h
  42. 8 0
      src/main.cpp

+ 4 - 2
CMakeLists.txt

@@ -464,8 +464,8 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Render/Objects/Effects/CPass.h
     src/WallpaperEngine/Render/Objects/Effects/CPass.cpp
 
-    src/WallpaperEngine/WebBrowser/CEF/RenderHandler.cpp
-    src/WallpaperEngine/WebBrowser/CEF/RenderHandler.h
+    src/WallpaperEngine/WebBrowser/CEF/SharedMemoryRenderHandler.cpp
+    src/WallpaperEngine/WebBrowser/CEF/SharedMemoryRenderHandler.h
     src/WallpaperEngine/WebBrowser/CEF/BrowserClient.cpp
     src/WallpaperEngine/WebBrowser/CEF/BrowserClient.h
     src/WallpaperEngine/WebBrowser/CEF/BrowserApp.cpp
@@ -478,6 +478,8 @@ set(COMMON_SOURCES
     src/WallpaperEngine/WebBrowser/CEF/WPSchemeHandler.h
     src/WallpaperEngine/WebBrowser/WebBrowserContext.cpp
     src/WallpaperEngine/WebBrowser/WebBrowserContext.h
+    src/WallpaperEngine/WebBrowser/IPC/WebHostSharedMemory.cpp
+    src/WallpaperEngine/WebBrowser/IPC/WebHostSharedMemory.h
 
     src/WallpaperEngine/Data/Assets/Types.h
     src/WallpaperEngine/Data/Assets/Texture.h

+ 44 - 5
src/WallpaperEngine/Application/ApplicationContext.cpp

@@ -6,6 +6,7 @@
 #include "WallpaperEngine/Render/CFBO.h"
 
 #include <algorithm>
+#include <charconv>
 #include <cstdlib>
 #include <cstring>
 #include <fstream>
@@ -251,11 +252,11 @@ bool matchesObjectToken (const std::string& token, int id, const std::string& na
 	return true;
     }
 
-    try {
-	return std::stoi (token) == id;
-    } catch (const std::exception&) {
-	return false;
-    }
+    int parsed = 0;
+    const auto* end = token.data () + token.size ();
+    const auto result = std::from_chars (token.data (), end, parsed);
+
+    return result.ec == std::errc () && result.ptr == end && parsed == id;
 }
 } // namespace
 
@@ -292,6 +293,44 @@ void ApplicationContext::loadSettingsFromArgv () {
 	    }
 	});
 
+    // Internal, not advertised in --help. Appended to CEF subprocess re-execs so they see the
+    // *current* (possibly hotswapped) background instead of the "background id" positional's
+    // launch-time value. Registered last so it takes precedence.
+    backgroundGroup.add_argument ("--current-background")
+	.default_value ("")
+	.hidden ()
+	.action ([this] (const std::string& value) -> void {
+	    if (!value.empty ()) {
+		this->settings.general.defaultBackground = translateBackground (value);
+	    }
+	});
+
+    // Internal, not advertised in --help: marks a self-re-exec as a disposable CEF host for one
+    // Web wallpaper.
+    backgroundGroup.add_argument ("--web-host")
+	.flag ()
+	.hidden ()
+	.store_into (this->settings.general.webHost);
+
+    backgroundGroup.add_argument ("--web-host-shm")
+	.default_value ("")
+	.hidden ()
+	.store_into (this->settings.general.webHostShm);
+
+    backgroundGroup.add_argument ("--web-host-width")
+	.default_value (0)
+	.hidden ()
+	.action ([this] (const std::string& value) -> void {
+	    this->settings.general.webHostWidth = static_cast<uint32_t> (std::stoul (value));
+	});
+
+    backgroundGroup.add_argument ("--web-host-height")
+	.default_value (0)
+	.hidden ()
+	.action ([this] (const std::string& value) -> void {
+	    this->settings.general.webHostHeight = static_cast<uint32_t> (std::stoul (value));
+	});
+
     backgroundMode.add_argument ("-w", "--window")
 	.help ("Window geometry to use for the given screen")
 	.action ([this] (const std::string& value) -> void {

+ 13 - 0
src/WallpaperEngine/Application/ApplicationContext.h

@@ -105,6 +105,15 @@ public:
 	    std::optional<PlaylistDefinition> defaultPlaylist;
 	    /** Span groups: multiple monitors sharing one stretched wallpaper */
 	    std::vector<SpanGroup> spanGroups;
+	    /**
+	     * Internal, not advertised in --help: marks this process as a disposable CEF host for a
+	     * single Web wallpaper instead of embedding CEF in the main engine process. Requires
+	     * webHostShm/Width/Height.
+	     */
+	    bool webHost;
+	    std::string webHostShm;
+	    uint32_t webHostWidth;
+	    uint32_t webHostHeight;
 	} general;
 
 	// Render settings
@@ -188,6 +197,10 @@ public:
             .screenPlaylists = {},
             .defaultPlaylist = std::nullopt,
             .spanGroups = {},
+            .webHost = false,
+            .webHostShm = "",
+            .webHostWidth = 0,
+            .webHostHeight = 0,
         },
         .render = {
             .mode = NORMAL_WINDOW,

+ 198 - 28
src/WallpaperEngine/Application/WallpaperApplication.cpp

@@ -1,5 +1,7 @@
 #include "WallpaperApplication.h"
 
+#include <cstdlib>
+
 #include "Steam/FileSystem/FileSystem.h"
 #include "WallpaperEngine/Application/ApplicationState.h"
 #include "WallpaperEngine/Assets/AssetLoadException.h"
@@ -19,6 +21,11 @@
 #include "WallpaperEngine/FileSystem/Adapters/MediaCover.h"
 #include "WallpaperEngine/Media/DBusMediaSource.h"
 
+#include "WallpaperEngine/WebBrowser/CEF/BrowserClient.h"
+#include "WallpaperEngine/WebBrowser/CEF/SharedMemoryRenderHandler.h"
+#include "WallpaperEngine/WebBrowser/IPC/WebHostSharedMemory.h"
+#include "include/cef_browser.h"
+
 #if DEMOMODE
 #include "recording.h"
 #endif /* DEMOMODE */
@@ -29,6 +36,7 @@
 #include <csignal>
 #include <fstream>
 #include <numeric>
+#include <string_view>
 #include <unistd.h>
 #define STB_IMAGE_WRITE_IMPLEMENTATION
 #include <stb_image_write.h>
@@ -38,6 +46,8 @@
 
 float g_Time;
 float g_TimeLast;
+/** Unscaled wall-clock time, unaffected by --speed */
+float g_RealTime;
 float g_Daytime;
 
 using namespace WallpaperEngine::Assets;
@@ -66,9 +76,34 @@ void CustomGLDebugCallback (
     }
 }
 
+bool WallpaperApplication::isCefSubprocess () const {
+    for (int i = 1; i < this->m_context.getArgc (); i++) {
+	if (std::string_view (this->m_context.getArgv ()[i]).starts_with ("--type=")) {
+	    return true;
+	}
+    }
+
+    return false;
+}
+
 WallpaperApplication::WallpaperApplication (ApplicationContext& context) : m_context (context) {
     this->initializeSubsystems ();
-    this->loadBackgrounds ();
+
+    try {
+	this->loadBackgrounds ();
+    } catch (const std::exception& e) {
+	if (!this->isCefSubprocess ()) {
+	    throw;
+	}
+
+	// CEF re-execs this binary for its own subprocesses with a reconstructed argv that doesn't
+	// always resolve to a loadable background. Must still reach setupBrowser() below regardless
+	// - that's what calls CefExecuteProcess() to hand off into Chromium's subprocess entrypoint;
+	// skipping it here leaves CEF's IPC handshake incomplete, which reads as a crashed subprocess
+	// and gets retried, turning one web wallpaper into several stray processes.
+	sLog.error ("Skipping background load for CEF subprocess re-exec: ", e.what ());
+    }
+
     this->setupProperties ();
     this->listObjects ();
     this->setupBrowser ();
@@ -317,16 +352,6 @@ void WallpaperApplication::initializePlaylists () {
     }
 }
 
-void WallpaperApplication::ensureBrowserForProject (const Project& project) {
-    if (!project.wallpaper->is<Web> ()) {
-	return;
-    }
-
-    if (!this->m_browserContext) {
-	this->m_browserContext = std::make_unique<WebBrowser::WebBrowserContext> (*this);
-    }
-}
-
 bool WallpaperApplication::makeAnyViewportCurrent () const {
     if (!this->m_renderContext) {
 	return false;
@@ -431,7 +456,6 @@ void WallpaperApplication::advancePlaylist (
 	auto project = this->loadBackground (nextPath.string ());
 
 	this->setupPropertiesForProject (*project);
-	this->ensureBrowserForProject (*project);
 
 	this->m_backgrounds[screen] = std::move (project);
 
@@ -446,8 +470,8 @@ void WallpaperApplication::advancePlaylist (
 
 	if (this->m_renderContext) {
 	    auto wallpaper = WallpaperEngine::Render::CWallpaper::fromWallpaper (
-		*this->m_backgrounds[screen]->wallpaper, *this->m_renderContext, *this->m_audioContext,
-		this->m_browserContext.get (), scaling, clamp
+		*this->m_backgrounds[screen]->wallpaper, *this->m_renderContext, *this->m_audioContext, nextPath,
+		scaling, clamp, this->resolveScreenRenderSize (screen)
 	    );
 	    wallpaper->setZoom (this->resolveScreenZoom (screen));
 	    wallpaper->setCornerColor (this->resolveScreenCornerColor (screen));
@@ -708,7 +732,6 @@ void WallpaperApplication::checkHotswapRequest () {
 	    auto project = this->loadBackground (targetPath);
 
 	    this->setupPropertiesForProject (*project);
-	    this->ensureBrowserForProject (*project);
 
 	    background = std::move (project);
 
@@ -723,8 +746,8 @@ void WallpaperApplication::checkHotswapRequest () {
 
 	    if (this->m_renderContext) {
 		auto wallpaper = WallpaperEngine::Render::CWallpaper::fromWallpaper (
-		    *background->wallpaper, *this->m_renderContext, *this->m_audioContext,
-		    this->m_browserContext.get (), scaling, clamp
+		    *background->wallpaper, *this->m_renderContext, *this->m_audioContext, targetPath, scaling, clamp,
+		    this->resolveScreenRenderSize (screen)
 		);
 		wallpaper->setZoom (this->resolveScreenZoom (screen));
 		wallpaper->setCornerColor (this->resolveScreenCornerColor (screen));
@@ -788,6 +811,17 @@ glm::vec4 WallpaperApplication::resolveScreenCornerColor (const std::string& scr
 	: this->m_context.settings.render.window.cornerColor;
 }
 
+glm::ivec2 WallpaperApplication::resolveScreenRenderSize (const std::string& screen) const {
+    const auto& viewports = this->m_renderContext->getOutput ().getViewports ();
+    const auto it = viewports.find (screen);
+
+    if (it != viewports.end ()) {
+	return { it->second->viewport.z, it->second->viewport.w };
+    }
+
+    return { this->m_renderContext->getOutput ().getFullWidth (), this->m_renderContext->getOutput ().getFullHeight () };
+}
+
 void WallpaperApplication::applyVolumeHotswap (int volume) {
     volume = std::max (0, std::min (volume, 128));
 
@@ -947,6 +981,16 @@ void WallpaperApplication::applySpeedHotswap (const std::string& value) {
 
     this->m_context.settings.render.playbackSpeed = speed;
 
+    // Particles/effects/scripts read settings.render.playbackSpeed directly every frame, but
+    // video wallpapers are driven by mpv's own clock and need the change pushed explicitly.
+    if (this->m_renderContext) {
+	for (const auto& [screen, wallpaper] : this->m_renderContext->getWallpapers ()) {
+	    if (wallpaper->is<WallpaperEngine::Render::Wallpapers::CVideo> ()) {
+		wallpaper->as<WallpaperEngine::Render::Wallpapers::CVideo> ()->setSpeed (speed);
+	    }
+	}
+    }
+
     sLog.out ("Hotswap: applied speed ", speed, " live");
 }
 
@@ -1009,20 +1053,131 @@ void WallpaperApplication::listObjects () const {
 }
 
 void WallpaperApplication::setupBrowser () {
-    bool anyWebProject = std::any_of (
-	this->m_backgrounds.begin (), this->m_backgrounds.end (),
-	[] (const std::pair<const std::string, ProjectUniquePtr>& pair) -> bool {
-	    return pair.second->wallpaper->is<Web> ();
-	}
-    );
-
-    if (!anyWebProject || this->m_browserContext) {
+    // The main engine process never hosts CEF directly - CEF only supports one
+    // CefInitialize()/CefShutdown() pair per process, so a process that might later need to stop
+    // and restart hosting a web wallpaper can't safely do it in place. Only two roles reach here:
+    // CEF's own subprocess re-execs (--type=zygote/gpu-process/renderer/utility), which must
+    // always reach WebBrowserContext even if loadBackgrounds() found nothing to load, and this
+    // process's own --web-host role.
+    if ((!this->isCefSubprocess () && !this->m_context.settings.general.webHost) || this->m_browserContext) {
 	return;
     }
 
     this->m_browserContext = std::make_unique<WebBrowser::WebBrowserContext> (*this);
 }
 
+void WallpaperApplication::runWebHost () {
+    using namespace WallpaperEngine::WebBrowser;
+    using namespace WallpaperEngine::WebBrowser::IPC;
+
+    const auto backgroundIt = this->m_backgrounds.find ("default");
+
+    if (backgroundIt == this->m_backgrounds.end () || !backgroundIt->second->wallpaper->is<Web> ()) {
+	sLog.error ("--web-host requires a single Web wallpaper background, none found");
+	return;
+    }
+
+    if (!this->m_browserContext) {
+	sLog.error ("--web-host: CEF was not initialized (setupBrowser() found no Web project?)");
+	return;
+    }
+
+    const Project& project = *backgroundIt->second;
+    const Web& web = *project.wallpaper->as<Web> ();
+    const int width = static_cast<int> (this->m_context.settings.general.webHostWidth);
+    const int height = static_cast<int> (this->m_context.settings.general.webHostHeight);
+
+    if (width <= 0 || height <= 0) {
+	sLog.error ("--web-host: invalid --web-host-width/--web-host-height");
+	return;
+    }
+
+    auto* shm = attachSharedMemory (this->m_context.settings.general.webHostShm, width, height);
+
+    if (shm == nullptr) {
+	sLog.error ("--web-host: failed to attach shared memory ", this->m_context.settings.general.webHostShm);
+	return;
+    }
+
+    CefWindowInfo windowInfo;
+    windowInfo.SetAsWindowless (0);
+
+    CefBrowserSettings browserSettings;
+    browserSettings.windowless_frame_rate = std::max (60, this->m_context.settings.render.maximumFPS);
+
+    const CefRefPtr<CEF::SharedMemoryRenderHandler> renderHandler = new CEF::SharedMemoryRenderHandler (shm);
+    const CefRefPtr<CEF::BrowserClient> client = new CEF::BrowserClient (renderHandler, project.properties);
+
+    // the "w" prefix + workshop id host must match what the scheme handler factory expects to resolve
+    const std::string htmlURL = std::string (WPENGINE_SCHEME) + "://w" + project.workshopId + "/" + web.filename;
+
+    sLog.out ("--web-host: creating browser for ", htmlURL, " (pid ", static_cast<long> (getpid ()), ")");
+
+    const CefRefPtr<CefBrowser> browser
+	= CefBrowserHost::CreateBrowserSync (windowInfo, client, htmlURL, browserSettings, nullptr, nullptr);
+
+    if (!browser) {
+	sLog.error ("--web-host: failed to create the CEF browser");
+	shm->helperFailed.store (true, std::memory_order_release);
+	closeSharedMemory (shm, this->m_context.settings.general.webHostShm, width, height, false);
+	return;
+    }
+
+    shm->helperReady.store (true, std::memory_order_release);
+
+    Input::MouseClickStatus lastLeft = Input::Released;
+    Input::MouseClickStatus lastRight = Input::Released;
+    uint32_t lastDesiredWidth = shm->desiredWidth.load (std::memory_order_relaxed);
+    uint32_t lastDesiredHeight = shm->desiredHeight.load (std::memory_order_relaxed);
+
+    while (!shm->quitRequested.load (std::memory_order_acquire)) {
+	CefDoMessageLoopWork ();
+
+	const uint32_t desiredWidth = shm->desiredWidth.load (std::memory_order_relaxed);
+	const uint32_t desiredHeight = shm->desiredHeight.load (std::memory_order_relaxed);
+
+	if (desiredWidth != lastDesiredWidth || desiredHeight != lastDesiredHeight) {
+	    lastDesiredWidth = desiredWidth;
+	    lastDesiredHeight = desiredHeight;
+	    browser->GetHost ()->WasResized ();
+	}
+
+	CefMouseEvent evt;
+	evt.x = static_cast<int> (shm->mouseX.load (std::memory_order_relaxed));
+	evt.y = static_cast<int> (shm->mouseY.load (std::memory_order_relaxed));
+	browser->GetHost ()->SendMouseMoveEvent (evt, false);
+
+	const auto left = static_cast<Input::MouseClickStatus> (shm->leftClick.load (std::memory_order_relaxed));
+	const auto right = static_cast<Input::MouseClickStatus> (shm->rightClick.load (std::memory_order_relaxed));
+
+	if (left != lastLeft) {
+	    browser->GetHost ()->SendMouseClickEvent (
+		evt, CefBrowserHost::MouseButtonType::MBT_LEFT, left == Input::Released, 1
+	    );
+	    lastLeft = left;
+	}
+
+	if (right != lastRight) {
+	    browser->GetHost ()->SendMouseClickEvent (
+		evt, CefBrowserHost::MouseButtonType::MBT_RIGHT, right == Input::Released, 1
+	    );
+	    lastRight = right;
+	}
+
+	std::this_thread::sleep_for (std::chrono::milliseconds (4));
+    }
+
+    browser->GetHost ()->CloseBrowser (true);
+
+    // CloseBrowser() only requests an async close - wait briefly for it to finish before
+    // CefShutdown() below tears everything down out from under it
+    for (int i = 0; i < 500 && !client->isClosed (); i++) {
+	CefDoMessageLoopWork ();
+    }
+
+    closeSharedMemory (shm, this->m_context.settings.general.webHostShm, width, height, false);
+}
+
 void WallpaperApplication::takeScreenshot (const std::filesystem::path& filename) const {
     const int width = this->m_renderContext->getOutput ().getFullWidth ();
     const int height = this->m_renderContext->getOutput ().getFullHeight ();
@@ -1215,7 +1370,8 @@ void WallpaperApplication::prepareOutputs () {
 	    : this->m_context.settings.render.window.clamp;
 
 	auto wallpaper = WallpaperEngine::Render::CWallpaper::fromWallpaper (
-	    *info->wallpaper, *m_renderContext, *m_audioContext, m_browserContext.get (), scaling, clamp
+	    *info->wallpaper, *m_renderContext, *m_audioContext, this->resolveScreenBackgroundPath (background),
+	    scaling, clamp, this->resolveScreenRenderSize (background)
 	);
 	wallpaper->setZoom (this->resolveScreenZoom (background));
 	wallpaper->setCornerColor (this->resolveScreenCornerColor (background));
@@ -1275,8 +1431,8 @@ void WallpaperApplication::prepareOutputs () {
 
 	// Create one shared wallpaper with the span group's scaling mode
 	auto sharedWallpaper = WallpaperEngine::Render::CWallpaper::fromWallpaper (
-	    *bgIt->second->wallpaper, *m_renderContext, *m_audioContext, m_browserContext.get (), spanGroup.scaling,
-	    spanGroup.clamp
+	    *bgIt->second->wallpaper, *m_renderContext, *m_audioContext, this->resolveScreenBackgroundPath (groupKey),
+	    spanGroup.scaling, spanGroup.clamp, glm::ivec2 { maxX - minX, maxY - minY }
 	);
 	sharedWallpaper->setZoom (spanGroup.zoom);
 	sharedWallpaper->setCornerColor (spanGroup.cornerColor);
@@ -1301,6 +1457,19 @@ void WallpaperApplication::setupOpenGLDebugging () {
 
 void WallpaperApplication::setup () {
     this->setupOutput ();
+
+    // we_manager launches us with LD_PRELOAD forcing the system's libEGL, because CEF's own
+    // bundled libEGL sits next to us on LD_LIBRARY_PATH and would otherwise shadow it and break
+    // our own Wayland/EGL output. That preload has already done its job for our own process by
+    // this point (setupOutput() above just created our GL context with it) - but it stays in the
+    // environment we inherit, and CEF's own subprocesses (spawned lazily, e.g. the GPU process
+    // the moment a web wallpaper's WebGL content first needs one) would inherit it too, forcing
+    // Mesa's system EGL onto CEF's ANGLE stack instead of the bundled EGL it actually expects.
+    // That silently breaks WebGL - no crash, no error, just a canvas that never paints anything.
+    // Unsetting it here only affects processes we fork+exec after this point; it doesn't undo
+    // anything already loaded into our own process by the dynamic linker at our own startup.
+    unsetenv ("LD_PRELOAD");
+
     this->setupAudio ();
     this->prepareOutputs ();
     this->setupOpenGLDebugging ();
@@ -1364,6 +1533,7 @@ void WallpaperApplication::render () {
 
 	g_TimeLast = g_Time;
 	g_Time += rawDelta * this->m_context.settings.render.playbackSpeed;
+	g_RealTime = rawTimeNow;
 	m_audioDriver->update ();
 	m_mediaSource->update ();
 	m_videoDriver->getInputContext ().update ();

+ 13 - 1
src/WallpaperEngine/Application/WallpaperApplication.h

@@ -37,6 +37,13 @@ public:
     void show ();
     void signal (int signal);
 
+    /**
+     * Entry point for a disposable CEF host child process (settings.general.webHost) instead of
+     * embedding CEF in the main engine process. Never touches Wayland/GL/audio. Runs until the
+     * main process signals quit through shared memory (or this process is killed).
+     */
+    void runWebHost ();
+
     [[nodiscard]] const std::map<std::string, ProjectUniquePtr>& getBackgrounds () const;
     [[nodiscard]] ApplicationContext& getContext () const;
     void update (Render::Drivers::Output::OutputViewport* viewport);
@@ -85,9 +92,11 @@ private:
     bool selectNextCandidate (ActivePlaylist& playlist, std::size_t& outOrderIndex);
     bool preflightWallpaper (const std::string& path);
     std::vector<std::size_t> buildPlaylistOrder (const ApplicationContext::PlaylistDefinition& definition);
-    void ensureBrowserForProject (const Project& project);
     bool makeAnyViewportCurrent () const;
 
+    /** True if this process was re-exec'd by CEF as a subprocess helper (--type=renderer/gpu-process/...) */
+    bool isCefSubprocess () const;
+
     /**
      * Pushes a volume change live to already-running video players and the SDL audio mixer,
      * without touching the loaded projects. volume is 0-128, matching --volume.
@@ -138,6 +147,9 @@ private:
     [[nodiscard]] std::string resolveScreenBackgroundPath (const std::string& screen) const;
     [[nodiscard]] float resolveScreenZoom (const std::string& screen) const;
     [[nodiscard]] glm::vec4 resolveScreenCornerColor (const std::string& screen) const;
+    // The resolution a Web wallpaper on this screen will actually be rendered at - falls back to
+    // the combined bounding box of every active screen if this one isn't registered yet.
+    [[nodiscard]] glm::ivec2 resolveScreenRenderSize (const std::string& screen) const;
 
     ApplicationContext& m_context;
     std::map<std::string, ProjectUniquePtr> m_backgrounds {};

+ 0 - 5
src/WallpaperEngine/Audio/AudioStream.cpp

@@ -150,11 +150,6 @@ AudioStream::~AudioStream () {
 
     this->m_audioThread = nullptr;
 
-    if (this->m_queue != nullptr) {
-	// wait for the audio buffers to be done
-	SDL_CondWait (this->m_queue->wait, this->m_queue->mutex);
-    }
-
     if (this->m_swrctx != nullptr && swr_is_initialized (this->m_swrctx) == true) {
 	swr_close (this->m_swrctx);
     }

+ 14 - 1
src/WallpaperEngine/Audio/Drivers/SDLAudioDriver.cpp

@@ -130,7 +130,20 @@ int SDLAudioDriver::addStream (AudioStream* stream) {
 
     return newStreamId;
 }
-void SDLAudioDriver::removeStream (int streamId) { this->m_streams.erase (streamId); }
+void SDLAudioDriver::removeStream (int streamId) {
+    // must hold the same lock the SDL audio callback thread holds while it iterates m_streams and
+    // calls into each stream (decodeFrame, isQueueEmpty, ...) - without it, erasing here can race
+    // the callback's map iteration (heap corruption) and the caller may go on to delete the
+    // AudioStream while the callback thread is still using it
+    SDL_LockMutex (this->m_streamListMutex);
+
+    if (const auto it = this->m_streams.find (streamId); it != this->m_streams.end ()) {
+	delete it->second;
+	this->m_streams.erase (it);
+    }
+
+    SDL_UnlockMutex (this->m_streamListMutex);
+}
 
 const std::map<int, SDLAudioBuffer*>& SDLAudioDriver::getStreams () { return this->m_streams; }
 

+ 22 - 13
src/WallpaperEngine/Render/CWallpaper.cpp

@@ -26,13 +26,21 @@ CWallpaper::CWallpaper (
     constexpr GLfloat position[] = { -1.0f, 1.0f,  0.0f, 1.0,  1.0f, 0.0f, -1.0f, -1.0f, 0.0f,
 				     -1.0f, -1.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f,  -1.0f, 0.0f };
 
+    // GL_DYNAMIC_DRAW: render() rewrites this buffer's contents (via glBufferSubData) whenever
+    // the wallpaper's UVs change. The attrib pointer/enable state below is captured by the VAO
+    // once here rather than redone every render() call - only the buffer's contents change later.
     glGenBuffers (1, &this->m_texCoordBuffer);
     glBindBuffer (GL_ARRAY_BUFFER, this->m_texCoordBuffer);
-    glBufferData (GL_ARRAY_BUFFER, sizeof (texCoords), texCoords, GL_STATIC_DRAW);
+    glBufferData (GL_ARRAY_BUFFER, sizeof (texCoords), texCoords, GL_DYNAMIC_DRAW);
+    glEnableVertexAttribArray (this->a_TexCoord);
+    glVertexAttribPointer (this->a_TexCoord, 2, GL_FLOAT, GL_FALSE, 0, nullptr);
 
+    // Static for the object's lifetime, so its attrib setup also only needs to happen once here.
     glGenBuffers (1, &this->m_positionBuffer);
     glBindBuffer (GL_ARRAY_BUFFER, this->m_positionBuffer);
     glBufferData (GL_ARRAY_BUFFER, sizeof (position), position, GL_STATIC_DRAW);
+    glEnableVertexAttribArray (this->a_Position);
+    glVertexAttribPointer (this->a_Position, 3, GL_FLOAT, GL_FALSE, 0, nullptr);
 }
 
 CWallpaper::~CWallpaper () {
@@ -255,17 +263,18 @@ void CWallpaper::render (
     glUseProgram (this->m_shader);
     glActiveTexture (GL_TEXTURE0);
     glBindTexture (GL_TEXTURE_2D, this->getWallpaperTexture ());
-    glEnableVertexAttribArray (this->a_TexCoord);
-    glBindBuffer (GL_ARRAY_BUFFER, this->m_texCoordBuffer);
-    glBufferData (GL_ARRAY_BUFFER, sizeof (texCoords), texCoords, GL_STATIC_DRAW);
-    glVertexAttribPointer (this->a_TexCoord, 2, GL_FLOAT, GL_FALSE, 0, nullptr);
+    glUniform1i (this->g_Texture0, 0);
 
-    glEnableVertexAttribArray (this->a_Position);
-    glBindBuffer (GL_ARRAY_BUFFER, this->m_positionBuffer);
-    glVertexAttribPointer (this->a_Position, 3, GL_FLOAT, GL_FALSE, 0, nullptr);
+    if (ustart != this->m_uploadedUstart || uend != this->m_uploadedUend || vstart != this->m_uploadedVstart
+	|| vend != this->m_uploadedVend) {
+	glBindBuffer (GL_ARRAY_BUFFER, this->m_texCoordBuffer);
+	glBufferSubData (GL_ARRAY_BUFFER, 0, sizeof (texCoords), texCoords);
+	this->m_uploadedUstart = ustart;
+	this->m_uploadedUend = uend;
+	this->m_uploadedVstart = vstart;
+	this->m_uploadedVend = vend;
+    }
 
-    glUniform1i (this->g_Texture0, 0);
-    glBindBuffer (GL_ARRAY_BUFFER, this->m_texCoordBuffer);
     glDrawArrays (GL_TRIANGLES, 0, 6);
 
 #if !NDEBUG
@@ -318,8 +327,8 @@ std::shared_ptr<const CFBO> CWallpaper::getFBO () const { return this->m_sceneFB
 
 std::unique_ptr<CWallpaper> CWallpaper::fromWallpaper (
     const Wallpaper& wallpaper, RenderContext& context, AudioContext& audioContext,
-    WebBrowser::WebBrowserContext* browserContext, const WallpaperState::TextureUVsScaling& scalingMode,
-    const uint32_t& clampMode
+    const std::filesystem::path& resolvedBackgroundPath, const WallpaperState::TextureUVsScaling& scalingMode,
+    const uint32_t& clampMode, const glm::ivec2& maxRenderSize
 ) {
     if (wallpaper.is<Scene> ()) {
 	return std::make_unique<WallpaperEngine::Render::Wallpapers::CScene> (
@@ -335,7 +344,7 @@ std::unique_ptr<CWallpaper> CWallpaper::fromWallpaper (
 
     if (wallpaper.is<Web> ()) {
 	return std::make_unique<WallpaperEngine::Render::Wallpapers::CWeb> (
-	    wallpaper, context, audioContext, *browserContext, scalingMode, clampMode
+	    wallpaper, context, audioContext, resolvedBackgroundPath, scalingMode, clampMode, maxRenderSize
 	);
     }
 

+ 13 - 6
src/WallpaperEngine/Render/CWallpaper.h

@@ -2,6 +2,8 @@
 
 #include <GL/glew.h>
 #include <GLFW/glfw3.h>
+#include <filesystem>
+#include <limits>
 #include <optional>
 
 #include "WallpaperEngine/Audio/AudioContext.h"
@@ -20,10 +22,6 @@ namespace WallpaperEngine::Application {
 class WallpaperApplication;
 }
 
-namespace WallpaperEngine::WebBrowser {
-class WebBrowserContext;
-}
-
 namespace WallpaperEngine::Render {
 namespace Helpers {
     class ContextAware;
@@ -83,10 +81,13 @@ public:
     [[nodiscard]] virtual int getWidth () const = 0;
     [[nodiscard]] virtual int getHeight () const = 0;
 
+    // maxRenderSize is only meaningful for Web wallpapers (see CWeb) - the largest resolution this
+    // wallpaper instance will ever be asked to render at: a single screen's size normally, or a span
+    // group's combined bounding box when spanned. Ignored for Scene/Video.
     static std::unique_ptr<CWallpaper> fromWallpaper (
 	const Wallpaper& wallpaper, RenderContext& context, AudioContext& audioContext,
-	WebBrowser::WebBrowserContext* browserContext, const WallpaperState::TextureUVsScaling& scalingMode,
-	const uint32_t& clampMode
+	const std::filesystem::path& resolvedBackgroundPath, const WallpaperState::TextureUVsScaling& scalingMode,
+	const uint32_t& clampMode, const glm::ivec2& maxRenderSize
     );
 
 protected:
@@ -116,6 +117,12 @@ private:
     GLint a_TexCoord = GL_NONE;
     GLuint m_destFramebuffer = GL_NONE;
     void setupShaders ();
+    // UVs last uploaded to m_texCoordBuffer - render() skips the upload when these haven't
+    // changed. NaN so the first render() call always uploads.
+    float m_uploadedUstart = std::numeric_limits<float>::quiet_NaN ();
+    float m_uploadedUend = std::numeric_limits<float>::quiet_NaN ();
+    float m_uploadedVstart = std::numeric_limits<float>::quiet_NaN ();
+    float m_uploadedVend = std::numeric_limits<float>::quiet_NaN ();
     std::map<std::string, std::shared_ptr<const CFBO>> m_fbos = {};
     AudioContext& m_audioContext;
     WallpaperState m_state;

+ 3 - 3
src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.cpp

@@ -240,14 +240,14 @@ void WaylandOutputViewport::setupLS () {
     wl_display_flush (m_driver->getWaylandContext ()->display);
 
     static const auto XCURSORSIZE = getenv ("XCURSOR_SIZE") ? std::stoi (getenv ("XCURSOR_SIZE")) : 24;
-    const auto PRCURSORTHEME
+    cursorTheme
 	= wl_cursor_theme_load (getenv ("XCURSOR_THEME"), XCURSORSIZE * scale, m_driver->getWaylandContext ()->shm);
 
-    if (!PRCURSORTHEME) {
+    if (!cursorTheme) {
 	sLog.exception ("Failed to get a cursor theme");
     }
 
-    pointer = wl_cursor_theme_get_cursor (PRCURSORTHEME, "left_ptr");
+    pointer = wl_cursor_theme_get_cursor (cursorTheme, "left_ptr");
     cursorSurface = wl_compositor_create_surface (m_driver->getWaylandContext ()->compositor);
 
     if (!cursorSurface) {

+ 1 - 0
src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.h

@@ -58,6 +58,7 @@ namespace Output {
 	WallpaperEngine::Input::MouseClickStatus rightClick = WallpaperEngine::Input::MouseClickStatus::Released;
 	wl_cursor* pointer = nullptr;
 	wl_surface* cursorSurface = nullptr;
+	wl_cursor_theme* cursorTheme = nullptr;
 	bool callbackInitialized = false;
 	bool hasXdgLogicalPosition = false;
 	zxdg_output_v1* xdgOutput = nullptr;

+ 8 - 2
src/WallpaperEngine/Render/Drivers/Output/X11Output.cpp

@@ -2,6 +2,8 @@
 #include "GLFWOutputViewport.h"
 #include "WallpaperEngine/Logging/Log.h"
 
+#include <cstdlib>
+
 #include <X11/Xatom.h>
 #include <X11/Xlib.h>
 #include <X11/extensions/Xrandr.h>
@@ -61,10 +63,12 @@ void X11Output::free () {
     this->m_viewports.clear ();
 
     // free all the resources we've got
+    // XDestroyImage() already frees m_imageData itself (via its default destroy_image proc, which
+    // calls XFree()/free() on the buffer XCreateImage() was given) - it must not be freed again here
     XDestroyImage (this->m_image);
+    this->m_imageData = nullptr;
     XFreeGC (this->m_display, this->m_gc);
     XFreePixmap (this->m_display, this->m_pixmap);
-    delete this->m_imageData;
     XCloseDisplay (this->m_display);
 }
 
@@ -213,8 +217,10 @@ void X11Output::initX11Background () {
     // set the window background as our pixmap
     XSetWindowBackgroundPixmap (this->m_display, this->m_root, this->m_pixmap);
     // allocate space for the image's data
+    // XCreateImage() takes ownership of this buffer and frees it itself (via free()) when the
+    // XImage is destroyed, so it must be allocated with malloc(), not new[]
     this->m_imageSize = this->m_fullWidth * this->m_fullHeight * 4;
-    this->m_imageData = new char[this->m_fullWidth * this->m_fullHeight * 4];
+    this->m_imageData = static_cast<char*> (malloc (this->m_imageSize));
     // create an image so we can copy it over
     this->m_image = XCreateImage (
 	this->m_display, CopyFromParent, 24, ZPixmap, 0, this->m_imageData, this->m_fullWidth, this->m_fullHeight, 32, 0

+ 24 - 1
src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.cpp

@@ -138,7 +138,18 @@ handleGlobal (void* data, struct wl_registry* registry, uint32_t name, const cha
 }
 
 static void handleGlobalRemoved (void* data, struct wl_registry* registry, uint32_t id) {
-    // todo: outputs
+    const auto driver = static_cast<WaylandOpenGLDriver*> (data);
+
+    // find the viewport bound to the removed global (e.g. a monitor being unplugged/disabled) -
+    // leaving it around leaks its layer-shell/EGL surfaces in the compositor for the rest of this
+    // client's connection, since nothing else will ever ask it to disconnect
+    const auto it = std::ranges::find_if (
+	driver->m_screens, [id] (const auto* viewport) { return viewport->waylandName == id; }
+    );
+
+    if (it != driver->m_screens.end ()) {
+	driver->onLayerClose (*it);
+    }
 }
 
 constexpr struct wl_registry_listener registryListener = {
@@ -271,6 +282,18 @@ void WaylandOpenGLDriver::onLayerClose (Output::WaylandOutputViewport* viewport)
 	wl_surface_destroy (viewport->surface);
     }
 
+    if (viewport->cursorSurface) {
+	wl_surface_destroy (viewport->cursorSurface);
+    }
+
+    if (viewport->cursorTheme) {
+	wl_cursor_theme_destroy (viewport->cursorTheme);
+    }
+
+    if (viewport->output) {
+	wl_output_release (viewport->output);
+    }
+
     // remove the output from the list
     std::erase (this->m_screens, viewport);
 

+ 17 - 7
src/WallpaperEngine/Render/Objects/CParticle.cpp

@@ -12,6 +12,7 @@
 #include <glm/gtc/matrix_transform.hpp>
 
 extern float g_Time;
+extern float g_RealTime;
 
 using namespace WallpaperEngine::Render::Objects;
 using namespace WallpaperEngine::Render::Utils;
@@ -152,6 +153,10 @@ void CParticle::setup () {
 	    m_controlPoints[cp.id].linkMouse = (cp.flags & 1) != 0;
 	    m_controlPoints[cp.id].worldSpace = (cp.flags & 2) != 0;
 
+	    if (m_controlPoints[cp.id].linkMouse) {
+		m_hasMouseControlPoint = true;
+	    }
+
 	    // Initialize position to offset for non-mouse-linked control points
 	    // Mouse-linked CPs will have their position updated in update()
 	    if (!m_controlPoints[cp.id].linkMouse) {
@@ -180,9 +185,11 @@ void CParticle::render () {
 	return;
     }
 
+    const float currentTime = m_hasMouseControlPoint ? g_RealTime : g_Time;
+
     // Initialize time on first render to avoid huge dt spike
     if (m_time == 0.0) {
-	m_time = g_Time;
+	m_time = currentTime;
 	// Skip update on first frame to avoid weird initial burst
 	// This ensures all particles start from a clean state
 	if (m_useRopeRenderer) {
@@ -194,8 +201,8 @@ void CParticle::render () {
     }
 
     // Update particles
-    float dt = g_Time - static_cast<float> (m_time);
-    m_time = g_Time;
+    float dt = currentTime - static_cast<float> (m_time);
+    m_time = currentTime;
 
     if (dt > 0.0f) {
 	// Cap dt to prevent simulation instability
@@ -2138,9 +2145,12 @@ void CParticle::renderRope () {
     // First pass: evaluate spline to get all interpolated points
     const uint32_t totalPoints = numSegments * subdivision + 1;
     // Store position, size, color (rgba) per point = 3 + 1 + 4 = 8 floats
-    std::vector<glm::vec3> splinePositions (totalPoints);
-    std::vector<float> splineSizes (totalPoints);
-    std::vector<glm::vec4> splineColors (totalPoints); // rgba
+    this->m_splinePositions.resize (totalPoints);
+    this->m_splineSizes.resize (totalPoints);
+    this->m_splineColors.resize (totalPoints); // rgba
+    auto& splinePositions = this->m_splinePositions;
+    auto& splineSizes = this->m_splineSizes;
+    auto& splineColors = this->m_splineColors;
 
     for (uint32_t i = 0; i < numSegments; i++) {
 	const auto& p1 = m_particles[i];
@@ -2180,7 +2190,7 @@ void CParticle::renderRope () {
     // UV smoothing: distribute UV proportional to arc length instead of uniform index.
     // Per wiki: only when all particle lifetimes match and scrolling is disabled.
     const bool useSmoothing = m_ropeUVSmoothing && m_uniformLifetimes && !m_ropeUVScrolling;
-    std::vector<float> cumulativeArcLength;
+    auto& cumulativeArcLength = this->m_cumulativeArcLength;
     float totalArcLength = 0.0f;
 
     if (useSmoothing) {

+ 11 - 0
src/WallpaperEngine/Render/Objects/CParticle.h

@@ -187,8 +187,19 @@ private:
     std::vector<float> m_vertices;
     std::vector<uint32_t> m_indices;
 
+    // Rope renderer scratch buffers - reused across frames (resize instead of reallocating) so
+    // renderRope() doesn't heap-allocate every frame the way a set of locals would.
+    std::vector<glm::vec3> m_splinePositions;
+    std::vector<float> m_splineSizes;
+    std::vector<glm::vec4> m_splineColors;
+    std::vector<float> m_cumulativeArcLength;
+
     double m_time { 0.0 };
 
+    // Mouse-linked particle systems (cursor trails etc) run on unscaled real time so they always
+    // track the pointer 1:1, regardless of the global playback speed multiplier - see --speed.
+    bool m_hasMouseControlPoint { false };
+
     // CPass-based rendering
     Effects::CPass* m_pass { nullptr };
     std::unique_ptr<ImageEffectPassOverride> m_passOverride;

+ 46 - 1
src/WallpaperEngine/Render/Objects/Effects/CPass.cpp

@@ -29,6 +29,39 @@ using namespace WallpaperEngine::Render::Objects::Effects;
 extern float g_Time;
 extern float g_Daytime;
 
+CPass::UniformEntry::~UniformEntry () {
+    if (!this->owned) {
+	return;
+    }
+
+    switch (this->type) {
+	case Double:
+	    delete static_cast<const double*> (this->value);
+	    break;
+	case Float:
+	    delete static_cast<const float*> (this->value);
+	    break;
+	case Integer:
+	    delete static_cast<const int*> (this->value);
+	    break;
+	case Vector4:
+	    delete static_cast<const glm::vec4*> (this->value);
+	    break;
+	case Vector3:
+	    delete static_cast<const glm::vec3*> (this->value);
+	    break;
+	case Vector2:
+	    delete static_cast<const glm::vec2*> (this->value);
+	    break;
+	case Matrix4:
+	    delete static_cast<const glm::mat4*> (this->value);
+	    break;
+	case Matrix3:
+	    delete static_cast<const glm::mat3*> (this->value);
+	    break;
+    }
+}
+
 const TextureMap DEFAULT_BINDS = {};
 const ImageEffectPassOverride DEFAULT_OVERRIDE = {};
 
@@ -88,6 +121,18 @@ CPass::CPass (
 }
 
 CPass::~CPass () {
+    for (const auto& value : this->m_uniforms | std::views::values) {
+	delete value;
+    }
+
+    for (const auto& value : this->m_referenceUniforms | std::views::values) {
+	delete value;
+    }
+
+    for (const auto* attrib : this->m_attribs) {
+	delete attrib;
+    }
+
     glDeleteVertexArrays (1, &m_vao);
     this->m_vao = GL_NONE;
 
@@ -975,7 +1020,7 @@ template <typename T> void CPass::addUniform (const std::string& name, UniformTy
     T* newValue = new T (value);
 
     // uniform found, add it to the list
-    this->m_uniforms.insert_or_assign (name, new UniformEntry (id, name, type, newValue, 1));
+    this->m_uniforms.insert_or_assign (name, new UniformEntry (id, name, type, newValue, 1, true));
 }
 
 template <typename T> void CPass::addUniform (const std::string& name, UniformType type, T* value, int count) {

+ 8 - 2
src/WallpaperEngine/Render/Objects/Effects/CPass.h

@@ -83,14 +83,20 @@ private:
 
     class UniformEntry {
     public:
-	UniformEntry (const GLint id, std::string name, UniformType type, const void* value, int count) :
-	    id (id), name (std::move (name)), type (type), value (value), count (count) { }
+	UniformEntry (
+	    const GLint id, std::string name, UniformType type, const void* value, int count, bool owned = false
+	) : id (id), name (std::move (name)), type (type), value (value), count (count), owned (owned) { }
+	~UniformEntry ();
 
 	const GLint id;
 	std::string name;
 	UniformType type;
 	const void* value;
 	int count;
+	// true when "value" is a heap copy this entry allocated (addUniform(name, type, T) by-value
+	// overload) rather than a pointer into a scene/shader-owned field (addUniform(..., T*, count)),
+	// so only owned entries may free their value.
+	bool owned;
     };
 
     class ReferenceUniformEntry {

+ 3 - 0
src/WallpaperEngine/Render/Wallpapers/CVideo.cpp

@@ -29,6 +29,7 @@ CVideo::CVideo (
     // a volume of 0 mutes the mpv backend (matching what --volume 0 already does).
     const auto& audioSettings = this->getContext ().getApp ().getContext ().settings.audio;
     this->m_player->setVolume (audioSettings.enabled ? audioSettings.volume * 100.0 / 128.0 : 0.0);
+    this->m_player->setSpeed (this->getContext ().getApp ().getContext ().settings.render.playbackSpeed);
     // make sure the video has at least one usage marked, this ensures the video plays
     this->m_player->incrementUsageCount ();
 }
@@ -55,6 +56,8 @@ const Data::Model::Video& CVideo::getVideo () const { return *this->getWallpaper
 
 void CVideo::setVolume (double volume) { this->m_player->setVolume (volume); }
 
+void CVideo::setSpeed (double speed) { this->m_player->setSpeed (speed); }
+
 void CVideo::setPause (bool newState) {
     if (newState) {
 	this->m_player->setPaused ();

+ 3 - 0
src/WallpaperEngine/Render/Wallpapers/CVideo.h

@@ -26,6 +26,9 @@ public:
     /** Pushes a new volume (0-100, matching what GLPlayer expects) to the underlying mpv player without a reload */
     void setVolume (double volume);
 
+    /** Pushes a new playback speed multiplier to the underlying mpv player without a reload, see --speed */
+    void setSpeed (double speed);
+
 protected:
     void renderFrame (const glm::ivec4& viewport) override;
 

+ 196 - 66
src/WallpaperEngine/Render/Wallpapers/CWeb.cpp

@@ -2,58 +2,146 @@
 // https://github.com/if1live/cef-gl-example
 // https://github.com/andmcgregor/cefgui
 #include "CWeb.h"
-#include "WallpaperEngine/WebBrowser/CEF/WPSchemeHandlerFactory.h"
+#include "WallpaperEngine/Application/WallpaperApplication.h"
+#include "WallpaperEngine/Logging/Log.h"
 
 #include "WallpaperEngine/Data/Model/Project.h"
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 
+#include <chrono>
+#include <csignal>
+#include <cstdlib>
+#include <dirent.h>
+#include <sys/prctl.h>
+#include <sys/wait.h>
+#include <thread>
+#include <unistd.h>
+
 using namespace WallpaperEngine::Render;
 using namespace WallpaperEngine::Render::Wallpapers;
+using namespace WallpaperEngine::WebBrowser::IPC;
+
+namespace {
+// Closes every fd but stdio, so the exec'd CEF child doesn't inherit the parent's
+// Wayland/EGL/X11/audio connections.
+void closeInheritedFds () {
+    DIR* dir = opendir ("/proc/self/fd");
+
+    if (dir == nullptr) {
+	return;
+    }
 
-using namespace WallpaperEngine::WebBrowser;
-using namespace WallpaperEngine::WebBrowser::CEF;
+    const int dirFd = dirfd (dir);
+
+    while (dirent* entry = readdir (dir)) {
+	char* end = nullptr;
+	const long fd = std::strtol (entry->d_name, &end, 10);
+
+	if (end != entry->d_name && *end == '\0' && fd > 2 && fd != dirFd) {
+	    close (static_cast<int> (fd));
+	}
+    }
+
+    closedir (dir);
+}
+} // namespace
 
 CWeb::CWeb (
-    const Wallpaper& wallpaper, RenderContext& context, AudioContext& audioContext, WebBrowserContext& browserContext,
-    const WallpaperState::TextureUVsScaling& scalingMode, const uint32_t& clampMode
-) : CWallpaper (wallpaper, context, audioContext, scalingMode, clampMode), m_browserContext (browserContext) {
-    // setup framebuffers
+    const Wallpaper& wallpaper, RenderContext& context, AudioContext& audioContext,
+    const std::filesystem::path& resolvedBackgroundPath, const WallpaperState::TextureUVsScaling& scalingMode,
+    const uint32_t& clampMode, const glm::ivec2& maxRenderSize
+) : CWallpaper (wallpaper, context, audioContext, scalingMode, clampMode) {
     this->setupFramebuffers ();
 
-    CefWindowInfo window_info;
-    window_info.SetAsWindowless (0);
+    // capacity for the shared frame buffer, clamped to by setSize() below - deliberately
+    // maxRenderSize and not Output::getFullWidth/Height() (see fromWallpaper's doc comment), which
+    // would badly oversize this for the common case of several separate, non-spanned wallpapers.
+    this->m_shmMaxWidth = static_cast<uint32_t> (std::max (1, maxRenderSize.x));
+    this->m_shmMaxHeight = static_cast<uint32_t> (std::max (1, maxRenderSize.y));
+
+    this->m_shm = createSharedMemory (this->m_shmName, this->m_shmMaxWidth, this->m_shmMaxHeight);
+
+    if (this->m_shm == nullptr) {
+	sLog.exception ("CWeb: failed to create shared memory for the web host process");
+    }
+
+    this->m_shm->desiredWidth.store (static_cast<uint32_t> (this->m_width), std::memory_order_relaxed);
+    this->m_shm->desiredHeight.store (static_cast<uint32_t> (this->m_height), std::memory_order_relaxed);
+
+    this->spawnHost (resolvedBackgroundPath);
+}
+
+void CWeb::spawnHost (const std::filesystem::path& resolvedBackgroundPath) {
+    auto& appContext = this->getContext ().getApp ().getContext ();
+
+    const pid_t pid = fork ();
+
+    if (pid < 0) {
+	// sLog.exception() throws, aborting construction before ~CWeb() ever runs - without this,
+	// the named shm segment createSharedMemory() already made would never be unlinked.
+	closeSharedMemory (this->m_shm, this->m_shmName, this->m_shmMaxWidth, this->m_shmMaxHeight, true);
+	sLog.exception ("CWeb: fork() failed while spawning the web host process");
+    }
+
+    if (pid == 0) {
+	closeInheritedFds ();
+
+	// kill this child if the parent dies without telling it to quit over shared memory
+	prctl (PR_SET_PDEATHSIG, SIGTERM);
+
+	std::vector<std::string> args;
+	args.emplace_back (resolvedBackgroundPath.string ());
+	args.emplace_back ("--web-host");
+	args.emplace_back ("--web-host-shm");
+	args.emplace_back (this->m_shmName);
+	args.emplace_back ("--web-host-width");
+	args.emplace_back (std::to_string (this->m_shmMaxWidth));
+	args.emplace_back ("--web-host-height");
+	args.emplace_back (std::to_string (this->m_shmMaxHeight));
+
+	if (!appContext.settings.general.assets.empty ()) {
+	    args.emplace_back ("--assets-dir");
+	    args.emplace_back (appContext.settings.general.assets.string ());
+	}
+
+	std::vector<char*> argv;
+	argv.reserve (args.size () + 2);
+	// cosmetic only (what the child sees as its own argv[0]) - the executable actually run is
+	// /proc/self/exe below, not this string, so it doesn't matter whether the original argv[0]
+	// was absolute, relative, or PATH-resolved by the shell that launched us
+	argv.push_back (appContext.getArgv ()[0]);
 
-    this->m_renderHandler = new WebBrowser::CEF::RenderHandler (this);
+	for (auto& arg : args) {
+	    argv.push_back (arg.data ());
+	}
 
-    CefBrowserSettings browserSettings;
-    // documentaion says that 60 fps is maximum value
-    browserSettings.windowless_frame_rate = std::max (60, context.getApp ().getContext ().settings.render.maximumFPS);
+	argv.push_back (nullptr);
 
-    this->m_client = new WebBrowser::CEF::BrowserClient (m_renderHandler);
-    // use the custom scheme for the wallpaper's files
-    const std::string htmlURL = WPSchemeHandlerFactory::generateSchemeName (this->getWeb ().project.workshopId)
-	+ "://root/" + this->getWeb ().filename;
-    this->m_browser
-	= CefBrowserHost::CreateBrowserSync (window_info, this->m_client, htmlURL, browserSettings, nullptr, nullptr);
+	execv ("/proc/self/exe", argv.data ());
+
+	// only reached if execv() itself failed
+	_exit (127);
+    }
+
+    this->m_hostPid = pid;
 }
 
 void CWeb::setSize (const int width, const int height) {
     this->m_width = width > 0 ? width : this->m_width;
     this->m_height = height > 0 ? height : this->m_height;
 
-    // do not refresh the texture if any of the sizes are invalid
     if (this->m_width <= 0 || this->m_height <= 0) {
 	return;
     }
 
-    // reconfigure the texture
-    glBindTexture (GL_TEXTURE_2D, this->getWallpaperTexture ());
-    glTexImage2D (
-	GL_TEXTURE_2D, 0, GL_RGBA8, this->getWidth (), this->getHeight (), 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr
+    // clamp to the capacity decided at construction time (m_shmMaxWidth/Height) - the host process
+    // notices this changed and calls WasResized() itself, see runWebHost()
+    this->m_shm->desiredWidth.store (
+	std::min (static_cast<uint32_t> (this->m_width), this->m_shmMaxWidth), std::memory_order_relaxed
+    );
+    this->m_shm->desiredHeight.store (
+	std::min (static_cast<uint32_t> (this->m_height), this->m_shmMaxHeight), std::memory_order_relaxed
     );
-
-    // Notify cef that it was resized(maybe it's not even needed)
-    this->m_browser->GetHost ()->WasResized ();
 }
 
 void CWeb::renderFrame (const glm::ivec4& viewport) {
@@ -69,55 +157,97 @@ void CWeb::renderFrame (const glm::ivec4& viewport) {
     // ensure we render over the whole framebuffer
     glViewport (0, 0, this->getWidth (), this->getHeight ());
 
-    // Cef processes all messages, including OnPaint, which renders frame
-    // If there is no OnPaint in message loop, we will not update(render) frame
-    //  This means some frames will not have OnPaint call in cef messageLoop
-    //  Because of that glClear will result in flickering on higher fps
-    //  Do not use glClear until some method to control rendering with cef is supported
-    // We might actually try to use cef to execute javascript, and not using off-screen rendering at all
-    // But for now let it be like this
-    //  glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
-    CefDoMessageLoopWork ();
+    // Pull the latest frame the host process painted, if there's one we haven't uploaded yet.
+    // Seqlock: frameSeq is even and stable while no write is in progress: odd means the host is
+    // mid-write, and a value that changed across our read means we caught a write in progress and
+    // may have read a torn frame - either way, just try again on a later call rather than block,
+    // bounded so a host that's somehow writing every few microseconds can't stall rendering here.
+    for (int attempt = 0; attempt < 4; attempt++) {
+	const uint32_t seqBefore = this->m_shm->frameSeq.load (std::memory_order_acquire);
+
+	if (seqBefore % 2 != 0 || seqBefore == this->m_lastUploadedSeq) {
+	    break;
+	}
+
+	const uint32_t frameWidth = this->m_shm->frameWidth.load (std::memory_order_relaxed);
+	const uint32_t frameHeight = this->m_shm->frameHeight.load (std::memory_order_relaxed);
+
+	if (frameWidth == 0 || frameHeight == 0) {
+	    break;
+	}
+
+	glBindTexture (GL_TEXTURE_2D, this->getWallpaperTexture ());
+
+	// Only reallocate GPU storage (glTexImage2D) when the size actually changed - the common
+	// case is the same size every frame, and glTexSubImage2D updating pixels in place avoids the
+	// driver-side realloc/sync that a fresh glTexImage2D call triggers, up to 60 times a second.
+	if (frameWidth == this->m_uploadedTextureWidth && frameHeight == this->m_uploadedTextureHeight) {
+	    glTexSubImage2D (
+		GL_TEXTURE_2D, 0, 0, 0, static_cast<GLsizei> (frameWidth), static_cast<GLsizei> (frameHeight),
+		GL_BGRA_EXT, GL_UNSIGNED_BYTE, this->m_shm->frameBuffer ()
+	    );
+	} else {
+	    glTexImage2D (
+		GL_TEXTURE_2D, 0, GL_RGBA, static_cast<GLsizei> (frameWidth), static_cast<GLsizei> (frameHeight), 0,
+		GL_BGRA_EXT, GL_UNSIGNED_BYTE, this->m_shm->frameBuffer ()
+	    );
+	    this->m_uploadedTextureWidth = frameWidth;
+	    this->m_uploadedTextureHeight = frameHeight;
+	}
+
+	glBindTexture (GL_TEXTURE_2D, 0);
+
+	const uint32_t seqAfter = this->m_shm->frameSeq.load (std::memory_order_acquire);
+
+	if (seqAfter == seqBefore) {
+	    this->m_lastUploadedSeq = seqAfter;
+	    break;
+	}
+	// torn read, the host wrote a new frame while we were copying - retry
+    }
 }
 
 void CWeb::updateMouse (const glm::ivec4& viewport) {
-    // update virtual mouse position first
     auto& input = this->getContext ().getInputContext ().getMouseInput ();
 
     const glm::dvec2 position = input.position ();
-    const auto leftClick = input.leftClick ();
-    const auto rightClick = input.rightClick ();
-
-    CefMouseEvent evt;
-    // Set mouse current position. Maybe clamps are not needed
-    evt.x = std::clamp (static_cast<int> (position.x - viewport.x), 0, viewport.z);
-    // Convert from OpenGL coordinates (Y=0 at bottom) to CEF coordinates (Y=0 at top)
-    evt.y = viewport.w - std::clamp (static_cast<int> (position.y - viewport.y), 0, viewport.w);
-    // Send mouse position to cef
-    this->m_browser->GetHost ()->SendMouseMoveEvent (evt, false);
-
-    // TODO: ANY OTHER MOUSE EVENTS TO SEND?
-    if (leftClick != this->m_leftClick) {
-	this->m_browser->GetHost ()->SendMouseClickEvent (
-	    evt, CefBrowserHost::MouseButtonType::MBT_LEFT,
-	    leftClick == WallpaperEngine::Input::MouseClickStatus::Released, 1
-	);
-    }
 
-    if (rightClick != this->m_rightClick) {
-	this->m_browser->GetHost ()->SendMouseClickEvent (
-	    evt, CefBrowserHost::MouseButtonType::MBT_RIGHT,
-	    rightClick == WallpaperEngine::Input::MouseClickStatus::Released, 1
-	);
-    }
+    // Convert from OpenGL coordinates (Y=0 at bottom) to CEF coordinates (Y=0 at top). Written
+    // unconditionally every frame - cheap atomic stores, and the host process itself only forwards
+    // clicks to CEF when it observes the value actually changed.
+    const int x = std::clamp (static_cast<int> (position.x - viewport.x), 0, viewport.z);
+    const int y = viewport.w - std::clamp (static_cast<int> (position.y - viewport.y), 0, viewport.w);
 
-    this->m_leftClick = leftClick;
-    this->m_rightClick = rightClick;
+    this->m_shm->mouseX.store (static_cast<double> (x), std::memory_order_relaxed);
+    this->m_shm->mouseY.store (static_cast<double> (y), std::memory_order_relaxed);
+    this->m_shm->leftClick.store (static_cast<int32_t> (input.leftClick ()), std::memory_order_relaxed);
+    this->m_shm->rightClick.store (static_cast<int32_t> (input.rightClick ()), std::memory_order_relaxed);
 }
 
 CWeb::~CWeb () {
-    CefDoMessageLoopWork ();
-    this->m_browser->GetHost ()->CloseBrowser (true);
+    this->m_shm->quitRequested.store (true, std::memory_order_release);
+
+    // Give the host process a chance to close its CEF browser and shut down cleanly before falling
+    // back to killing it outright - same bounded-wait philosophy this used when CEF was embedded
+    // directly in this process (500 iterations there too, just pumping CEF's own message loop
+    // instead of polling waitpid - there's no message loop to pump here anymore).
+    bool exited = false;
+
+    for (int i = 0; i < 500; i++) {
+	int status = 0;
+
+	if (waitpid (this->m_hostPid, &status, WNOHANG) == this->m_hostPid) {
+	    exited = true;
+	    break;
+	}
+
+	std::this_thread::sleep_for (std::chrono::milliseconds (10));
+    }
+
+    if (!exited) {
+	kill (this->m_hostPid, SIGKILL);
+	waitpid (this->m_hostPid, nullptr, 0);
+    }
 
-    delete this->m_renderHandler;
+    closeSharedMemory (this->m_shm, this->m_shmName, this->m_shmMaxWidth, this->m_shmMaxHeight, true);
 }

+ 25 - 15
src/WallpaperEngine/Render/Wallpapers/CWeb.h

@@ -5,29 +5,33 @@
 #include <glm/glm.hpp>
 
 #include <algorithm>
+#include <filesystem>
 #include <memory>
 #include <string>
+#include <sys/types.h>
 #include <utility>
 #include <vector>
 
 #include "WallpaperEngine/Audio/AudioStream.h"
 #include "WallpaperEngine/Render/CWallpaper.h"
-#include "WallpaperEngine/WebBrowser/CEF/BrowserClient.h"
-#include "WallpaperEngine/WebBrowser/CEF/RenderHandler.h"
+#include "WallpaperEngine/WebBrowser/IPC/WebHostSharedMemory.h"
 
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 
-namespace WallpaperEngine::WebBrowser::CEF {
-class RenderHandler;
-}
-
 namespace WallpaperEngine::Render::Wallpapers {
+// Web wallpapers are rendered by CEF, which this never embeds directly (CEF only supports one
+// CefInitialize()/CefShutdown() pair per process, so switching a running process between hosting
+// and not hosting a web wallpaper isn't safe). Instead CWeb spawns a disposable child process
+// (WallpaperApplication::runWebHost(), a self re-exec with --web-host) that owns CEF for exactly
+// one browser, and reads its rendered frames back through shared memory - matching how upstream
+// Wallpaper Engine on Windows delegates web wallpapers to a separate webwallpaper.exe process
+// rather than hosting CEF in the main process.
 class CWeb : public CWallpaper {
 public:
     CWeb (
 	const Wallpaper& wallpaper, RenderContext& context, AudioContext& audioContext,
-	WallpaperEngine::WebBrowser::WebBrowserContext& browserContext,
-	const WallpaperState::TextureUVsScaling& scalingMode, const uint32_t& clampMode
+	const std::filesystem::path& resolvedBackgroundPath, const WallpaperState::TextureUVsScaling& scalingMode,
+	const uint32_t& clampMode, const glm::ivec2& maxRenderSize
     );
     ~CWeb () override;
     [[nodiscard]] int getWidth () const override { return this->m_width; }
@@ -44,17 +48,23 @@ protected:
     friend class CWallpaper;
 
 private:
-    WallpaperEngine::WebBrowser::WebBrowserContext& m_browserContext;
-    CefRefPtr<CefBrowser> m_browser = nullptr;
-    CefRefPtr<WallpaperEngine::WebBrowser::CEF::BrowserClient> m_client = nullptr;
-    WallpaperEngine::WebBrowser::CEF::RenderHandler* m_renderHandler = nullptr;
+    void spawnHost (const std::filesystem::path& resolvedBackgroundPath);
+
+    WallpaperEngine::WebBrowser::IPC::WebHostSharedMemory* m_shm = nullptr;
+    std::string m_shmName;
+    uint32_t m_shmMaxWidth = 0;
+    uint32_t m_shmMaxHeight = 0;
+    // seqlock value of the last frame actually uploaded to the GL texture - see renderFrame()
+    uint32_t m_lastUploadedSeq = 0;
+    // size of the texture as currently allocated on the GPU, so renderFrame() can tell whether it
+    // needs to reallocate storage (glTexImage2D) or can just update pixels in place (glTexSubImage2D)
+    uint32_t m_uploadedTextureWidth = 0;
+    uint32_t m_uploadedTextureHeight = 0;
+    pid_t m_hostPid = -1;
 
     int m_width = 16;
     int m_height = 17;
 
-    WallpaperEngine::Input::MouseClickStatus m_leftClick = Input::Released;
-    WallpaperEngine::Input::MouseClickStatus m_rightClick = Input::Released;
-
     glm::vec2 m_mousePosition = {};
     glm::vec2 m_mousePositionLast = {};
 };

+ 1 - 1
src/WallpaperEngine/Scripting/Adapters/VectorAdapter.cpp

@@ -934,7 +934,7 @@ template <int components> VectorAdapter<components>::~VectorAdapter () {
 }
 
 template <int components> JSValue VectorAdapter<components>::instantiate (ScriptableObject& object) {
-    throw new std::runtime_error ("Cannot create a Vector4 instance from a ScriptableObject");
+    throw std::runtime_error ("Cannot create a Vector4 instance from a ScriptableObject");
 }
 
 template <int components> JSValue VectorAdapter<components>::instantiate (DynamicValue& value) {

+ 9 - 0
src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.cpp

@@ -107,6 +107,14 @@ void GLPlayer::setVolume (double volume) {
     }
 }
 
+void GLPlayer::setSpeed (double speed) {
+    this->m_speed = speed;
+
+    if (this->m_handle) {
+	mpv_set_property (this->m_handle, "speed", MPV_FORMAT_DOUBLE, &this->m_speed);
+    }
+}
+
 void GLPlayer::setPaused () {
     this->m_paused = true;
 
@@ -244,6 +252,7 @@ void GLPlayer::init () {
     mpv_set_property_string (this->m_handle, "hwdec", "auto");
     mpv_set_property_string (this->m_handle, "loop", "inf");
     mpv_set_property (this->m_handle, "volume", MPV_FORMAT_DOUBLE, &this->m_volume);
+    mpv_set_property (this->m_handle, "speed", MPV_FORMAT_DOUBLE, &this->m_speed);
 
     // initialize gl context for mpv
     mpv_opengl_init_params gl_init_params { get_proc_address, this };

+ 2 - 0
src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.h

@@ -44,6 +44,7 @@ public:
     void setMuted ();
     void clearMuted ();
     void setVolume (double volume);
+    void setSpeed (double speed);
     void setPaused ();
     void clearPaused ();
 
@@ -72,6 +73,7 @@ protected:
     mpv_handle* m_handle = nullptr;
     mpv_render_context* m_renderContext = nullptr;
     double m_volume = 0.0f;
+    double m_speed = 1.0;
     bool m_muted = false;
     bool m_untimed = false;
     bool m_paused = false;

+ 16 - 6
src/WallpaperEngine/WebBrowser/CEF/BrowserApp.cpp

@@ -1,5 +1,8 @@
 #include "BrowserApp.h"
+#include "WPSchemeHandlerFactory.h"
+#include "WallpaperEngine/Application/WallpaperApplication.h"
 #include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/WebBrowser/WebBrowserContext.h"
 
 using namespace WallpaperEngine::WebBrowser::CEF;
 
@@ -9,12 +12,11 @@ BrowserApp::BrowserApp (WallpaperEngine::Application::WallpaperApplication& appl
 CefRefPtr<CefBrowserProcessHandler> BrowserApp::GetBrowserProcessHandler () { return this; }
 
 void BrowserApp::OnContextInitialized () {
-    // register all the needed schemes, "wp" + the background id is going to be our scheme
-    for (const auto& [workshopId, factory] : this->getHandlerFactories ()) {
-	CefRegisterSchemeHandlerFactory (
-	    WPSchemeHandlerFactory::generateSchemeName (workshopId), static_cast<const char*> (nullptr), factory
-	);
-    }
+    // domain_name = nullptr matches every host under WPENGINE_SCHEME - the factory itself picks
+    // the right wallpaper's project per request from the host (workshop id).
+    CefRegisterSchemeHandlerFactory (
+	WPENGINE_SCHEME, static_cast<const char*> (nullptr), new WPSchemeHandlerFactory (this->getApplication ())
+    );
 }
 
 void BrowserApp::OnBeforeCommandLineProcessing (const CefString& process_type, CefRefPtr<CefCommandLine> command_line) {
@@ -51,4 +53,12 @@ void BrowserApp::OnBeforeChildProcessLaunch (CefRefPtr<CefCommandLine> command_l
     for (int i = 1; i < this->getApplication ().getContext ().getArgc (); i++) {
 	command_line->AppendArgument (this->getApplication ().getContext ().getArgv ()[i]);
     }
+
+    // The "background id" positional above is only ever the launch-time value - without this, a
+    // subprocess spawned after a hotswap would resolve the wrong (or no) project for its own
+    // scheme handler lookups.
+    const auto& currentBackground = this->getApplication ().getContext ().settings.general.defaultBackground;
+    if (!currentBackground.empty ()) {
+	command_line->AppendSwitchWithValue ("--current-background", currentBackground.string ());
+    }
 }

+ 68 - 2
src/WallpaperEngine/WebBrowser/CEF/BrowserClient.cpp

@@ -1,7 +1,73 @@
 #include "BrowserClient.h"
+#include <iostream>
+#include <nlohmann/json.hpp>
 
 using namespace WallpaperEngine::WebBrowser::CEF;
+using namespace WallpaperEngine::Data::Model;
 
-BrowserClient::BrowserClient (CefRefPtr<CefRenderHandler> ptr) : m_renderHandler (std::move (ptr)) { }
+BrowserClient::BrowserClient (CefRefPtr<CefRenderHandler> ptr, const Properties& properties) :
+    m_renderHandler (std::move (ptr)), m_properties (properties) { }
 
-CefRefPtr<CefRenderHandler> BrowserClient::GetRenderHandler () { return m_renderHandler; }
+CefRefPtr<CefRenderHandler> BrowserClient::GetRenderHandler () { return m_renderHandler; }
+
+CefRefPtr<CefLifeSpanHandler> BrowserClient::GetLifeSpanHandler () { return this; }
+
+CefRefPtr<CefDisplayHandler> BrowserClient::GetDisplayHandler () { return this; }
+
+CefRefPtr<CefLoadHandler> BrowserClient::GetLoadHandler () { return this; }
+
+void BrowserClient::OnBeforeClose (CefRefPtr<CefBrowser> browser) { this->m_closed = true; }
+
+bool BrowserClient::OnConsoleMessage (
+    CefRefPtr<CefBrowser> browser, cef_log_severity_t level, const CefString& message, const CefString& source,
+    int line
+) {
+    // Some wallpapers log continuously during normal operation (Live2D motion transitions, asset
+    // fetch tracing, etc via console.log/INFO) - forwarding every one of those means a synchronous
+    // flushed write for the lifetime of the wallpaper, across two processes on a dual-monitor setup.
+    // Only warnings and above are worth the cost; they're also the only ones actually useful for
+    // spotting a page failing silently.
+    if (level < LOGSEVERITY_WARNING) {
+	return false;
+    }
+
+    std::cout << "[web console] " << source.ToString () << ":" << line << ": " << message.ToString () << std::endl;
+    return false;
+}
+
+void BrowserClient::OnLoadError (
+    CefRefPtr<CefBrowser> browser, CefRefPtr<CefFrame> frame, ErrorCode errorCode, const CefString& errorText,
+    const CefString& failedUrl
+) {
+    std::cout << "[web load error] " << failedUrl.ToString () << ": " << errorText.ToString () << " (" << errorCode
+	       << ")" << std::endl;
+}
+
+void BrowserClient::OnLoadEnd (CefRefPtr<CefBrowser> browser, CefRefPtr<CefFrame> frame, int httpStatusCode) {
+    if (!frame->IsMain ()) {
+	return;
+    }
+
+    nlohmann::json props = nlohmann::json::object ();
+
+    for (const auto& [name, property] : this->m_properties) {
+	nlohmann::json value;
+
+	switch (property->getType ()) {
+	case DynamicValue::Boolean: value = property->getBool (); break;
+	case DynamicValue::Int: value = property->getInt (); break;
+	case DynamicValue::Float: value = property->getFloat (); break;
+	case DynamicValue::String: value = property->getString (); break;
+	default: value = property->toString (); break;
+	}
+
+	props[name] = { { "value", value } };
+    }
+
+    const std::string script = "if (window.wallpaperPropertyListener && "
+				"window.wallpaperPropertyListener.applyUserProperties) { "
+				"window.wallpaperPropertyListener.applyUserProperties(" + props.dump ()
+	+ "); }";
+
+    frame->ExecuteJavaScript (script, frame->GetURL (), 0);
+}

+ 39 - 3
src/WallpaperEngine/WebBrowser/CEF/BrowserClient.h

@@ -1,20 +1,56 @@
 #pragma once
 
+#include "WallpaperEngine/Data/Model/Property.h"
+#include "WallpaperEngine/Data/Model/UserSetting.h"
 #include "include/cef_client.h"
+#include <atomic>
 
 namespace WallpaperEngine::WebBrowser::CEF {
 // *************************************************************************
 //! \brief Provide access to browser-instance-specific callbacks. A single
 //! CefClient instance can be shared among any number of browsers.
 // *************************************************************************
-class BrowserClient : public CefClient {
+class BrowserClient : public CefClient, public CefLifeSpanHandler, public CefDisplayHandler, public CefLoadHandler {
 public:
-    explicit BrowserClient (CefRefPtr<CefRenderHandler> ptr);
+    // properties must outlive this instance - runWebHost() holds the owning Project for as long as
+    // the browser (and this client) is alive.
+    BrowserClient (CefRefPtr<CefRenderHandler> ptr, const WallpaperEngine::Data::Model::Properties& properties);
 
     [[nodiscard]] CefRefPtr<CefRenderHandler> GetRenderHandler () override;
+    [[nodiscard]] CefRefPtr<CefLifeSpanHandler> GetLifeSpanHandler () override;
+    [[nodiscard]] CefRefPtr<CefDisplayHandler> GetDisplayHandler () override;
+    [[nodiscard]] CefRefPtr<CefLoadHandler> GetLoadHandler () override;
+
+    // CEF owns the browser's actual teardown asynchronously - our own CefRefPtr<CefBrowser> must
+    // not be allowed to drop to zero (destroying it) before this fires, or ~CWeb crashes tearing
+    // down a browser CEF itself isn't done with yet.
+    void OnBeforeClose (CefRefPtr<CefBrowser> browser) override;
+    [[nodiscard]] bool isClosed () const { return this->m_closed; }
+
+    // Surfaces the page's own console (JS errors/warnings, WebGL context failures, etc) - CEF
+    // swallows this by default, and it's often the only clue a page is failing silently.
+    bool OnConsoleMessage (
+	CefRefPtr<CefBrowser> browser, cef_log_severity_t level, const CefString& message, const CefString& source,
+	int line
+    ) override;
+
+    void OnLoadError (
+	CefRefPtr<CefBrowser> browser, CefRefPtr<CefFrame> frame, ErrorCode errorCode, const CefString& errorText,
+	const CefString& failedUrl
+    ) override;
+
+    // Wallpaper Engine's web wallpapers expect the host to call
+    // window.wallpaperPropertyListener.applyUserProperties(...) once the page is ready - some
+    // wallpapers gate their entire render loop on a property only ever set inside that callback, so
+    // skipping this leaves them stuck rendering nothing, silently, forever.
+    void OnLoadEnd (CefRefPtr<CefBrowser> browser, CefRefPtr<CefFrame> frame, int httpStatusCode) override;
 
     CefRefPtr<CefRenderHandler> m_renderHandler = nullptr;
 
     IMPLEMENT_REFCOUNTING (BrowserClient);
+
+private:
+    const WallpaperEngine::Data::Model::Properties& m_properties;
+    std::atomic<bool> m_closed = false;
 };
-} // namespace WallpaperEngine::WebBrowser::CEF
+} // namespace WallpaperEngine::WebBrowser::CEF

+ 0 - 27
src/WallpaperEngine/WebBrowser/CEF/RenderHandler.cpp

@@ -1,27 +0,0 @@
-#include "RenderHandler.h"
-
-using namespace WallpaperEngine::WebBrowser::CEF;
-
-RenderHandler::RenderHandler (WallpaperEngine::Render::Wallpapers::CWeb* webdata) : m_webdata (webdata) { }
-
-// Required by CEF
-void RenderHandler::GetViewRect (CefRefPtr<CefBrowser> browser, CefRect& rect) {
-    rect = CefRect (0, 0, this->m_webdata->getWidth (), this->m_webdata->getHeight ());
-}
-
-// Will be executed in CEF message loop
-void RenderHandler::OnPaint (
-    CefRefPtr<CefBrowser> browser, PaintElementType type, const RectList& dirtyRects, const void* buffer,
-    const int width, const int height
-) {
-    glActiveTexture (GL_TEXTURE0);
-    glBindTexture (GL_TEXTURE_2D, this->texture ());
-    glTexImage2D (GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_BGRA_EXT, GL_UNSIGNED_BYTE, buffer);
-    glBindTexture (GL_TEXTURE_2D, 0);
-}
-
-int RenderHandler::getWidth () const { return this->m_webdata->getWidth (); }
-
-int RenderHandler::getHeight () const { return this->m_webdata->getHeight (); }
-
-GLuint RenderHandler::texture () const { return this->m_webdata->getWallpaperFramebuffer (); }

+ 0 - 43
src/WallpaperEngine/WebBrowser/CEF/RenderHandler.h

@@ -1,43 +0,0 @@
-#pragma once
-
-#include "WallpaperEngine/Render/Wallpapers/CWeb.h"
-#include "include/cef_browser.h"
-
-namespace WallpaperEngine::Render::Wallpapers {
-class CWeb;
-}
-
-namespace WallpaperEngine::WebBrowser::CEF {
-// *************************************************************************
-//! \brief Private implementation to handle CEF events to draw the web page.
-// *************************************************************************
-class RenderHandler : public CefRenderHandler {
-public:
-    explicit RenderHandler (WallpaperEngine::Render::Wallpapers::CWeb* webdata);
-
-    //! \brief
-    ~RenderHandler () override = default;
-
-    //! \brief CefRenderHandler interface
-    void GetViewRect (CefRefPtr<CefBrowser> browser, CefRect& rect) override;
-
-    //! \brief CefRenderHandler interface
-    //! Update the OpenGL texture.
-    void OnPaint (
-	CefRefPtr<CefBrowser> browser, PaintElementType type, const RectList& dirtyRects, const void* buffer, int width,
-	int height
-    ) override;
-
-    //! \brief CefBase interface
-    IMPLEMENT_REFCOUNTING (RenderHandler);
-
-private:
-    WallpaperEngine::Render::Wallpapers::CWeb* m_webdata = nullptr;
-
-    [[nodiscard]] int getWidth () const;
-    [[nodiscard]] int getHeight () const;
-
-    //! \brief Return the OpenGL texture handle
-    [[nodiscard]] GLuint texture () const;
-};
-} // namespace WallpaperEngine::WebBrowser::CEF

+ 41 - 0
src/WallpaperEngine/WebBrowser/CEF/SharedMemoryRenderHandler.cpp

@@ -0,0 +1,41 @@
+#include "SharedMemoryRenderHandler.h"
+#include <algorithm>
+#include <cstring>
+
+using namespace WallpaperEngine::WebBrowser::CEF;
+using namespace WallpaperEngine::WebBrowser::IPC;
+
+SharedMemoryRenderHandler::SharedMemoryRenderHandler (WebHostSharedMemory* shm) : m_shm (shm) { }
+
+void SharedMemoryRenderHandler::GetViewRect (CefRefPtr<CefBrowser> browser, CefRect& rect) {
+    const uint32_t width = std::clamp (this->m_shm->desiredWidth.load (std::memory_order_relaxed), 1u, this->m_shm->maxWidth);
+    const uint32_t height
+	= std::clamp (this->m_shm->desiredHeight.load (std::memory_order_relaxed), 1u, this->m_shm->maxHeight);
+
+    rect = CefRect (0, 0, static_cast<int> (width), static_cast<int> (height));
+}
+
+void SharedMemoryRenderHandler::OnPaint (
+    CefRefPtr<CefBrowser> browser, PaintElementType type, const RectList& dirtyRects, const void* buffer,
+    const int width, const int height
+) {
+    if (type != PET_VIEW) {
+	return;
+    }
+
+    // GetViewRect always clamps to maxWidth/maxHeight, so CEF should never hand back anything
+    // larger than the buffer's capacity - guard against it anyway rather than overflow the segment.
+    if (width <= 0 || height <= 0 || static_cast<uint32_t> (width) > this->m_shm->maxWidth
+	|| static_cast<uint32_t> (height) > this->m_shm->maxHeight) {
+	return;
+    }
+
+    // Seqlock: only this thread (CEF's UI thread, one browser per host process) ever writes, so the
+    // sequence is always even when we get here.
+    const uint32_t seq = this->m_shm->frameSeq.load (std::memory_order_relaxed);
+    this->m_shm->frameSeq.store (seq + 1, std::memory_order_release);
+    this->m_shm->frameWidth.store (static_cast<uint32_t> (width), std::memory_order_relaxed);
+    this->m_shm->frameHeight.store (static_cast<uint32_t> (height), std::memory_order_relaxed);
+    std::memcpy (this->m_shm->frameBuffer (), buffer, static_cast<size_t> (width) * height * 4);
+    this->m_shm->frameSeq.store (seq + 2, std::memory_order_release);
+}

+ 31 - 0
src/WallpaperEngine/WebBrowser/CEF/SharedMemoryRenderHandler.h

@@ -0,0 +1,31 @@
+#pragma once
+
+#include "WallpaperEngine/WebBrowser/IPC/WebHostSharedMemory.h"
+#include "include/cef_browser.h"
+#include "include/cef_render_handler.h"
+
+namespace WallpaperEngine::WebBrowser::CEF {
+// Used only inside the disposable web-host child process (see WallpaperApplication::runWebHost()).
+// Has no GL context and no CWeb to talk to directly - it writes CEF's off-screen-rendered pixels
+// straight into the shared memory segment the main process reads frames from. GetViewRect reports
+// whatever size the main process last asked for via shm - the caller (runWebHost()) is responsible
+// for calling CefBrowserHost::WasResized() when that changes.
+class SharedMemoryRenderHandler : public CefRenderHandler {
+public:
+    explicit SharedMemoryRenderHandler (WallpaperEngine::WebBrowser::IPC::WebHostSharedMemory* shm);
+
+    ~SharedMemoryRenderHandler () override = default;
+
+    void GetViewRect (CefRefPtr<CefBrowser> browser, CefRect& rect) override;
+
+    void OnPaint (
+	CefRefPtr<CefBrowser> browser, PaintElementType type, const RectList& dirtyRects, const void* buffer,
+	int width, int height
+    ) override;
+
+    IMPLEMENT_REFCOUNTING (SharedMemoryRenderHandler);
+
+private:
+    WallpaperEngine::WebBrowser::IPC::WebHostSharedMemory* m_shm;
+};
+} // namespace WallpaperEngine::WebBrowser::CEF

+ 7 - 18
src/WallpaperEngine/WebBrowser/CEF/SubprocessApp.cpp

@@ -1,30 +1,19 @@
 #include "SubprocessApp.h"
-#include "WPSchemeHandlerFactory.h"
-#include "WallpaperEngine/Data/Model/Project.h"
+#include "WallpaperEngine/WebBrowser/WebBrowserContext.h"
 
 using namespace WallpaperEngine::WebBrowser::CEF;
 
 SubprocessApp::SubprocessApp (WallpaperEngine::Application::WallpaperApplication& application) :
-    m_application (application) {
-    for (const auto& info : this->m_application.getBackgrounds () | std::views::values) {
-	this->m_handlerFactories[info->workshopId] = new WPSchemeHandlerFactory (*info);
-    }
-}
+    m_application (application) { }
 
 void SubprocessApp::OnRegisterCustomSchemes (CefRawPtr<CefSchemeRegistrar> registrar) {
-    // register all the needed schemes, "wp" + the background id is going to be our scheme
-    for (const auto& workshopId : this->m_handlerFactories | std::views::keys) {
-	registrar->AddCustomScheme (
-	    WPSchemeHandlerFactory::generateSchemeName (workshopId),
-	    CEF_SCHEME_OPTION_STANDARD | CEF_SCHEME_OPTION_SECURE | CEF_SCHEME_OPTION_FETCH_ENABLED
-	);
-    }
+    // One fixed scheme shared by every web wallpaper; the factory resolves the project per
+    // request by host (workshop id).
+    registrar->AddCustomScheme (
+	WPENGINE_SCHEME, CEF_SCHEME_OPTION_STANDARD | CEF_SCHEME_OPTION_SECURE | CEF_SCHEME_OPTION_FETCH_ENABLED
+    );
 }
 
 const WallpaperEngine::Application::WallpaperApplication& SubprocessApp::getApplication () const {
     return this->m_application;
-}
-
-const std::map<std::string, WPSchemeHandlerFactory*>& SubprocessApp::getHandlerFactories () const {
-    return this->m_handlerFactories;
 }

+ 0 - 2
src/WallpaperEngine/WebBrowser/CEF/SubprocessApp.h

@@ -17,10 +17,8 @@ public:
 
 protected:
     const WallpaperEngine::Application::WallpaperApplication& getApplication () const;
-    const std::map<std::string, WPSchemeHandlerFactory*>& getHandlerFactories () const;
 
 private:
-    std::map<std::string, WPSchemeHandlerFactory*> m_handlerFactories = {};
     WallpaperEngine::Application::WallpaperApplication& m_application;
     IMPLEMENT_REFCOUNTING (SubprocessApp);
     DISALLOW_COPY_AND_ASSIGN (SubprocessApp);

+ 14 - 10
src/WallpaperEngine/WebBrowser/CEF/WPSchemeHandler.cpp

@@ -15,9 +15,6 @@ WPSchemeHandler::WPSchemeHandler (const Project& project) :
 bool WPSchemeHandler::Open (CefRefPtr<CefRequest> request, bool& handle_request, CefRefPtr<CefCallback> callback) {
     DCHECK (!CefCurrentlyOn (TID_UI) && !CefCurrentlyOn (TID_IO));
 
-#if !NDEBUG
-    std::cout << "Processing request for path " << request->GetURL ().c_str () << std::endl;
-#endif
     // url contains the full path, we need to get rid of the protocol
     // otherwise files won't be found
     CefURLParts parts;
@@ -27,10 +24,19 @@ bool WPSchemeHandler::Open (CefRefPtr<CefRequest> request, bool& handle_request,
 	return false;
     }
 
-    const std::string host = CefString (&parts.host);
-    const std::string path = CefString (&parts.path);
+    std::cout << "Processing request for path " << request->GetURL ().c_str () << std::endl;
+
+    // CefParseURL hands back the path exactly as it appeared in the URL, still percent-encoded -
+    // filenames with spaces or other escaped characters (e.g. "Corin%20[M3].png") would otherwise
+    // never match the real file ("Corin [M3].png") on disk.
+    const std::string path = CefURIDecode (
+	CefString (&parts.path), false,
+	static_cast<cef_uri_unescape_rule_t> (UU_SPACES | UU_URL_SPECIAL_CHARS_EXCEPT_PATH_SEPARATORS)
+    );
 
-    const std::string file = path.substr (1);
+    // path is "/<file>", relative to this wallpaper's own origin (wp://w<id>/...) - the host
+    // already picked this handler's m_project, so there's no workshop id prefix to strip here.
+    const std::string file = path.size () > 1 ? path.substr (1) : path;
 
     try {
 	// try to read the file on the current container, if the file doesn't exists
@@ -43,10 +49,8 @@ bool WPSchemeHandler::Open (CefRefPtr<CefRequest> request, bool& handle_request,
 
 	this->m_contents = this->m_assetLoader.read (file);
 	callback->Continue ();
-    } catch (AssetLoadException&) {
-#if !NDEBUG
-	std::cout << "Cannot read file " << file << std::endl;
-#endif
+    } catch (AssetLoadException& e) {
+	std::cout << "Cannot read file " << file << ": " << e.what () << std::endl;
     }
 
     handle_request = true;

+ 2 - 1
src/WallpaperEngine/WebBrowser/CEF/WPSchemeHandler.h

@@ -17,7 +17,8 @@ using namespace WallpaperEngine::Assets;
 using namespace WallpaperEngine::Data::Model;
 
 /**
- * wp{id}:// actual handler called by cef to access files
+ * Serves one wallpaper's files under WPENGINE_SCHEME, bound to that wallpaper's Project by
+ * WPSchemeHandlerFactory::Create once it's resolved the request's host to a workshop id.
  */
 class WPSchemeHandler : public CefResourceHandler {
 public:

+ 35 - 5
src/WallpaperEngine/WebBrowser/CEF/WPSchemeHandlerFactory.cpp

@@ -1,20 +1,50 @@
 #include "WPSchemeHandlerFactory.h"
 #include "WPSchemeHandler.h"
+#include "WallpaperEngine/Application/WallpaperApplication.h"
+#include "WallpaperEngine/Data/Model/Project.h"
 #include "WallpaperEngine/WebBrowser/WebBrowserContext.h"
+#include "include/cef_parser.h"
 #include "include/wrapper/cef_helpers.h"
+#include <iostream>
+#include <ranges>
 
 using namespace WallpaperEngine::WebBrowser::CEF;
 
-WPSchemeHandlerFactory::WPSchemeHandlerFactory (const Project& project) : m_project (project) { }
+WPSchemeHandlerFactory::WPSchemeHandlerFactory (const WallpaperEngine::Application::WallpaperApplication& application) :
+    m_application (application) { }
 
 CefRefPtr<CefResourceHandler> WPSchemeHandlerFactory::Create (
     CefRefPtr<CefBrowser> browser, CefRefPtr<CefFrame> frame, const CefString& scheme_name,
     CefRefPtr<CefRequest> request
 ) {
     CEF_REQUIRE_IO_THREAD ();
-    return new WPSchemeHandler (this->m_project);
-}
 
-std::string WPSchemeHandlerFactory::generateSchemeName (const std::string& workshopId) {
-    return std::string (WPENGINE_SCHEME) + workshopId;
+    CefURLParts parts;
+
+    if (!CefParseURL (request->GetURL (), parts)) {
+	return nullptr;
+    }
+
+    // Hosts are "w<workshopId>" (see CWeb.cpp) - the leading letter keeps the host from being
+    // purely numeric, since Chromium's URL canonicalizer treats an all-digit host as a legacy
+    // decimal-form IPv4 address and silently rewrites it, which would otherwise break every
+    // workshopId == host comparison below.
+    const std::string host = CefString (&parts.host);
+
+    if (host.size () < 2 || host[0] != 'w') {
+	return nullptr;
+    }
+
+    const std::string workshopId = host.substr (1);
+
+    for (const auto& info : this->m_application.getBackgrounds () | std::views::values) {
+	if (info->workshopId == workshopId) {
+	    return new WPSchemeHandler (*info);
+	}
+    }
+
+    std::cout << "WPSchemeHandlerFactory: no loaded project for workshop id " << workshopId
+	       << " (request: " << request->GetURL ().ToString () << ")" << std::endl;
+
+    return nullptr;
 }

+ 10 - 9
src/WallpaperEngine/WebBrowser/CEF/WPSchemeHandlerFactory.h

@@ -3,29 +3,30 @@
 #include "include/cef_scheme.h"
 #include <string>
 
-namespace WallpaperEngine::Data::Model {
-struct Project;
+namespace WallpaperEngine::Application {
+class WallpaperApplication;
 }
 
 namespace WallpaperEngine::WebBrowser::CEF {
-using namespace WallpaperEngine::Data::Model;
-
 /**
- * Simple factory that creates a scheme handler for wp when requested by Cef
+ * Serves every web wallpaper's files under one fixed scheme (WPENGINE_SCHEME), resolving which
+ * wallpaper a request belongs to from its URL host (the workshop id) at request time. This has
+ * to stay dynamic rather than bound to one wallpaper at construction: CEF only allows declaring
+ * valid custom scheme names once, very early during CefInitialize, so a scheme registered for one
+ * workshop id could never cover a different web wallpaper hotswapped in later in the same
+ * process - hence one shared scheme with a runtime lookup instead of one scheme per id.
  */
 class WPSchemeHandlerFactory : public CefSchemeHandlerFactory {
 public:
-    explicit WPSchemeHandlerFactory (const Project& project);
+    explicit WPSchemeHandlerFactory (const WallpaperEngine::Application::WallpaperApplication& application);
 
     CefRefPtr<CefResourceHandler> Create (
 	CefRefPtr<CefBrowser> browser, CefRefPtr<CefFrame> frame, const CefString& scheme_name,
 	CefRefPtr<CefRequest> request
     ) override;
 
-    static std::string generateSchemeName (const std::string& workshopId);
-
 private:
-    const Project& m_project;
+    const WallpaperEngine::Application::WallpaperApplication& m_application;
 
     IMPLEMENT_REFCOUNTING (WPSchemeHandlerFactory);
     DISALLOW_COPY_AND_ASSIGN (WPSchemeHandlerFactory);

+ 87 - 0
src/WallpaperEngine/WebBrowser/IPC/WebHostSharedMemory.cpp

@@ -0,0 +1,87 @@
+#include "WebHostSharedMemory.h"
+
+#include <atomic>
+#include <fcntl.h>
+#include <new>
+#include <sys/mman.h>
+#include <unistd.h>
+
+#include "WallpaperEngine/Logging/Log.h"
+
+using namespace WallpaperEngine::WebBrowser::IPC;
+
+namespace {
+std::atomic<uint32_t> g_nameCounter { 0 };
+
+std::string generateName () {
+    return "/lwe-web-" + std::to_string (getpid ()) + "-" + std::to_string (g_nameCounter.fetch_add (1));
+}
+} // namespace
+
+WebHostSharedMemory* WallpaperEngine::WebBrowser::IPC::createSharedMemory (
+    std::string& outName, uint32_t width, uint32_t height
+) {
+    const std::size_t size = WebHostSharedMemory::totalSize (width, height);
+    outName = generateName ();
+
+    const int fd = shm_open (outName.c_str (), O_CREAT | O_EXCL | O_RDWR, 0600);
+    if (fd < 0) {
+	sLog.error ("Failed to create shared memory segment ", outName);
+	return nullptr;
+    }
+
+    if (ftruncate (fd, static_cast<off_t> (size)) != 0) {
+	sLog.error ("Failed to size shared memory segment ", outName);
+	close (fd);
+	shm_unlink (outName.c_str ());
+	return nullptr;
+    }
+
+    void* mapping = mmap (nullptr, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
+    close (fd);
+
+    if (mapping == MAP_FAILED) {
+	sLog.error ("Failed to map shared memory segment ", outName);
+	shm_unlink (outName.c_str ());
+	return nullptr;
+    }
+
+    auto* mem = new (mapping) WebHostSharedMemory ();
+    mem->maxWidth = width;
+    mem->maxHeight = height;
+
+    return mem;
+}
+
+WebHostSharedMemory*
+WallpaperEngine::WebBrowser::IPC::attachSharedMemory (const std::string& name, uint32_t width, uint32_t height) {
+    const std::size_t size = WebHostSharedMemory::totalSize (width, height);
+
+    const int fd = shm_open (name.c_str (), O_RDWR, 0600);
+    if (fd < 0) {
+	sLog.error ("Failed to open shared memory segment ", name);
+	return nullptr;
+    }
+
+    void* mapping = mmap (nullptr, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
+    close (fd);
+
+    if (mapping == MAP_FAILED) {
+	sLog.error ("Failed to map shared memory segment ", name);
+	return nullptr;
+    }
+
+    return static_cast<WebHostSharedMemory*> (mapping);
+}
+
+void WallpaperEngine::WebBrowser::IPC::closeSharedMemory (
+    WebHostSharedMemory* mem, const std::string& name, uint32_t width, uint32_t height, bool unlinkSegment
+) {
+    if (mem != nullptr) {
+	munmap (mem, WebHostSharedMemory::totalSize (width, height));
+    }
+
+    if (unlinkSegment) {
+	shm_unlink (name.c_str ());
+    }
+}

+ 68 - 0
src/WallpaperEngine/WebBrowser/IPC/WebHostSharedMemory.h

@@ -0,0 +1,68 @@
+#pragma once
+
+#include <atomic>
+#include <cstddef>
+#include <cstdint>
+#include <string>
+
+namespace WallpaperEngine::WebBrowser::IPC {
+// Shared-memory transport between the main engine process (reads frames, writes input/size) and a
+// disposable "web host" child process that owns the actual CEF browser (writes frames, reads
+// input/size). One segment per CWeb instance. The pixel buffer is allocated once at maxWidth x
+// maxHeight (the wallpaper's maxRenderSize - see CWeb), but the browser can be resized within that
+// cap for the lifetime of the host process by changing desiredWidth/desiredHeight; actual painted
+// content may be smaller than the cap and is described by frameWidth/frameHeight.
+struct WebHostSharedMemory {
+    // Seqlock guarding the pixel buffer + frameWidth/frameHeight: the writer (host process) bumps
+    // this to an odd value before writing and to the next even value after. A reader must retry if
+    // it observes an odd value, or if the value changed between the start and end of its read.
+    std::atomic<uint32_t> frameSeq { 0 };
+    std::atomic<uint32_t> frameWidth { 0 };
+    std::atomic<uint32_t> frameHeight { 0 };
+
+    // Main process -> host process: desired browser viewport size. The host polls this and calls
+    // CefBrowserHost::WasResized() when it changes.
+    std::atomic<uint32_t> desiredWidth { 0 };
+    std::atomic<uint32_t> desiredHeight { 0 };
+
+    // Host process -> main process
+    std::atomic<bool> helperReady { false };
+    std::atomic<bool> helperFailed { false };
+
+    // Main process -> host process
+    std::atomic<bool> quitRequested { false };
+    std::atomic<double> mouseX { 0.0 };
+    std::atomic<double> mouseY { 0.0 };
+    // Matches WallpaperEngine::Input::MouseClickStatus (Released = 0, Clicked = 1)
+    std::atomic<int32_t> leftClick { 0 };
+    std::atomic<int32_t> rightClick { 0 };
+
+    // Fixed capacity of the buffer below, set once at creation - never changes for the lifetime of
+    // the segment.
+    uint32_t maxWidth = 0;
+    uint32_t maxHeight = 0;
+
+    // BGRA8 pixel data, maxWidth * maxHeight * 4 bytes capacity, immediately follows this header in
+    // the same mapping. Only the top-left frameWidth x frameHeight of it holds live content.
+    [[nodiscard]] uint8_t* frameBuffer () { return reinterpret_cast<uint8_t*> (this) + sizeof (WebHostSharedMemory); }
+
+    [[nodiscard]] static std::size_t totalSize (uint32_t maxWidth, uint32_t maxHeight) {
+	return sizeof (WebHostSharedMemory) + static_cast<std::size_t> (maxWidth) * maxHeight * 4;
+    }
+};
+
+// Creates a new, zero-initialized segment with capacity for maxWidth*maxHeight BGRA8 and maps it.
+// Fills outName with a unique shm name the host process can be told to attach to. Returns nullptr
+// on failure.
+WebHostSharedMemory* createSharedMemory (std::string& outName, uint32_t maxWidth, uint32_t maxHeight);
+
+// Attaches to a segment created by createSharedMemory() elsewhere (a different process). maxWidth
+// and maxHeight must match what the creator used. Returns nullptr on failure.
+WebHostSharedMemory* attachSharedMemory (const std::string& name, uint32_t maxWidth, uint32_t maxHeight);
+
+// Unmaps a segment obtained from either function above. Only the creator should pass
+// unlinkSegment=true (removes the shm name once both sides are done with it).
+void closeSharedMemory (
+    WebHostSharedMemory* mem, const std::string& name, uint32_t maxWidth, uint32_t maxHeight, bool unlinkSegment
+);
+} // namespace WallpaperEngine::WebBrowser::IPC

+ 1 - 0
src/WallpaperEngine/WebBrowser/WebBrowserContext.cpp

@@ -1,5 +1,6 @@
 #include "WebBrowserContext.h"
 #include "CEF/BrowserApp.h"
+#include "WallpaperEngine/Application/WallpaperApplication.h"
 #include "WallpaperEngine/Logging/Log.h"
 #include "WallpaperEngine/WebBrowser/CEF/SubprocessApp.h"
 #include "include/cef_app.h"

+ 4 - 1
src/WallpaperEngine/WebBrowser/WebBrowserContext.h

@@ -1,13 +1,16 @@
 #pragma once
 
 #include "WallpaperEngine/Application/ApplicationContext.h"
-#include "WallpaperEngine/Application/WallpaperApplication.h"
 #include "include/cef_app.h"
 #include "include/cef_browser_process_handler.h"
 #include "include/wrapper/cef_helpers.h"
 
 #define WPENGINE_SCHEME "wp"
 
+namespace WallpaperEngine::Application {
+class WallpaperApplication;
+}
+
 namespace WallpaperEngine::WebBrowser::CEF {
 class BrowserApp;
 }

+ 8 - 0
src/main.cpp

@@ -54,6 +54,14 @@ int main (int argc, char* argv[]) {
 	    return 0;
 	}
 
+	// Disposable CEF host for a single Web wallpaper - runs its own loop until told to quit
+	// over shared memory, never touches Wayland/GL/audio.
+	if (appContext.settings.general.webHost) {
+	    app->runWebHost ();
+	    delete app;
+	    return 0;
+	}
+
 	std::signal (SIGINT, signalhandler);
 	std::signal (SIGTERM, signalhandler);
 	std::signal (SIGKILL, signalhandler);