diff --git a/ChangeLog.md b/ChangeLog.md
new file mode 100644
--- /dev/null
+++ b/ChangeLog.md
@@ -0,0 +1,9 @@
+# Revision history for jsaddle-webkitgtk
+
+## 0.10.0.0
+
+* Ported to GTK 4 and WebKitGTK 6.0 (gi-gtk4, gi-webkit 6.x,
+  gi-javascriptcore6), replacing GTK 3 / WebKitGTK 3.  `run` keeps its
+  type; `runInWebView` now takes a WebKitGTK 6.0 `WebView`.  Native builds
+  are Linux only (the JavaScript backend stub builds anywhere).
+* jsaddle-webkit2gtk (GTK 3 / webkit2gtk 4.0) remains for GTK 3 apps.
diff --git a/demo/Main.hs b/demo/Main.hs
new file mode 100644
--- /dev/null
+++ b/demo/Main.hs
@@ -0,0 +1,87 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ExtendedDefaultRules #-}
+{-# OPTIONS_GHC -Wno-type-defaults #-}
+-- | Minimal jsaddle-webkitgtk demo: a click counter (exercises the
+--   synchronous prompt bridge via the callback) and an uptime ticker
+--   (exercises async batches from a background Haskell thread).
+--
+--   With @--smoke@ it also asserts a full round trip through the bridge
+--   (async eval + result, and a Haskell callback invoked from JS) and
+--   exits 0/1 — suitable for headless CI under @xvfb-run@.
+module Main (main) where
+
+import Control.Concurrent (forkIO, threadDelay)
+import Control.Monad (forever, void, when)
+import Control.Monad.IO.Class (liftIO)
+import Data.IORef (atomicModifyIORef', newIORef, readIORef, writeIORef)
+import qualified Data.Text as T
+import System.Environment (getArgs)
+import System.Exit (ExitCode(..))
+import System.IO (hSetBuffering, stdout, BufferMode(LineBuffering))
+import System.Posix.Process (exitImmediately)
+import Language.Javascript.JSaddle
+import Language.Javascript.JSaddle.WebKitGTK (run)
+
+main :: IO ()
+main = do
+    hSetBuffering stdout LineBuffering
+    smoke <- elem "--smoke" <$> getArgs
+    -- exitImmediately: exitSuccess/exitFailure only throw in the calling
+    -- thread, and everything here runs off the GTK main loop's thread.
+    when smoke . void . forkIO $ do
+        threadDelay 60000000
+        putStrLn "SMOKE-TIMEOUT"
+        exitImmediately (ExitFailure 1)
+    run $ do
+        doc <- jsg "document"
+        body <- doc ! "body"
+        void $ body # "insertAdjacentHTML" $
+            ( "beforeend"
+            , "<h1>jsaddle-webkitgtk demo</h1>\
+              \<p>Uptime: <span id=\"uptime\">0</span>s</p>\
+              \<p><button id=\"btn\">Clicked <span id=\"count\">0</span> times</button></p>"
+            )
+
+        clicks <- liftIO $ newIORef (0 :: Int)
+        btn <- doc # "getElementById" $ ["btn"]
+        void $ btn # "addEventListener" $
+            ( "click"
+            , fun $ \_ _ _ -> do
+                n <- liftIO $ atomicModifyIORef' clicks $ \c -> (c + 1, c + 1)
+                el <- doc # "getElementById" $ ["count"]
+                el <# "textContent" $ T.pack (show n)
+            )
+
+        seconds <- liftIO $ newIORef (0 :: Int)
+        ctx <- askJSM
+        void . liftIO . forkIO . forever $ do
+            threadDelay 1000000
+            s <- atomicModifyIORef' seconds $ \c -> (c + 1, c + 1)
+            runJSaddle ctx $ do
+                el <- jsg "document" # "getElementById" $ ["uptime"]
+                el <# "textContent" $ T.pack (show s)
+
+        when smoke $ do
+            v <- eval "6 * 7"
+            n <- valToNumber v
+            liftIO . putStrLn $ "SMOKE eval 6*7 = " ++ show n
+            -- A Haskell callback invoked from an eval'd script goes through
+            -- the synchronous JSaddleSync prompt path.
+            got <- liftIO $ newIORef (0 :: Double)
+            _ <- jsg "globalThis" <# "hsSmoke" $ fun $ \_ _ as -> case as of
+                (a:_) -> valToNumber a >>= liftIO . writeIORef got
+                _     -> return ()
+            _ <- eval "globalThis.hsSmoke(123)"
+            syncPoint
+            liftIO $ do
+                -- callbacks run on forked threads; give it a moment
+                let waitCb 0 = readIORef got
+                    waitCb k = do
+                        x <- readIORef got
+                        if x == 123 then return x
+                                    else threadDelay 100000 >> waitCb (k - 1 :: Int)
+                x <- waitCb 100
+                putStrLn $ "SMOKE callback got " ++ show x
+                if n == 42 && x == 123
+                    then putStrLn "SMOKE-OK" >> exitImmediately ExitSuccess
+                    else putStrLn "SMOKE-FAIL" >> exitImmediately (ExitFailure 1)
diff --git a/jsaddle-webkitgtk.cabal b/jsaddle-webkitgtk.cabal
--- a/jsaddle-webkitgtk.cabal
+++ b/jsaddle-webkitgtk.cabal
@@ -1,16 +1,17 @@
 name: jsaddle-webkitgtk
-version: 0.9.0.0
+version: 0.10.0.0
 cabal-version: >=1.10
 build-type: Simple
 license: MIT
 license-file: LICENSE
+extra-source-files: ChangeLog.md
 maintainer: Hamish Mackenzie <Hamish.K.Mackenzie@googlemail.com>
 synopsis: Interface for JavaScript that works with GHCJS and GHC
 description:
-    This package provides an EDSL for calling JavaScript that
-    can be used both from GHCJS and GHC.  When using GHC
-    the application is run using Warp and WebSockets to
-    drive a small JavaScipt helper.
+    jsaddle backend hosting the app in a GTK4 window containing a
+    WebKitGTK 6.0 WebView (gi-gtk4 / gi-webkit 6.x).  The modern
+    replacement for jsaddle-webkit2gtk (GTK3 / webkit2gtk 4.0, which has
+    been removed from nixpkgs).
 category: Web, Javascript
 author: Hamish Mackenzie
 
@@ -23,22 +24,42 @@
     exposed-modules:
         Language.Javascript.JSaddle.WebKitGTK
     build-depends:
-        aeson >=0.8.0.2 && <1.3,
-        base <5,
-        bytestring >=0.10.6.0 && <0.11,
+        base <5
+    if !impl(ghcjs -any) && !arch(javascript)
+      build-depends:
+        aeson >=0.8.0.2 && <2.4,
+        bytestring >=0.10.6.0 && <0.13,
         directory >=1.0.0.2 && <1.4,
-        gi-glib >=2.0.6 && <2.1,
-        gi-gtk >=3.0.6 && <3.1,
-        gi-webkit >=3.0.6 && <3.1,
-        gi-javascriptcore >=3.0.6 && <3.1,
-        haskell-gi-base >=0.20 && <0.21,
-        jsaddle >=0.9.0.0 && <0.10,
-        text >=1.2.1.3 && <1.3,
-        webkitgtk3-javascriptcore >=0.14.0.0 && <0.15
+        gi-glib >=2.0.14 && <2.1,
+        gi-gio >=2.0.14 && <2.1,
+        gi-gtk4 >=4.0.8 && <4.1,
+        gi-webkit >=6.0.2 && <6.1,
+        gi-javascriptcore6 >=6.0.2 && <6.1,
+        haskell-gi-base >=0.26 && <0.27,
+        jsaddle >=0.9.9.0 && <0.10,
+        text >=1.2.1.3 && <1.3 || >= 2.0 && < 2.2
     if !os(windows)
       build-depends:
-        unix >=2.3.1.0 && <2.8
+        unix >=2.3.1.0 && <2.9
+    -- Native builds need webkitgtk 6.0 (GTK4): Linux only.
+    -- (The ghcjs/javascript stub builds anywhere.)
+    if !os(linux) && !impl(ghcjs) && !arch(javascript)
+      buildable: False
     default-language: Haskell2010
     hs-source-dirs: src
     ghc-options: -ferror-spans -Wall
 
+executable jsaddle-webkitgtk-demo
+    main-is: Main.hs
+    hs-source-dirs: demo
+    build-depends:
+        base <5,
+        jsaddle,
+        jsaddle-webkitgtk,
+        text,
+        unix
+    -- Native-only (no ghcjs stub): a plain WebKitGTK window app.
+    if !os(linux)
+      buildable: False
+    default-language: Haskell2010
+    ghc-options: -ferror-spans -Wall -threaded
diff --git a/src/Language/Javascript/JSaddle/WebKitGTK.hs b/src/Language/Javascript/JSaddle/WebKitGTK.hs
--- a/src/Language/Javascript/JSaddle/WebKitGTK.hs
+++ b/src/Language/Javascript/JSaddle/WebKitGTK.hs
@@ -3,15 +3,16 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE PatternSynonyms #-}
 {-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE MonoLocalBinds #-}
 -----------------------------------------------------------------------------
 --
--- Module      :  Language.Javascript.JSaddle.WebSockets
+-- Module      :  Language.Javascript.JSaddle.WebKitGTK
 -- Copyright   :  (c) Hamish Mackenzie
 -- License     :  MIT
 --
 -- Maintainer  :  Hamish Mackenzie <Hamish.K.Mackenzie@googlemail.com>
 --
--- |
+-- | Run jsaddle apps in a GTK4 window hosting a WebKitGTK 6.0 WebView.
 --
 -----------------------------------------------------------------------------
 module Language.Javascript.JSaddle.WebKitGTK (
@@ -30,9 +31,7 @@
 
 #else
 
-import Control.Monad (when, void)
-import qualified Control.Exception as E (catch)
-import Control.Exception (SomeException(..))
+import Control.Monad (void)
 import Control.Concurrent (forkIO, yield)
 
 #ifndef mingw32_HOST_OS
@@ -40,191 +39,128 @@
 #endif
 import System.Directory (getCurrentDirectory)
 
-import Data.Monoid ((<>))
 import Data.ByteString.Lazy (toStrict, fromStrict)
 import qualified Data.ByteString.Lazy as LB (ByteString)
 import Data.Aeson (encode, decode)
-import Data.Text (Text)
-import qualified Data.Text as T (unpack, pack)
-import Data.Text.Foreign (useAsPtr, fromPtr)
+import qualified Data.Text as T (pack)
 import Data.Text.Encoding (decodeUtf8, encodeUtf8)
 
-import Foreign.Ptr (castPtr, nullPtr)
-import Foreign.Marshal.Array (withArray, peekArray)
-import Foreign.Storable (Storable(..))
-import Foreign.Marshal.Alloc (alloca)
-
-import Data.GI.Base.ManagedPtr (withManagedPtr)
 import GI.GLib
-       (timeoutAdd, idleAdd, pattern PRIORITY_HIGH, pattern PRIORITY_DEFAULT)
-import qualified GI.Gtk as Gtk (main, init)
+       (timeoutAdd, idleAdd, mainLoopNew, mainLoopRun, mainLoopQuit,
+        pattern PRIORITY_HIGH, pattern PRIORITY_DEFAULT)
+import qualified GI.Gtk as Gtk (init)
 import GI.Gtk
-       (windowSetPosition, windowSetDefaultSize, windowNew, scrolledWindowNew,
-        noAdjustment, containerAdd, WindowType(..), WindowPosition(..),
-        widgetDestroy, widgetGetToplevel, widgetShowAll, onWidgetDestroy,
-        mainQuit)
-import GI.JavaScriptCore.Structs.GlobalContext (GlobalContext(..))
+       (Window, windowNew, windowSetDefaultSize, windowSetChild,
+        windowPresent, windowDestroy, scrolledWindowNew,
+        scrolledWindowSetChild, onWidgetDestroy)
+import GI.Gio (Cancellable)
+import GI.JavaScriptCore (valueToString)
 import GI.WebKit
-       (WebView, webViewGetMainFrame, webFrameGetGlobalContext,
-        onWebInspectorInspectWebView, webViewGetInspector, webViewSetSettings,
-        setWebSettingsEnableDeveloperExtras,
-        setWebSettingsEnableUniversalAccessFromFileUris,
-        webViewGetSettings, webViewNew, onWebViewLoadFinished,
-        webViewLoadString)
-
-import Graphics.UI.Gtk.WebKit.JavaScriptCore.JSBase
-       (JSValueRef, JSValueRefRef, JSObjectRef, JSContextRef,
-        jsevaluatescript, JSStringRef)
-import Graphics.UI.Gtk.WebKit.JavaScriptCore.JSStringRef
-       (jsstringretain, jsstringrelease, jsstringgetcharactersptr,
-        jsstringgetlength, jsstringcreatewithcharacters)
-import Graphics.UI.Gtk.WebKit.JavaScriptCore.JSObjectRef
-       (jsobjectcallasfunction, JSCSize, jsobjectsetproperty,
-        jsobjectmakefunctionwithcallback, mkJSObjectCallAsFunctionCallback)
-import Graphics.UI.Gtk.WebKit.JavaScriptCore.JSContextRef
-       (jscontextgetglobalobject)
-import Graphics.UI.Gtk.WebKit.JavaScriptCore.JSValueRef
-       (jsvalueprotect, jsvalueunprotect,
-        jsvaluetostringcopy, jsvaluemakestring, jsvaluemakeundefined)
+       (scriptDialogPromptSetText, scriptDialogPromptGetDefaultText,
+        scriptDialogGetMessage, scriptDialogGetDialogType,
+        onWebViewScriptDialog, WebView, webViewNew,
+        setSettingsEnableWriteConsoleMessagesToStdout,
+        setSettingsEnableJavascript,
+        onUserContentManagerScriptMessageReceived,
+        userContentManagerRegisterScriptMessageHandler,
+        webViewGetUserContentManager,
+        webViewEvaluateJavascript, LoadEvent(..), webViewLoadHtml,
+        onWebViewLoadChanged, setSettingsEnableDeveloperExtras,
+        webViewSetSettings, webViewGetSettings, ScriptDialogType(..))
 
-import Language.Javascript.JSaddle (JSM, Results)
+import Language.Javascript.JSaddle (JSM, Results, Batch)
 import Language.Javascript.JSaddle.Run (runJavaScript)
 import Language.Javascript.JSaddle.Run.Files (initState, runBatch, ghcjsHelpers)
 
-quitWebView :: WebView -> IO ()
-quitWebView wv = postGUIAsync $ do w <- widgetGetToplevel wv --TODO: Shouldn't this be postGUISync?
-                                   widgetDestroy w
+noCancellable :: Maybe Cancellable
+noCancellable = Nothing
 
-installQuitHandler :: WebView -> IO ()
+quitWindow :: Window -> IO ()
+quitWindow window = postGUIAsync $ windowDestroy window
+
+installQuitHandler :: Window -> IO ()
 #ifdef mingw32_HOST_OS
-installQuitHandler wv = return () -- TODO: Maybe figure something out here for Windows users.
+installQuitHandler _ = return () -- TODO: Maybe figure something out here for Windows users.
 #else
-installQuitHandler wv = void $ installHandler keyboardSignal (Catch (quitWebView wv)) Nothing
+installQuitHandler window = void $ installHandler keyboardSignal (Catch (quitWindow window)) Nothing
 #endif
 
 postGUIAsync :: IO () -> IO ()
 postGUIAsync action =
   void . idleAdd PRIORITY_DEFAULT $ action >> return False
 
-withJSString :: Text -> (JSStringRef -> IO a) -> IO a
-withJSString s f = do
-    jsstring <- useAsPtr s $ \p l -> jsstringcreatewithcharacters (castPtr p) (fromIntegral l)
-    _ <- jsstringretain jsstring
-    result <- f jsstring
-    jsstringrelease jsstring
-    return result
-
-withJSContextRef :: GlobalContext -> (JSContextRef -> IO a) -> IO a
-withJSContextRef (GlobalContext ctx) f = withManagedPtr ctx (f . castPtr)
-
-valueToText :: JSContextRef -> JSValueRef -> IO Text
-valueToText ctxRef s = do
-    jsstring <- jsvaluetostringcopy ctxRef s nullPtr
-    l <- jsstringgetlength jsstring
-    p <- jsstringgetcharactersptr jsstring
-    result <- fromPtr (castPtr p) (fromIntegral l)
-    jsstringrelease jsstring
-    return result
-
-check :: JSContextRef -> (JSValueRefRef -> IO a) -> IO a
-check ctxRef f =
-    alloca $ \exceptionPtr -> do
-        poke exceptionPtr nullPtr
-        result <- f exceptionPtr
-        exception <- peek exceptionPtr
-        when (exception /= nullPtr) $ do
-            errorText <- valueToText ctxRef exception
-            error $ T.unpack errorText
-        return result
-
-eval :: JSContextRef -> Text -> IO JSValueRef
-eval ctxRef script =
-    withJSString script $ \script' ->
-        check ctxRef $ jsevaluatescript ctxRef script' nullPtr nullPtr 0
-
 run :: JSM () -> IO ()
 run main = do
-    _ <- Gtk.init Nothing
-    window <- windowNew WindowTypeToplevel
+    Gtk.init
+    loop <- mainLoopNew Nothing False
     _ <- timeoutAdd PRIORITY_HIGH 10 (yield >> return True)
+    window <- windowNew
     windowSetDefaultSize window 900 600
-    windowSetPosition window WindowPositionCenter
-    scrollWin <- scrolledWindowNew noAdjustment noAdjustment
+    scrollWin <- scrolledWindowNew
     webView <- webViewNew
     settings <- webViewGetSettings webView
-    setWebSettingsEnableUniversalAccessFromFileUris settings True
-    setWebSettingsEnableDeveloperExtras settings True
+    setSettingsEnableDeveloperExtras settings True
+    setSettingsEnableJavascript settings True
+    setSettingsEnableWriteConsoleMessagesToStdout settings True
     webViewSetSettings webView settings
-    window `containerAdd` scrollWin
-    scrollWin `containerAdd` webView
-    _ <- onWidgetDestroy window mainQuit
-    widgetShowAll window
-    inspector <- webViewGetInspector webView
-    _ <- onWebInspectorInspectWebView inspector $ \_ -> do
-        inspectorWindow <- windowNew WindowTypeToplevel
-        windowSetDefaultSize inspectorWindow 900 600
-        inspectorScrollWin <- scrolledWindowNew noAdjustment noAdjustment
-        inspectorWebView <- webViewNew
-        inspectorWindow `containerAdd` inspectorScrollWin
-        inspectorScrollWin `containerAdd` inspectorWebView
-        widgetShowAll inspectorWindow
-        return inspectorWebView
+    scrolledWindowSetChild scrollWin (Just webView)
+    windowSetChild window (Just scrollWin)
+    _ <- onWidgetDestroy window $ mainLoopQuit loop
+    windowPresent window
     pwd <- getCurrentDirectory
-    void . onWebViewLoadFinished webView $ \ _ ->
-        runInWebView main webView
-    webViewLoadString webView "" "text/html" "UTF-8" $ "file://" <> T.pack pwd <> "/"
-    installQuitHandler webView
-    Gtk.main
+    let webViewLoadChangedCallback LoadEventFinished = runInWebView main webView
+        webViewLoadChangedCallback _                 = return ()
+    _ <- onWebViewLoadChanged webView webViewLoadChangedCallback
+    webViewLoadHtml webView "" . Just $ "file://" <> T.pack pwd <> "/index.html"
+    installQuitHandler window
+    mainLoopRun loop
 
 runInWebView :: JSM () -> WebView -> IO ()
 runInWebView f webView = do
-    ctx <- webViewGetMainFrame webView >>= webFrameGetGlobalContext
-    runJSaddleBatch <-
-        withJSContextRef ctx $ \ctxRef -> do
-            result <- eval ctxRef (decodeUtf8 $ toStrict jsaddleJs)
-            jsvalueprotect ctxRef result
-            return result
-
-    (processResults, _, start) <- runJavaScript (\batch -> postGUIAsync $
-        withJSContextRef ctx $ \ctxRef ->
-            withJSString (decodeUtf8 . toStrict $ encode batch) $ \script -> do
-                val <- jsvaluemakestring ctxRef script
-                withArray [val] $ \args ->
-                    void . check ctxRef $ jsobjectcallasfunction ctxRef runJSaddleBatch nullPtr 1 args
-                jsvalueunprotect ctxRef val)
+    (processResults, syncResults, start) <- runJavaScript (\batch -> postGUIAsync $
+        webViewEvaluateJavascript webView
+            (decodeUtf8 . toStrict $ "runJSaddleBatch(" <> encode batch <> ");")
+            (-1) Nothing Nothing noCancellable Nothing)
         f
 
-    withJSContextRef ctx $ \ctxRef -> do
-        addJSaddleHandler ctxRef processResults
-        void $ forkIO start
+    addJSaddleHandler webView processResults syncResults
+    webViewEvaluateJavascript webView (decodeUtf8 $ toStrict jsaddleJs)
+        (-1) Nothing Nothing noCancellable . Just $
+            \_obj _asyncResult ->
+                void $ forkIO start
 
-addJSaddleHandler :: JSContextRef -> (Results -> IO ()) -> IO ()
-addJSaddleHandler ctxRef processResult = do
-    callback <- mkJSObjectCallAsFunctionCallback wrap
-    function <- jsobjectmakefunctionwithcallback ctxRef nullPtr callback
-    global <- jscontextgetglobalobject ctxRef
-    withJSString "jsaddleCallback" $ \propName ->
-        jsobjectsetproperty ctxRef global propName function 0 nullPtr
-  where
-    wrap :: JSContextRef -> JSObjectRef -> JSObjectRef -> JSCSize -> JSValueRefRef -> JSValueRefRef -> IO JSValueRef
-    wrap _ctx _fobj _this argc argv exception = do
-            [arg] <- peekArray (fromIntegral argc) argv
-            bs <- encodeUtf8 <$> valueToText ctxRef arg
-            mapM_ processResult (decode (fromStrict bs))
-            jsvaluemakeundefined ctxRef
-      `E.catch` \(e :: SomeException) -> do
-            withJSString (T.pack (show e)) (jsvaluemakestring ctxRef) >>= poke exception
-            jsvaluemakeundefined ctxRef
+addJSaddleHandler :: WebView -> (Results -> IO ()) -> (Results -> IO Batch) -> IO ()
+addJSaddleHandler webView processResult syncResults = do
+    manager <- webViewGetUserContentManager webView
+    _ <- onUserContentManagerScriptMessageReceived manager Nothing $ \value -> do
+        bs <- encodeUtf8 <$> valueToString value
+        mapM_ processResult (decode (fromStrict bs))
+    _ <- onWebViewScriptDialog webView $ \dialog ->
+        scriptDialogGetDialogType dialog >>= \case
+            ScriptDialogTypePrompt ->
+                scriptDialogGetMessage dialog >>= \case
+                    "JSaddleSync" -> do
+                        resultsText <- scriptDialogPromptGetDefaultText dialog
+                        case decode (fromStrict $ encodeUtf8 resultsText) of
+                            Just results -> do
+                                batch <- syncResults results
+                                scriptDialogPromptSetText dialog (decodeUtf8 . toStrict $ encode batch)
+                                return True
+                            Nothing -> return False
+                    _ -> return False
+            _ -> return False
 
+    void $ userContentManagerRegisterScriptMessageHandler manager "jsaddle" Nothing
+
 jsaddleJs :: LB.ByteString
 jsaddleJs = ghcjsHelpers <> mconcat
-    [ "(function() {\n"
+    [ "runJSaddleBatch = (function() {\n"
     , initState
-    , "\nreturn function(batchJSON) {\n"
-    , "  var batch = JSON.parse(batchJSON);\n"
-    , runBatch (\a -> "window.jsaddleCallback(JSON.stringify(" <> a <> "));\n") Nothing
+    , "\nreturn function(batch) {\n"
+    , runBatch (\a -> "window.webkit.messageHandlers.jsaddle.postMessage(JSON.stringify(" <> a <> "));\n")
+               (Just (\a -> "JSON.parse(window.prompt(\"JSaddleSync\", JSON.stringify(" <> a <> ")))"))
     , "};\n"
-    , "})()\n"
+    , "})()"
     ]
 
 #endif
