Skip to content

web/: embed the TV's Chromium (libcbe) in a native app - #1

Open
mariotaku wants to merge 40 commits into
mainfrom
webview-cbe
Open

web/: embed the TV's Chromium (libcbe) in a native app#1
mariotaku wants to merge 40 commits into
mainfrom
webview-cbe

Conversation

@mariotaku

Copy link
Copy Markdown
Member

Adds a web/ family alongside media/: two samples that link /usr/lib/libcbe.so — the TV's own Chromium, the engine every webOS web app already runs inside — and put a real web view in a native app, with no WAM and no web app package.

web/
  libcbe/    reconstructed headers and the link stub, shared by both
  cbe/       the smallest thing that puts a web page on screen
  hybrid/    SDL2 and a web view in one process, swapping which one is shown

Why this needed reverse engineering

There is no SDK for libcbe. No headers ship on the device or in the NDK, and the library is a stripped 70 MB blob with a C++ ABI. The headers in web/libcbe/webos/ were reconstructed from two sources that agree with each other:

  • firmware symbol tables (dev-toolbox-cli/common/data/*/libcbe.so.json), which give every name and signature across every release;
  • WAM's own vtableslibWebAppMgr.so contains BlinkWebView and WebAppWaylandWindow, the only in-firmware subclasses, which pin down slot order and object size. operator new(32) followed by a subclass writing its first field at offset 8 says webos::WebViewBase is exactly a vptr plus one pimpl pointer.

Three facts are load-bearing and are commented as such:

  • WebViewDelegate has no virtual destructor — adding one shifts every slot by two. WebAppWindowDelegate does have one, so the two classes are not symmetric.
  • The delegate is 24 slots, not the 17 with recoverable names. libcbe indexes past them, and a short vtable reads whatever follows it in memory. Leaving them out segfaults a few hundred milliseconds into the first page load, which is how they were found.
  • Chromium is built -fno-rtti and exports no typeinfo for these classes, so subclasses must agree.

The shape of the API

WebOSMain() is Chromium's content main: it takes the process over, re-execs the binary for the renderer, owns the message loop, and never returns. There is no "initialise the web view, then carry on".

The seam is the one WAM uses — libcbe pumps the default GMainContext on its browser UI thread, so work queued there before WebOSMain() runs on that thread once Chromium is up. That is where windows and web views may first be created, and it is also how web/hybrid drives SDL: g_timeout_add(16, Pump, ...) polls SDL events and repaints, so both toolkits share one thread and switching views is a plain function call.

webOS 4 only

Both bounds are ABI, not caution. webOS 3's libstdc++ has no GLIBCXX_3.4.21, so nothing can call exports mangled with std::__cxx11::basic_string there without statically linking a newer runtime. webOS 5 replaced the free WebOSMain() with a webos::WebOSMain class and changed WebViewBase's constructor; webOS 6 added a second parallel API under neva_app_runtime. The webos:: API itself is unchanged from 3.4 to 11.2, so a webOS 5+ variant is a small delta rather than a rewrite.

Nothing is bundled: the system libcbe has /usr/lib/cbe/webos_resources.pak compiled in and finds ICU, the V8 snapshots and the locale paks itself.

Verified on hardware

49LK5900, webOS 4.4.3, by display capture rather than by logs alone:

  • web/cbe renders a page full-screen at 1920x1080 and registers with LSM and SAM as the foreground card;
  • web/hybrid runs both views from one process and switches in both directions, with the page's exit button reaching native code through TitleChanged, and re-entry resuming the suspended page without reloading;
  • RunJavaScript() works; LoadExtension("palmsystem") plus a trusted trust level turns delegate slots 19/20 into a synchronous JS↔native RPC (PalmSystem.getResource(...) returned a value produced in C++);
  • loading the app's own page.html over file:// needs three settings — without SetAllowUniversalAccessFromFileUrls the renderer is killed mid-load with bad IPC message, reason 114 rather than being told no. SetWebSecurityEnabled(false) is not needed.

Not verified: the remote. Every transition was driven by a test hook calling the same functions a key press calls; synthetic keys via /dev/uinput are not routed to the app by LSM. The SDLK_RETURN and Back paths are wired but unexercised, and both READMEs say so. Lifecycle (SAM relaunch/close, background suspend) is not implemented either.

web/cbe/README.md and web/hybrid/README.md carry the details, including the two heavier JS bridges and why the two-app alternative costs more than it looks.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i

Mariotaku and others added 17 commits August 22, 2026 12:50
/usr/lib/libcbe.so is the engine every webOS web app already runs inside, and
it is a plain shared library: a native app can link it and get a real web view
with no WAM and no web app package. There is no SDK for it - no headers on the
device or in the NDK, and the library is stripped - so the ABI this sample
links against was reconstructed from two sources that agree with each other:
the firmware symbol tables, and the vtables of WAM's own BlinkWebView and
WebAppWaylandWindow, which are the only in-firmware subclasses.

The sample's shape is the inversion at the centre of the API. WebOSMain() is
Chromium's content main: it takes the process over and never returns, so there
is no "initialise the web view, then carry on". The seam is the one WAM uses -
libcbe pumps the default GMainContext on its browser UI thread, so work queued
there before WebOSMain() runs on that thread once Chromium is up.

Three things in the headers are load-bearing, and two of them were found the
hard way: WebViewDelegate has no virtual destructor, it is 24 slots long
rather than the 17 that have recoverable names (a short vtable segfaults a few
hundred milliseconds into the first page load), and Chromium's -fno-rtti means
no typeinfo is exported for these classes.

webOS 4 only, and both bounds are ABI rather than caution: webOS 3's libstdc++
has no C++11 std::string ABI to call these exports with, and webOS 5 replaced
the free WebOSMain() with a webos::WebOSMain class.

Verified on a 49LK5900 (webOS 4.4.3): the page loads and paints, and the
window registers with LSM and SAM as the foreground card.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Answers the hybrid-app question the sample raises but did not address: how
native code and page JavaScript reach each other, and whether a web view can
be combined with native rendering.

Verified on a 49LK5900 with a throwaway probe build. RunJavaScript() works and
returns nothing, so there are two ways back out: document.title arriving as
TitleChanged(), which needs no setup at all, and the real one - LoadExtension
("palmsystem", not "v8/palmsystem") plus a trusted trust level, after which the
injection's native functions turn into BrowserControlMsg IPC and land in
delegate slots 19 and 20. HandleBrowserControlFunction() is synchronous and its
std::string* out-parameter becomes the JavaScript return value, so
PalmSystem.getResource('probe-cmd', ...) really did come back as
"native-said-hello". Page console.log is invisible until --enable-logging=stderr
is passed.

For combining with native rendering, the finding is a negative one: nothing in
the exported API hands back a GL texture or an exported surface, so composition
has to happen at the window level - SetTransparentBackground over a hardware
video plane, or window groups with ordered layers. Neither combination is
tested, and that is said plainly.

Adds LoadExtension / ClearExtensions / RunJavaScriptInAllFrames /
AddUserStyleSheet to the reconstructed header and the link stub. main.cpp is
unchanged: the sample stays minimal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Answers the "hide the web window, show a native one, come back" question with
device evidence rather than with what the API names imply.

One process: Hide() plus NATIVE_WINDOW_MINIMIZED really does take the surface
off the screen - the display capture shows the TV falling through to the HDMI
input behind it - and Show() brings the page back with no reload and no
navigation, so state survives the round trip. Adds the suspend/resume calls
this needs to the reconstructed header and the link stub.

Two apps: tested with media/lgnc as the native side. Launching over it works
and it keeps running; closeByAppId closes cleanly; but the screen does not go
back - LSM left the TV on externalinput.av1 rather than restoring the app that
was still alive behind. Relaunching to return gave new pids, a cold restart
rather than a resume, because nothing here implements the SAM native
lifecycle. That lifecycle work, not the launching, is what the two-app split
actually costs.

main.cpp is unchanged; the probes were throwaway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
The two toolkits disagree about who owns the process: WebOSMain() is
Chromium's content main and never returns, while SDL wants a
while (SDL_PollEvent) loop in main(). Chromium wins, and SDL is pumped from
its loop instead - a g_timeout_add on the browser UI thread polls events and
repaints, so both toolkits share one thread and switching views is a plain
function call. SDL_InitSubSystem(SDL_INIT_VIDEO) from there reports the wayland
driver and hands back a window and an accelerated renderer with libcbe already
running alongside.

Leaving the web view is a title change: the page sets document.title, the app
sees TitleChanged, and that is the whole channel. Two details keep it honest -
the exit title is ignored unless the web view is on screen, so a leftover title
cannot bounce the user straight back out, and the page restores its title on
visibilitychange so there is a fresh edge next time. Coming back resumes the
suspended page rather than reloading it, which is the point of suspending
rather than destroying.

Loading the app's own page.html over file:// needs three settings, and two are
not enough: without SetAllowUniversalAccessFromFileUrls the renderer is killed
mid-load with "bad IPC message, reason 114" rather than being told no.
SetWebSecurityEnabled(false) is not needed. Also redirects stdout, since SAM
points a launched app's at /dev/null.

Moves the reconstructed headers and the link stub to web/libcbe, shared by both
samples the way media/common is shared by the media ones.

Verified on a 49LK5900 (webOS 4.4.3) by display capture: both views render
full-screen, switching works both ways, the exit button reaches native code,
and the SDL view keeps animating after coming back. The transitions were driven
by a test hook rather than by the remote - synthetic keys via /dev/uinput are
not routed to the app by LSM - so the key paths themselves are untested, and
the READMEs say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Claude Code writes a per-project MCP server config there, and it is
machine-local: the entry this repo picked up carries an absolute path into a
home directory. Nothing a checkout needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Signalling native code by setting document.title and watching TitleChanged()
worked, and was the wrong thing. The title is a UI property with one global
slot: the channel collides with any page that manages its own title, cannot
carry arguments, and needs edge-detection hacks to tell a fresh signal from a
leftover one - this sample had both, ignoring the exit title unless the view
was on screen and resetting it on visibilitychange to manufacture an edge.

libcbe already has callbacks for this. With the palmsystem injection loaded,
PalmSystem.close() arrives as WebViewDelegate::Close() - the delegate's own
dedicated slot - and PalmSystem.platformBack() as
HandleBrowserControlCommand("platformBack"). Nothing is overloaded, nothing is
parsed out of a shared channel, and the page carries no state between visits,
so both guards are gone. TitleChanged() is only logged now.

Both routes confirmed on a 49LK5900: the exit button reaches Close() and
switches views, and re-entry still resumes the suspended page without
reloading. web/cbe/README.md now says plainly that the title is not an IPC
channel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Two claims in the previous commits were wrong or vaguer than the evidence.

webOS 3 was described as unreachable because libcbe's exports are mangled with
std::__cxx11::basic_string while webOS 3's libstdc++ predates that ABI. That is
backwards: webOS 3's libcbe is the *old* string ABI throughout - there is not
one __cxx11 symbol in the library - so it is self-consistent, and a webOS 3
build is a -D_GLIBCXX_USE_CXX11_ABI=0 variant rather than an impossibility. It
also predates the WebViewBase() + Initialize() split, taking dimensions in the
constructor instead, and has no SetAppPath, LoadExtension or UpdatePreferences.
Of the 30 libcbe symbols the sample needs, 11 are absent from a 3.4 dump, all
of them one of those two differences. That is the same shape as
media/smp/common being built four times, and the README now says so.

The real hard floor is 3.4, and it is the library rather than the ABI: webOS 1
and 2 ship no libcbe at all.

Second, "webOS 4" was doing more work than the evidence supports. The verifier
has exactly two webOS 4 dumps, 4.4.2 and 4.9.7, and nothing between 4.0 and
4.3, so the lower half of the declared >=4 range rests on the API being
unchanged across the generation, not on a check. Hardware testing was 4.4.3.
Documented as "4.4.2 verified, 4.0 assumed" rather than quietly implied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
The version table called those releases "no engine to link", which is wrong in
a way that would stop someone looking. They have Qt5WebKit - libQt5WebKit.so
against Qt 5.0.0 on webOS 1 and Qt 5.2.1 on webOS 2 - and libQt5WebKitWidgets
carries the whole classic WebKit1 API, including a JavaScript bridge better
than libcbe's: evaluateJavaScript returns a value synchronously, and
addToJavaScriptWindowObject hands page JavaScript a real QObject.

It is still a separate project rather than a variant here, for two reasons now
written down. Nothing in the firmware uses the widgets API - scanning every
shared object in the webOS 2.2.3 dump, libQt5WebKitWidgets has zero consumers,
and WAM reaches WebKit through QML instead - so a shipped-but-unexercised
library would have to be proven to load and paint before anything is built on
it. And the NDK ships Qt 5.15.14 with no QtWebKit headers, against TVs running
5.0.0 and 5.2.1, and Qt's binary compatibility runs forwards only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Checking the "the webos:: API survives to 11.2, so a webOS 5 variant is a small
delta" claim against the full symbol set turned up a bug in the declared range.
webos::WebViewBase::Initialize gains one bool at 4.10 - a 2019 W19P release,
still webOS 4 - so a sample declaring ">=4, <5" would fail to load there. The
range is now ">=4, <4.10".

-verify cannot catch this. The verifier ships dumps for 4.4.2 and 4.9.7 and
nothing between 4.10 and 5.3, so ">=4, <5" and ">=4, <4.10" check exactly the
same two firmwares and both pass. It was only visible in the larger set under
dev-toolbox-cli/common/data. Said plainly in the README, because a clean
-verify claiming less than it appears to is the sort of thing this repo has
been bitten by before.

The claim it came from was also too vague, and is now a table. Construction
moves five times between 3.4 and 11.2 - constructor, Initialize arity and entry
point - but everything else holds: of the 30 libcbe symbols the sample uses,
the same three are the only ones missing on every release from 5.3.1 to 11.2.
The other 27 are untouched across six generations.

That reframes which variant is worth writing. Not webOS 5, which is a
generation of one, but 6.4 through 11.2, where the constructor and Initialize
stop moving - a single build covering every set from 2021 to 2025.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
The firmware dumps carry one entry per major webOS release, so a starfish
number stands for a whole generation rather than a single build. That makes the
earlier "4.0 to 4.3 is assumed" hedge meaningless - starfish 4.4.2 *is* the
webOS 4.0 generation - and it sharpens what these samples actually cover.

webOS 4 has two dumps: starfish 4.4.2 (HE_DTV_W18R) is webOS 4.0, the 2018
sets, which is what this targets and what the 49LK5900 tested against; starfish
4.10.0 (HE_DTV_W19P) is webOS 4.5, the 2019 sets, where Initialize takes a
ninth argument. So the honest scope is one model generation, and reaching the
next one is a single bool - the smallest variant on offer anywhere in this
family, and it doubles the hardware.

Also records that webosbrew-elf-verify reports All OK for starfish 4.10.0 even
though nothing in that entire dump exports the eight-argument Initialize -
grepping every .json in the firmware finds only the nine-argument one. It flags
the same symbol correctly on 5.3.1, so the check works in general and this is a
false negative. The declared range was set by reading the symbol tables rather
than by trusting the tool, and the README says so, because a clean -verify
meaning less than it appears to is a trap this repo has hit from the other
direction already.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Correcting a claim repeated through these commits: "there is no SDK for this"
is wrong. /opt/webos-sdk-x86_64 ships webos/webview_base.h,
webview_delegate.h and webapp_window_delegate.h, plus all of WAM under
usr/include/webappmanager/, and a libcbe.so besides.

It confirms every load-bearing fact the vtable archaeology produced, arrived at
independently: WebViewBase deriving from WebViewDelegate with a single
WebView* private member (so 8 bytes, matching operator new(32) and the
offset-8 writes), a WebViewDelegate with no virtual destructor, and a
WebAppWindowDelegate that has one. It also supplies names for the slots that
were HandleUnknown17/18/21/22/23 and corrects CheckKeyFilterTable's return type
from bool to unsigned.

It does not replace the reconstruction, which is why the headers here stay. The
SDK is chromium53 and retail webOS 4 is chromium68, with the delegate moved in
between: AcceptsVideoCapture and AcceptsAudioCapture are gone, and
DidStartNavigation, DidFinishNavigation and LoadAborted are new. Its libcbe is
a third ABI again - webos::WebOSMain::Run and a six-argument Initialize, the
webOS 6+ shape. So slot numbers still come from the vtables; only the names
come from the header, and the ones past 20 are marked unconfirmed since
~WebViewBase is virtual there and may occupy some of them.

AllowMouseOnOffEvent now returns bool rather than void, so a caller reading the
result gets a definite answer rather than whatever was left in r0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Three separate bugs, all in the path back into the web view.

Hide() destroys the Wayland window rather than unmapping it - "Wayland
Window(id:1) will be destroyed" - so the next Show() builds a new one and web
contents left attached to the old one composite nowhere. AttachWebContents now
runs on every show. It also has to run *before* Show(): attaching afterwards
leaves the page loading normally, reporting load finished, and never appearing.

DetachWebContents() on the way out segfaults on a null pointer, because by then
the contents it would detach are already gone. Removed.

And the page was leaving via PalmSystem.close(), which reads like the right
call and is not: the callback arrives from RenderViewHostImpl::OnClose() with
the render view already being destroyed, so the next visit gets a dead view.
PalmSystem.platformBack() is a plain notification and the page survives it.
Close() is still handled so a page that really closes itself hands the screen
back rather than leaving a dead window up.

Delegate callbacks also run inside libcbe's own call stack, so switching views
from one lands mid-teardown and segfaults - ShowNativeView sat directly under
RenderViewHostImpl::OnClose() in the crash backtrace. The switch is now
deferred through a one-shot g_idle_add.

Verified on a 49LK5900 by driving the transitions from a trigger file and
capturing each state: first web view, native, second web view - the second
renders identically to the first, heading and button and all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
The native half was coloured rectangles, because SDL2 on its own cannot draw a
character. Nuklear brings a baked font atlas, so the view can now say what it
is and offer a real button - rebaked at 30px, since the built-in 13px is
unreadable across a living room.

It lives in native_ui.c behind a four-function header, and in C rather than
C++: Nuklear compiles its implementation into exactly one translation unit and
is unhappy as C++, and main.cpp is about one process owning two window systems
rather than about a UI toolkit.

GLES2 rather than SDL_Renderer, and that is forced rather than chosen.
Nuklear's sdl_renderer backend - like Dear ImGui's imgui_impl_sdlrenderer2 - is
built on SDL_RenderGeometry, which arrived in SDL 2.0.18; the TV ships 2.0.4
while the buildroot NDK ships 2.30.12, so that build compiles perfectly and
fails on the device. A second GL context alongside Chromium's EGL turns out to
be fine: Mali-470 MP, OpenGL ES 2.0.

The GLES2 backend still calls SDL_GetTicks64, which -verify caught and the
compiler could not. A function-like macro substitutes over the two call sites
before the header is included, rather than patching a fetched dependency or
defining a competing SDL_GetTicks64 that would shadow the real one on firmware
that has it.

Verified on a 49LK5900: the panel renders with text, and driving the
transitions from a trigger file gives web, native, web with the second web
entry identical to the first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
The sample showed two views trading places but never anything crossing between
them, which is the part a hybrid app actually needs. Now the native panel owns
a counter and hands it to the page on the way in, and the page sends a note
back that the panel shows.

Native to page is RunJavaScript and nothing else - no typed bridge, no return
value - so the value goes in as a literal in a call to a function the page
agrees to define. The only subtlety is timing: the first visit waits for
LoadFinished because the page has not run yet, later visits push immediately
because it is loaded and merely suspended, and both defer out of the delegate
callback.

Page to native goes through the injection's getResource and is the only channel
libcbe offers that carries a value in both directions - the string written to
HandleBrowserControlFunction's result parameter becomes the JavaScript return
value, synchronously. Two constraints shape it, and both are now documented
where someone will hit them: the command names belong to the injection rather
than to the app, so a real protocol lives inside the argument, and only the
first argument survives the trip.

Also records that this is not a homebrew-only seam. LG's own webOSTV.js reaches
the platform the same way - new PalmServiceBridge, onservicecallback,
bridge.call(uri, params) - and uses PalmSystem.platformBack, deviceInfo,
identifier and stageReady, so a page inside this app has the same foundation a
normal webOS web app does. Whether webOS.service.request() then succeeds is
left explicitly untested, since ls-hubd already declines this executable a
service role.

Verified on a 49LK5900: the page shows the counter native sent, and the panel
shows the note the page sent back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
The sample traded a counter back and forth, which demonstrated the channels but
not a reason to want them. The real problem is the one chiaki-ng has: a native
app needs the user to log in on somebody else's web page, and needs what comes
back in the redirect URL, and shipping a whole browser for that is absurd.

So the sample is now that flow. The native panel offers "Sign in", the web view
loads a provider's login form, and submitting it lands back in the panel as
"signed in as demo".

The channel that matters turns out not to be JavaScript at all.
DidStartNavigation fires with the full URL and query string *before* the
request is made, so the redirect target never has to exist - this points at
webosbrew.invalid and the interception lands first, with ERR_NAME_NOT_RESOLVED
arriving behind it. StopLoading in the handler saves the DNS lookup and the
error page. That is exactly how chiaki reads ?code= off
remoteplay.dl.playstation.net/remoteplay/redirect.

Parameters go in the same way, in the URL the app opens - state and
redirect_uri, read by the page with URLSearchParams - and a redirect whose
nonce does not match is thrown away, which is what stops an unrelated
navigation being taken for the answer. The window enables the virtual keyboard,
since the form has to be typed into with a remote.

The getResource bridge stays documented as the other channel, the one that
carries a value both ways, but nothing in this flow needs it.

The form is flex rather than grid: the TV's Chromium is 68 and its grid put all
four fields on one line.

Verified on a 49LK5900 end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
The OK key was the last thing in this sample driven only by a test hook.
/dev/uinput was a dead end - the virtual device registers and LSM routes
nothing from it - but the service behind the phone-remote app does the job:

  luna-send -f luna://com.webos.service.networkinput/test/sendKeyCode \
      '{"keyCode":28}'

It takes evdev codes rather than webOS or JavaScript ones. 28 is KEY_ENTER, and
the app logs [sdl] key 13 - SDLK_RETURN - then [switch] native -> web. So the
OK path is confirmed end to end, and the recipe is in the README because it is
useful to any sample here that reads the remote.

Back is still unverified, and now for a known reason rather than for lack of
trying: 158, 174 and 1 inject without error and reach nothing in either view,
and do not close the app either, so something filters them before any window
sees them. sendSpecialKey is no help - the key table in
/usr/sbin/network-input-service covers media and menu keys with no BACK or EXIT
in it. A real remote may still deliver what this service will not.

The web window does now ask for the key, with the property names WAM uses -
_WEBOS_ACCESS_POLICY_KEYS_BACK and _WEBOS_ACCESS_POLICY_KEYS_EXIT, which appear
nowhere but inside libWebAppMgr.so. Kept because it is what the platform
expects, and flagged as unverified rather than claimed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
They have no keysym at all - the webOS fork of SDL reports them only as
scancodes above 480, SDL_WEBOS_SCANCODE_BACK 482 and _EXIT 505, defined in
SDL_webOS.h. Pump() switched on keysym.sym, so it dropped them silently. It now
checks the scancode first, and the hints use the header's constants rather than
hand-copied strings.

Measuring it also corrects the previous commit, which called Exit unreachable.
It arrives fine - evdev 174 shows up as sym=0 scancode=505 - and had been
logged all along as "[sdl] key 0", which I read as noise because only the
keysym was being printed. The table is in the README now: 28 -> sym 13, 174 ->
scancode 505, 1 -> 27, 14 -> 8. KEY_BACK 158 really does reach nothing.

The more useful finding is that keys only reach whichever window is up. Once
the web view is showing, the SDL loop sees nothing - input belongs to libcbe's
window. So OK opening the web view is an SDL concern and leaving again is not:
that has to come from the page's keydown handler or from
WebAppWindowBase::event(), and neither is verified. Said plainly rather than
implied by a handler that looks like it covers both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
@mariotaku

Copy link
Copy Markdown
Member Author

Keyboard focus in the web view: a limitation, and a dead end

Walked the whole sign-in flow on a 49LK5900 (webOS 4.4.3) one remote key at a time, injecting with com.webos.service.networkinput/test/sendKeyCode and comparing frames pixel-by-pixel. The flow works, but not as nicely as it should, and the reason is worth recording before someone else spends the afternoon on it.

What actually happens

step key (evdev) result
1 native panel, (not signed in)
2 OK 28 → web view, params handed over in the URL
3 OK 28 nothing — pixel-identical frame, no log line
4 Tab 15 focus → Username, virtual keyboard appears
5 Enter 28 nothing
6 Tab 15 focus → Password, keyboard stays
7 Tab 15 focus → Log in, keyboard dismissed
8 Enter 28 submit → redirect intercepted → signed in as demo

So by remote it is OK, Tab, Tab, Tab, Enter. By pointer it is a single click, because mouse events carry their own target and need no focused element.

Two facts fall out of this that are useful beyond the focus problem:

  • Input is exclusive to whichever window owns it. Not one [sdl] key line appeared between steps 3 and 8 — the SDL loop is completely deaf while the web view is up. Leaving the web view therefore cannot be an SDL concern.
  • SetUseVirtualKeyboard(true) works, and the VKB behaves properly: up for text inputs, dismissed on a button.

The web view starts with nothing focused

Enter does nothing until Tab establishes focus. Tab is not merely moving focus, it is creating it — and after that, Enter activates a focused button (though it still never submits from a text input).

Three attempts to fix it, all failed:

attempt result
autofocus on the Log in button CSS ring appears, Enter still does nothing
webview->SetFocus(true) on show no change, and Tab stopped rescuing it
page-side .focus() on load no change

All reverted. The third made things worse and I had stacked the changes without re-verifying between them, so it took a revert to get back to known-good — the committed version does still work as the table above.

My diagnostic was also unreliable throughout: page console.log never reached the log even with --enable-logging=stderr and --v=1, so a keydown logger in the page produced nothing and proved nothing. Worth fixing before the next attempt.

The one real clue

libcbe says so itself:

ERROR:webos_view.h(123)] Not implemented reached in
  virtual void WebOSView::OnWidgetActivationChanged(views::Widget*, bool)

Widget activation is a stub in this build. In Chromium's views that is what hands focus to the FocusManager, so nothing ever becomes the focused element. That fits every observation: pointer fine, keyboard dead, Tab working because focus traversal does not depend on prior activation. If that is the cause, no amount of page-side JavaScript will fix it.

Worth trying next

  • SetCSSNavigationEnabled(true) — webOS's own spatial navigation, plausibly the intended focus mechanism on a TV, and exported by libcbe.
  • Fix page-console logging first, so the next attempt has a working diagnostic instead of guesswork.
  • ForwardWebOSEvent() exists and could inject a synthetic Tab, but WebOSEvent has an exported vtable and no exported constructors, so building one is guesswork of a different kind.

Not blocking the PR: the sample works by pointer, and by remote with the extra Tabs. Flagged here rather than papered over.

The README read as though the sign-in flow worked by remote in two presses. It
does not: it is OK, Tab, Tab, Tab, Enter, because the page has no focused
element until Tab creates one, and Enter does nothing until then. By pointer it
is one click, since mouse events carry their own target.

autofocus, a page-side focus() on load, and WebViewBase::SetFocus(true) were
each tried and none helped. libcbe logs "Not implemented ...
WebOSView::OnWidgetActivationChanged", and widget activation is what hands
focus to Chromium's FocusManager, so that is the likely cause and not something
page-side JavaScript can reach. SetCSSNavigationEnabled(true) is the untested
next idea.

Full write-up is on the pull request; this is the short version, in the repo,
so the sample does not claim more than it does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Mariotaku and others added 10 commits August 24, 2026 00:14
A sibling of web/cbe against the libcbe that shipped on webOS 3, measured on a
43UH6100 at starfish 3.4.0. Not a build variant: between 3 and 4 the library
changed its API as well as its string ABI. WebViewBase takes its size in the
constructor with no Initialize(), WebAppWindowBase has no InitWindow(), the
delegate has its own slot order with DidFirstNonBlankPaint at 2 and
LoadProgressChanged taking a URL, LoadStarted carries the URL where webOS 4
grew a separate DidStartNavigation, and the browser-control pair sits at 18/19.
The layout came from webOS 3's own libWebAppMgr, the same way the webOS 4 one
did.

Two undocumented things stop it starting, and both cost real time to find.
--ozone-platform is "weboswayland" on this generation, not "wayland". And
CDM_LIB_PATH must be set: webOS 3's WebOSMain does std::string(getenv(...))
with no null check and appends /libwidevinecdmadapter.so, so an unset variable
aborts the process before any of our code runs, with nothing to go on but
"basic_string::_S_construct null not valid". Finding it meant pulling the 64 MB
library, resolving the crash address to WebOSMain+0x21f8 and reading the
literal the getenv loads.

-verify also caught SetTrustLevel and UpdatePreferences, copied from the webOS
4 sample and simply absent here.

What works: the process starts, the page loads, and every delegate callback
fires with the recovered signatures - so the vtable lines up and the old-ABI
strings arrive intact. What does not: the surface never reaches the screen. LSM
keeps the previous app foreground, and SetWindowProperty("appId"),
SetWindowHostState, SetHiddenState(false) and SetOpacity after Show() all fail
to hand it over. webOS 3 has no Activate(), which is what does it on webOS 4.
Committed as work-in-progress with that said plainly in the README rather than
implied by a sample that looks finished.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Reverse-engineered webOS 3's WAM and libcbe looking for what makes a window
reach the screen. Did not find it. Recording the search so it is not repeated.

The one real gain is that appinfo needs "noSplashOnLaunch": true. Without it
SAM's launch splash sits over everything indefinitely, which made a missing
window look like a compositing bug with a picture on top. With it, the screen
shows the TV's own no-signal wallpaper - so the surface genuinely is not there.
Added to the shared appinfo template rather than only this sample, since a
sample that draws immediately never wants the splash.

Ruled out, all measured: SetWindowProperty("appId") (the only property WAM sets
besides the key-access ones), SetWindowHostState before and after Show(),
SetHiddenState(false), SetOpacity(1.0f), and the --app-id switch that exists in
libcbe. webos::Platform::Get() returns nil - that singleton belongs to the
browser application and is built by ChromeMain, not WebOSMain. Dropping
Resize(), which WAM never calls on this generation, makes things worse rather
than better: the delegate stops firing and Wayland reports "proxy already has
listener".

Also confirmed WAM is not doing anything the sample omits:
WebAppWaylandWindow::show() is onStageActivated() - pure WAM bookkeeping, no
libcbe calls - followed by WebAppWindowBase::Show().

The live lead is registration. libcbe carries
palm://com.webos.applicationManager/registerNativeApp and a webos::LunaServices
whose Initialize takes a base::FilePath and needs a LunaServices(Platform*), so
that path may live on the browser's side of the library rather than the
embedder's. Hypothesis, not a finding, and labelled as such.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
…gnores

More RE, still not solved, but the failure is much better characterised and
several more doors are closed.

The sharpest symptom is now measured rather than inferred: the window object is
real - non-null native handle, correct 1920x1080 panel - but GetWindowHostState
stays 0 through SetWindowHostState(NATIVE_WINDOW_FULLSCREEN) and through
Show(). The compositor never acknowledges the state. So the surface exists and
is being drawn into, and LSM simply does not treat it as an app window, which
is a better description than "the window does not appear".

Neither the enum nor the sequence is wrong. WAM passes literal 3 for
fullscreen, matching this header, and WebAppWayland::raise() - webOS 3's
equivalent of webOS 4's Activate() - makes exactly one libcbe call,
SetWindowHostState(3), which the sample already makes.

Two switches turn out to be load-bearing: --webos-wam is required, and without
it the process exits before writing a line of log. --app-id, which also exists
in the library, changes nothing. The app does reach the Luna bus - ls-monitor
shows two client-only connections owned by the executable - so libcbe's own LS2
client is running.

That suggests a conclusion worth testing: both in-firmware users bring their
own window management. WAM wraps WebOSMain in WebAppWayland, and the browser
does not use WebOSMain at all - it uses ChromeMain, which is what builds
webos::Platform and its Luna side. There may be no supported standalone
embedder on this generation, with webOS 4 the release that fixed it. Written up
as a hypothesis with its three pieces of evidence, not as a finding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Following the browser: on webOS 3 there is no browser binary to follow.
Scanning /usr/bin and every installed app turns up exactly one consumer of
libcbe on the whole TV - /usr/bin/WebAppMgr. The browser there is a web app
that WAM hosts, not a native binary, unlike webOS 4 where
com.webos.app.browser/chrome links the library directly. So there is no
standalone embedder anywhere in this firmware to copy, which is why none of
this can be checked against something known to work.

webOS 3's WebAppMgr binary is no help either: its undefined symbols are the
same short list as webOS 4's, so at process level it does what this sample
already does.

webos::Runtime is a real find even though it did not fix anything. Unlike
webos::Platform, its singleton is alive in a plain embedder - Runtime::Get()
returns a valid pointer - and it carries SetWindowSize, InitializePlatform(const
base::FilePath&) and Initialize(webos::PlatformDelegate*). The first two were
called successfully and changed nothing; the third needs a delegate interface
that has not been reconstructed, and is the remaining candidate on that path.
base::FilePath turns out to be declarable, since libcbe exports its std::string
constructor and destructor and its layout is that single member.

The experiments are removed from main.cpp rather than left as dead code; what
survives is one line printing the window's size, native handle and host state,
because the host state reading back 0 however it is set is the sharpest
statement of the problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
…ther

webos::PlatformDelegate's vtable is exported, so its shape is recoverable even
though nothing in the firmware implements the class: two destructor slots then
nine __cxa_pure_virtual entries. A stub with that shape is enough for
Runtime::Initialize(PlatformDelegate*) to accept it, and both that and
InitializePlatform(base::FilePath) then return cleanly - so all three Runtime
entry points are now reachable from a plain embedder.

None of them changes anything. The host state still reads back 0 and LSM still
shows the previous app. The delegate is never called during startup either,
which means its unknown signatures never came up - and also that initialising
it is not what the window is waiting for.

Also dead: asserting Show() and SetWindowHostState(FULLSCREEN) from
DidFirstNonBlankPaint(), on the theory that the compositor might ignore a state
set on a surface that has never committed a buffer. host-state=0 after the
first frame too.

The experiments are out of main.cpp again; the recovered declarations stay in
the header because they are real results, labelled with what they do not do.
A native webOS app has to tell SAM it is running, and libcbe does not do it -
it opens its own Luna connections but never registers the app. On webOS 3
nothing else will either: WAM's binary registers itself as
com.palm.webappmanager before handing over to WebOSMain.

SDL-webOS does this in SDL_webOSRegisterApp(), so luna_register.c is the same
call with the same library, minus SDL: HLunaServiceCall to
luna://com.webos.applicationManager/registerNativeApp with {"id": appId}, from
libhelpers.so.2 which is already on the TV. dlopen rather than linked, since one
function is not worth a NEEDED entry. The context keeps multiple=1 so the
subscription stays open for relaunch and close events, which is how a real app
would receive them.

It works - {"message":"registered","returnValue":true} - and the window still
does not appear. So this was a genuine gap in the sample and the right thing to
do regardless, but it is not what the compositor is waiting for.

WAYLAND_DEBUG=1 was the other thing to try and produces no protocol output from
libcbe, so that diagnostic is closed too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Reading SDL-webOS's windowing side and then the actual protocol traffic
relocates the problem entirely.

LG's libwayland-client honours WAYLAND_DEBUG but writes to
/tmp/wayland_<progname>_<pid>.log rather than stderr, which is why the earlier
attempt to capture it found nothing. With that log, what libcbe does is plain:

  -> wl_compositor@4.create_surface(new id wl_surface@20)
  -> wl_webos_shell@10.get_shell_surface(new id wl_webos_shell_surface@23, ...)
  -> wl_webos_shell_surface@23.set_state(3)
  -> wl_webos_shell_surface@23.set_property("appId", "org.webosbrew...")

All correct, no protocol errors, and set_state(3) is the same fullscreen request
SDL-webOS sends from Wayland_activate_window(). But across all five processes of
a run, each with its own connection log, attach=0 and commit=0. No buffer ever
reaches the surface.

So the window is created and configured properly and LSM has what it needs; the
missing piece is presentation. The page genuinely renders - progress, title,
first non-blank paint, load finished all arrive - and those pixels never reach
wl_surface@20. It also explains GetWindowHostState() staying 0: the compositor
sends no state event for a surface that has never committed.

Dropping --in-process-gpu on the theory that the GPU path was at fault changes
nothing.

Every windowing-API avenue chased over the last several commits was therefore
aimed at the wrong layer. The README now says where to start instead: capture
the same log from web/cbe on a webOS 4 set, where identical code does present,
and diff the two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
…g app

Two things this round, both from comparing against something that works.

media/ndl/esplayer - a plain SDL sample, already verified on this same TV -
does become the foreground app, which rules out the platform, the packaging and
the launch path together. Its Wayland trace also shows the shape libcbe is
missing: attach, damage, commit, and only then does the compositor reply
state_changed(3) by itself. SDL never sends set_state at all. So the window
state is a consequence of presenting a frame, not a precondition for it, and
every attempt in the previous commits to force the state was pushing on the
wrong end.

The app's own log had been carrying the real fault all along, buried in
Chromium's noise:

  ERROR:command_buffer_proxy_impl.cc(153)]
      Could not send GpuCommandBufferMsg_Initialize.

webOS 3's GPU path wants more setup than webOS 4's. Adding WAM's own GPU
switches for this generation - gpu-rasterization, impl-side-painting,
ignore-gpu-blacklist, threaded-compositing, num-raster-threads,
ui-use-prepare-shader-program, ui-disable-opaque-shader-program,
disable-low-res-tiling - makes the error go away.

Not sufficient: attach and commit are still 0 and the screen is unchanged. But
it is a real fault fixed, and a compositor cannot present a surface that
Chromium never draws into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
…s made

WAM's entire switch list, all twenty-five, adopted verbatim: the page still
loads and the window still never appears. Trimmed back to the GPU subset, which
is the part that fixed a real error, since the rest is noise in a sample.

Located the failure one step further in. libcbe logs EGL window surface
creation when it happens, and webOS 3's library has the same format string, but
it never appears in a run - so
weboswayland::WaylandDisplay::CreateAcceleratedSurface() is never reached. That
is the same fact as attach=0, seen from libcbe's side rather than the
compositor's.

Not for want of a window: it reports a valid handle, 1920x1080, non-null native
pointer.

Also tried and inert: SetVisibilityState(VISIBILITY_VISIBLE) and
SetHiddenState(false), on the theory that Chromium will not paint a page it
believes hidden, and calling AttachWebContents() after Show() instead of before,
the opposite of webOS 4's order.

One signal left unexplained rather than glossed over: "proxy 0x... already has
listener" on every run, which is libwayland saying a listener was added twice to
one proxy - and the trace does show libcbe creating two registries and binding
wl_webos_shell more than once. Whether that is benign or the cause is not
established.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
The answer was in the app's own log, under Chromium's NOTIMPLEMENTED noise:

  ERROR:display.cc(305)] Not implemented reached in
    weboswayland::WaylandDisplay::SetWidgetState(..., SHOW, ...)

SetWidgetState is the ozone call that maps a window. webOS 3's weboswayland
backend implements FULLSCREEN, INACTIVE and UNINITIALIZED - the only state
strings in the library - and SHOW falls through to NOTIMPLEMENTED. That is
precisely where the window would have been mapped, and it does nothing, which
is why no accelerated surface is ever created and nothing is committed.

It comes from inside libcbe rather than from the sample: removing our Show()
call does not stop it. And the other backend is no escape - --ozone-platform=
wayland reaches DesktopFactoryWayland and then the process dies, so
weboswayland is mandatory.

Also ruled out this round. The Wayland and DRM setup is complete: wl_drm binds,
authenticate is sent, authenticated() comes back with the full format list and
capabilities(0), so nothing is missing there. And unsquashing a webOS 3.4.3
rootfs from ~/Projects/webos-firmwares confirms what the 3.4.0 device showed -
exactly one binary links libcbe, WebAppMgr, so there is no in-firmware example
to copy.

The tension is stated rather than smoothed over: WAM's web apps do appear on
webOS 3 with this same backend, so either they map windows by a path this
sample has not found, or SHOW being unimplemented is benign there for a reason
not yet understood.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Mariotaku and others added 12 commits August 24, 2026 19:21
…ctions

Restarting WAM with WAYLAND_DEBUG and reading SAM's running-app list corrects
two claims this branch had been building on.

First: attach=0 is normal. WAM's own trace reaches attach=0 commit=0 too, so
libcbe does not present through wl_surface.attach on that connection and its
absence proves nothing - the previous commit's "no frame is ever committed"
framing was wrong. Diffed by request type, the only thing WAM sends that this
sample does not is wl_webos_xinput_extension.register_input, which is input.

Second, and the useful one: webOS 3 does have a native standalone libcbe
embedder. Asking SAM what is running - rather than searching the filesystem,
which is how the earlier wrong claim was reached - shows com.webos.app.browser
as native_builtin with a live pid, and /proc/<pid>/exe points into
/mnt/otncabi, outside every directory previously searched.

Its command line contradicts what this sample was doing: --ozone-platform=
wayland rather than weboswayland, --webos-launch-json carrying the app id as
"nid" rather than --webos-wam, plus a specific GPU set. weboswayland is WAM's
backend, the one whose SetWidgetState leaves SHOW unimplemented, so a non-WAM
app was never meant to use it.

Adopting that verbatim does not work yet - the process dies inside libcbe under
__vsnprintf_chk, and dropping just the launch-json still dies - so the sample
keeps the weboswayland combination that at least starts, and the README says
plainly that the browser runs jailed under /var/palm/jail and that this is the
thread to pull.

Also tried and inert: _WEBOS_WINDOW_TYPE=_WEBOS_WINDOW_TYPE_CARD, which WAM
sets on every window and this sample did not. Kept, since it is correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Adopted webOS 3's native browser configuration piece by piece and bisected what
breaks. With everything else from it in place - the launch-json handling,
CHROMIUM_BROWSER=yes, BROWSER_NAME=Chromium38, its GPU switches - the sample
still runs on weboswayland. Switching that one flag to --ozone-platform=wayland
kills it before Chromium writes a log line: zero-byte log, crash under
__vsnprintf_chk inside libcbe.

So the blocker is that single switch, and the jail is not involved - this
sample is jailed too, under /var/palm/jail/org.webosbrew.sample.web.cbe3, so
that suspect from the previous commit is wrong.

Two pieces of the browser's setup are kept because they are correct regardless
of this. SAM hands a native app its launch parameters as a bare JSON argument,
and the browser converts it to --webos-launch-json rather than forwarding it -
libcbe would otherwise see {"nid":...} where it expects a URL - so the sample
now does the same. And CHROMIUM_BROWSER/BROWSER_NAME are set, which the browser
has and a plain native app does not.

Environment was ruled out by diffing /proc/<pid>/environ between the running
browser and this sample: those two variables, plus fontconfig paths pointing
into the browser's own directory, are the only differences.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Chased the --ozone-platform=wayland crash properly, and it moved twice.

The first crash needed the crash report, because the log was the casualty:
zero bytes, with the backtrace in __vsnprintf_chk called from libcbe.
Disassembling that address shows a varargs logging helper - __snprintf_chk for
a prefix, then __vsnprintf_chk for the message - so libcbe segfaults inside its
own logger, and whatever it was trying to report is lost with it. The cause is
missing FONTCONFIG_PATH and FONTCONFIG_FILE, which the browser has pointing at
its own bundled fonts and a plain native app does not have at all. Setting them
to the system /etc/fonts is enough.

With those set the wayland backend gets much further - Ozone init, SAM
registration, the Luna lifecycle subscription - and then the parent dies on a
virtual call through a garbage vtable pointer. The disassembly shows a
set-delegate helper: store the pointer at this+64, then immediately call slot
10 on it. So that path wants a delegate a WAM-shaped embedder never supplies.
It is not this sample's reconstructed vtables - on weboswayland every delegate
callback arrives correctly.

--no-zygote is required too: removing it, as the browser's own command line
does, regresses to a zero-byte log.

The sample keeps weboswayland, which loads pages, and keeps the fontconfig,
CHROMIUM_BROWSER and launch-json changes, which are correct regardless of which
backend is used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
…two APIs

Chasing the --ozone-platform=wayland crash to the end answers it. Stepping
through startup one puts() at a time puts the crash in new SampleWindow() - the
webos::WebAppWindowBase constructor - and it is not timing, since delaying it
three seconds crashes identically.

The rest lines up behind that. webOS 3's libcbe contains two ozone platforms,
ozonewayland (registered as "wayland") and weboswayland; webOS 4's contains
only ozonewayland. On the TV, weboswayland is passed by exactly one process,
/usr/bin/WebAppMgr, while the native browser passes wayland - and the browser's
binary does not reference WebAppWindowBase at all, building its UI from
Browser::Init and the Views stack instead.

So they are a matched pair: WebAppWindowBase is WAM's windowing API and belongs
to weboswayland, and wayland is for Views-based apps. Constructing a
WebAppWindowBase under wayland segfaults inside libcbe because that object has
no backend there. It also explains why web/cbe works unchanged on webOS 4: LG
collapsed the two platforms into one by then.

A standalone embedder on webOS 3 therefore has to pick a side, and both are
incomplete for this sample's purposes - WAM's side works except for mapping the
window, and the browser's side is a different and much larger API that none of
the reconstructed headers here apply to.

The sample stays on weboswayland, which is the only backend its API exists on,
and the earlier fontconfig, CHROMIUM_BROWSER and launch-json findings are kept
since they are correct either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Correcting a correction. This branch previously concluded that attach=0 was
normal because WAM shows it too. That comparison was against WAM with only
com.webos.app.container up, which is preloaded and not visible. Restarting WAM
under WAYLAND_DEBUG and launching an app that really does reach the foreground
(com.palm.app.settings) gives attach=7 commit=7 on the browser process's own
connection, with wl_surface.attach/commit plainly in the trace.

So libcbe does present through wl_surface.attach on that connection, and its
absence in this sample is meaningful. It also settles the direction of
causation: LSM sends state_changed only after a buffer arrives - the working
SDL sample shows attach, damage, commit, then state_changed(3) and exposed - so
GetWindowHostState() reading back 0 is a consequence of having no frame, not a
cause of it.

The same trace shows WAM's architecture, worth knowing before copying from it:
one window created at startup, with set_property("appId", ...) swapped on that
same shell surface as apps come and go, rather than a window per app.

Ruled out this round: SetScaleFactor(1.0f), SetOpacity(1.0f) and
SetHiddenState(false) - the non-virtual WebAppWindowBase entry points WAM
imports and this sample was not calling, found by diffing readelf --dyn-syms on
libWebAppMgr.so against our call sites. Kept because WAM calls them, but inert.
Also ruled out: GetWebContents() returning null. It returns a valid pointer.

The answer to "can the WAM side render at all" is therefore yes on the evidence
- WAM does it on this backend, through this API, on this TV - and what remains
unknown is what triggers the first frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Ran out the remaining cheap comparisons against WAM, and they all match: the
Wayland request streams differ only by xinput_extension.register_input, the
thread lists are the same set including Chrome_InProcGp and WaylandDisplayP,
the environments are identical once CHROMIUM_BROWSER, BROWSER_NAME and
fontconfig are set, SAM registration succeeds, the window reports 1920x1080
with a native pointer and handle 1, and GetWebContents() returns a non-null
pointer that AttachWebContents receives.

The row that localises it is the delegate: DidFirstNonBlankPaint fires, so the
renderer is painting. What never happens is the browser-side compositor turning
that into a frame - CreateAcceleratedSurface is never reached, hence no EGL
surface, no buffer, no wl_surface.attach.

So the gap is between "renderer has painted" and "browser compositor asks the
GPU for an output surface for widget 1", and two libcbe stubs sit near it:
WebOSView::GetActiveWebContents() - which fires twice a run and is the more
suspicious, since it is the view that should host the contents - and
SetWidgetState(SHOW). Establishing whether WAM avoids them needs decompiling
WebOSView/WebOSWidgetView rather than more probing from outside.

Also recorded: --v=1 adds nothing because VLOG is compiled out of this build,
so Chromium's verbose logging is not an avenue. --enable-logging=stderr is kept
for page console.log.

Also checked and identical: /proc/<pid>/task thread lists, and the fact that
esplayer - a plain native SDL app on this TV - does map a window and take
foreground, which rules out LSM policy against non-WAM apps entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Decompiled it rather than guessing, and it is not the gap - but the technique
is worth keeping, since it is the only way into this binary.

libcbe has no .symtab and these functions are local, so the only handle on them
is the NOTIMPLEMENTED string. ARM Thumb reaches a string PC-relatively as a
literal V plus an "add rX, pc" at address P, with V = target - (P + 4), so
scanning .text for words whose implied P lands nearby and decodes as add rX,pc
(0x4478-0x447f) finds the real references and nothing else. That located the
stub body - logging::GetMinLogLevel, a LogMessage at line 102, matching
webos_view.cc(102) - and its single caller, which is four instructions:

  bl   GetActiveWebContents()    ; stub, returns NULL
  cbz  r0, done                  ; skipped silently
  ldr  r3, [r0]                  ; contents->vptr
  ldr  r3, [r3, #356]            ; slot 89
  blx  r3

So libcbe wants one method on the active WebContents and skips it. We hold that
pointer - GetWebContents() returns it - so the skipped call can be made by hand
through the same vtable slot. It runs cleanly and changes nothing: no window,
host state still 0. Whatever slot 89 does, it does not start compositing.

The experiment is removed rather than left in; the write-up and the
literal-resolution recipe stay, because the next attempt needs both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Imported libcbe into Ghidra and read both backends, correcting a claim that had
spread through several passages of the README.

weboswayland::WaylandDisplay::SetWidgetState and its ozonewayland counterpart
are structurally identical switches. Both implement CREATED, FULLSCREEN,
MAXIMIZED, MINIMIZED, RESTORE and ACTIVE; both leave SHOW, HIDE and INACTIVE as
NOTIMPLEMENTED - display.cc:305 and display.cc:254 respectively.

ozonewayland is the backend the TV's own native browser uses successfully and it
stubs SHOW identically, so the stub is normal, it is not what withholds the
window, and the FULLSCREEN path this sample already drives is the implemented
one. The three places that described the stub as the thing standing between this
sample and a window now say what is actually missing instead: the browser-side
compositor never asking the GPU for an output surface.

Also confirms the Ghidra import is readable over MCP: weboswayland's
CreateAcceleratedSurface allocates a window object, keys it into a map by widget
id, and emits the "Wayland Window(id:%d widget:%p) is created" line seen on
webOS 4 and never here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
The webOS 3 sample is unfinished: the reconstructed ABI is correct and
demonstrated - the page loads and every delegate callback fires - but the
window never reaches the screen. The failure is localised and every dead end is
written down, so the README now says so at the top rather than leaving a reader
to work through the whole investigation before finding out it does not work.

Points at the two things that do: web/cbe on webOS 4, and neva_app_runtime on
webOS 6 and newer, which has public upstream headers and needs no reverse
engineering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
A third shape of webos::WebViewBase, measured on a 65UP7560 at starfish 6.5.2.
The entry point is a class - webos::WebOSMain(delegate).Run(argc, argv) - the
view constructor takes (bool, int, int), the delegate is 61 slots rather than
24, and sizeof(WebViewBase) is 92 rather than 8.

That size is load-bearing and cost the most time to find: libcbe's constructor
writes to offset 90 and WAM's BlinkWebView allocates 116 with its own fields
starting at 92, so a subclass declared smaller corrupts the heap and the process
dies inside malloc much later, with a backtrace nowhere near the cause.

Initialize has two overloads and they split the range: webOS 5 has only the
10-argument form, 7.4 through 11.2 only the 6-argument one, and 6.4 carries
both. This sample uses the 10-argument form because that is what WAM calls on
6.5, which caps it at webOS 6; the 6-argument form verifies clean to 11.2 and is
the basis for a 7+ variant.

Recorded why neva_app_runtime is not the shortcut it appears to be, since that
was the plan going in: nothing in the firmware uses it, so there is no vtable to
recover names from, and its own vtable has 40 slots where the nearest public
headers declare 32 and 26 - LG's build is Chromium 79, between the two public
trees. It is a naming reference, not a layout, and without WAM to check against
it is a worse starting point than webos::.

Not working yet: the delegate is reached and DidStartNavigation fires, but its
string argument arrives as garbage, so a slot is misaligned in the first 17. The
README says so at the top rather than presenting a sample that does not run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
The page loads and renders full-screen on a 65UP7560 at starfish 6.5.2, and the
app takes the foreground.

The fault was one slot out of 61. Most delegate slots return void, so a
placeholder with an empty body is harmless - the caller ignores r0. Slot 53 is
GetWebContents(), it is virtual, and libcbe calls it through the vtable during
Initialize(). Declared as a void placeholder it handed libcbe whatever was in r0
as a WebContents*, and the process died inside Initialize with a backtrace
pointing at libcbe rather than at the mistake.

It is now pure in the delegate and overridden in WebViewBase with no body, so
the slot resolves to libcbe's own _ZN5webos11WebViewBase14GetWebContentsEv at
link time and the app inherits the real implementation instead of shadowing it.

Finding it needed a slot tracer: every one of the 61 overrides replaced by a
body that prints its index and touches no argument. Exactly one line came out,
"[slot] 53", which named it at once - reading arguments had only ever produced
garbage. That technique is written up, because it generalises to any of these
reconstructed vtables: a slot that returns a pointer cannot be stubbed, and a
tracer finds it in a single run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Same web view as web/cbe-webos6 through neva_app_runtime, the other embedding
API in the same libcbe - and the only one under web/ that starts from published
source rather than from a vtable (webosose/chromium87,
src/neva/app_runtime/public). Verified on a 65UP7560 at starfish 6.5.2: the page
loads and renders full-screen and the app takes the foreground. -verify is clean
from 6.4 to 11.2.

It is the smaller API by some margin: AppRuntimeMain(argc, argv) rather than a
WebOSMain class, no Initialize at all, 40 delegate slots against 61, and a
16-byte WebViewBase against 92.

The published headers still needed checking, because the TV is Chromium 79 -
between the public chromium68 and chromium87 trees - and its vtable has 40 slots
where those declare 26 and 32. What settles the order without depending on names
is which slots are pure virtual: libcbe's vtable relocates a pure slot to
__cxa_pure_virtual, and that pattern is 0-15, 18, 19, 20, 21, 29 against
upstream's 0-15, 18, 19, 20, 21 - identical through slot 28. So 0-28 are
upstream's in upstream's order, and 29-39 are LG additions left as placeholders.

Two traps recorded: AppRuntimeMain is C++-mangled rather than extern "C", which
-verify catches; and the object sizes are load-bearing the same way they are on
the webos:: side, 16 and 8 bytes here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYYTWYi9WkpdpJ5EpwZD9i
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant