Sky.Webview

A first-class desktop UI backend. Same TEA shape as Sky.Live and Sky.Tui (init / update / view / subscriptions), but the runtime opens a native system webview window and renders the same Std.Ui.Element tree to HTML in-process. No HTTP server, no SSE, no session store.

You reach it through Std.App. Write one App.app value and build it for the desktop with --target; you never import Std.Webview in app code — it is the low-level runtime Std.App composes.

--targetDeliversRunner it maps to
desktopSky.Live rendered in a native windowApp.runLiveWindow
desktop:mac | desktop:windows | desktop:linuxnative desktop shell around a Sky.Spa clientApp.runWebview

Why

The cross-backend story — one App.app source, a --target per delivery:

--targetView targetBest for
web (Sky.Live)Web (HTTP + SSE, multi-tenant)Apps with a server, multiple users, public URL
terminal:tui (Sky.Tui)Terminal (ANSI cells)CLI tools, headless dashboards, SSH sessions
desktop (Sky.Webview)Native desktop windowSingle-user desktop apps, packaged binaries, offline-first

Write view : Model -> Element Msg once. Pick the target at build time. The same Std.Ui tree paints under all three.

Status (v0.1 MVP)

Shipped — the desktop runtime Std.App composes for --target desktop:

Out of scope for v0.1 (deferred to v0.2 / v0.3):

Quick start

module Main exposing (main)

import Sky.Core.Prelude exposing (..)
import Sky.Core.String as String
import Sky.Core.Task exposing (Task)
import Sky.Core.Error exposing (Error)
import Std.App as App
import Std.Cmd as Cmd
import Std.Sub as Sub
import Std.Ui as Ui exposing (Element)


type alias Model = { count : Int }
type Msg = Inc | Dec


init : () -> ( Model, Cmd Msg )
init _ = ( { count = 0 }, Cmd.none )


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Inc -> ( { model | count = model.count + 1 }, Cmd.none )
        Dec -> ( { model | count = model.count - 1 }, Cmd.none )


subscriptions : Model -> Sub Msg
subscriptions _ = Sub.none


view : Model -> Element Msg
view model =
    Ui.row [ Ui.spacing 12 ]
        [ Ui.button [] { onPress = Just Dec, label = Ui.text "-" }
        , Ui.text (String.fromInt model.count)
        , Ui.button [] { onPress = Just Inc, label = Ui.text "+" }
        ]


appDef =
    App.app
        { init = init, update = update, view = view, subscriptions = subscriptions }
        |> App.withWindow "Counter" 480 360
        |> App.withNotFound ()


main : Task Error ()
main =
    App.run appDef

Build the native window with sky build --target desktop src/Main.sky (bare desktop = Sky.Live in a native webview window). App.withNotFound is mandatory for the web/desktop families — it is compile-enforced. Runner-direct equivalent (no --target): main = App.runLiveWindow appDef.

Native shell around a Sky.Spa client (the desktop / mobile shell)

The desktop window above runs the TEA loop in-process. A Sky.Spa client instead compiles the loop to wasm, serves it over HTTP from its own stateless backend, and runs it inside a native shell. From one App.app source, a --target picks the shell:

sky build --target desktop:mac    src/Main.sky   # native desktop shell (= App.runWebview)
sky build --target mobile:ios     src/Main.sky   # iOS WKWebView shell
sky build --target tablet:ipad    src/Main.sky   # iPad shell

The exact same client that powers the --target web build becomes a desktop or mobile app — the client and server stay separate; only the shell is native, and the build synthesises the Sky.Spa split for you (no separate Std.Spa entry). One Sky.Spa app spans web, desktop, and mobile with no per-platform app logic. Worked example: examples/60-spa-todos/desktop.

Which backend the shell loads. Set it with App.withAppUrl "https://app.example.test/" on the App value, or with SKY_APP_URL at build time (which wins). The iOS and Android shells bake the address in at build time, because a phone cannot reach the build machine's localhost. The desktop shell also reads SKY_APP_URL at run time. With neither set, the shell loads the development default on PORT (8951 when unset): localhost on the iOS simulator, 10.0.2.2 on the Android emulator, and 127.0.0.1 on desktop. The build summary prints the address and its source. See docs/skyapp/overview.md and docs/sky-toml.md (SKY_APP_URL).

Low-level mechanism. The native shell is the Std.Webview runtime loading a URL (Webview.url "http://127.0.0.1:8951/" …); Std.App's desktop/mobile targets drive it for you. Reach for the raw Std.Webview surface only when you are building the shell plumbing itself.

Links into the app (macOS). In a packaged .app whose Info.plist names hosts in SkyLinkHosts (the release build writes them from Bundle.AssociatedDomain "applinks:…"), Webview.url opens a universal link or a URL sent to the app (open -a <app> <url>) for one of those hosts on the link's path on the loaded address: as the first page when the link launches the app, in place (history.pushState + popstate) when the app runs. See "Links into the app" in docs/skyapp/native.md.

Platform requirements

OSWhat you needv0.1
macOS 12+WKWebView (ships with the OS)✅ smoke-validated
Windows 11Edge WebView2 runtime (evergreen distributable)⚠️ builds, untested
Ubuntu 22.04+libwebkit2gtk-4.0-37 (Debian/Ubuntu) or webkit2gtk4.0 (Fedora/Arch)⚠️ builds, untested

webview_go's cgo bindings provide the system-webview bridge. Builds without cgo (CGO_ENABLED=0) get a stub that returns an Err Error from the desktop runner instead of panicking at link time.

How it works

Bird's-eye view:

  1. First render. view model → Std.Ui.Element → HTML body via the Sky.Live renderer. The body lands inside <div id="sky-root"> via webview.SetHtml.
  2. Event dispatch. Every event an element declares (a sky-<event> attribute: click, input, submit, contextmenu, custom events, …) is bound. Its handler id is <sky-id>.<event>, derived per event, so an element with several handlers sends each event to its own handler. The JS shim's __skyBindEvents wires native listeners that forward (handlerId, args) to the Go-side __skyDispatch Bind callback. The bound function looks up the Msg ctor and pushes it onto a bounded msgCh.
  3. Update loop. A goroutine drains msgCh, runs update msg model, computes the new VNode tree, and diffs it against the previous. Patches are JSON-encoded and sent over the bridge as __skyApplyPatches([…]) via webview.Eval.
  4. Clean shutdown. When the user closes the window, webview.Run() returns, the subscription manager stops every ticker, and Task.run resolves to Ok ().

Comparison with Sky.Live

Concern--target web (Sky.Live)--target desktop (Sky.Webview)
WireHTTP + SSEIn-process Bind + Eval
Session storememory / sqlite / redis / postgres / firestoreNone — single-user, single-process
CSRFYes, per-sessionN/A
Reconnect bannerYesN/A
URL routingApp.withRoutes [ … ], history, sky-navN/A — desktop apps don't have an address bar
Multi-tenantYesNo — one window, one model
Auto-mounts dev consoleYesNo — /_sky/* paths only meaningful with HTTP
Cross-platformAnywhere with a browsermacOS / Windows / Linux with a system webview

The convergence point is view : Model -> Element Msg. Identical across --target web, --target terminal:tui, and --target desktop — one App.app source, three targets.

Environment variables

EnvDefaultEffect
SKY_WEBVIEW_DEBUG1 (on)Enable webview DevTools (right-click → Inspect). Set 0 / false for prod-tightened builds.

See also