web/: embed the TV's Chromium (libcbe) in a native app - #1
Conversation
/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
Keyboard focus in the web view: a limitation, and a dead endWalked the whole sign-in flow on a 49LK5900 (webOS 4.4.3) one remote key at a time, injecting with What actually happens
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:
The web view starts with nothing focusedEnter 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:
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 The one real cluelibcbe says so itself: Widget activation is a stub in this build. In Chromium's views that is what hands focus to the Worth trying next
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
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
…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
Adds a
web/family alongsidemedia/: 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.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:dev-toolbox-cli/common/data/*/libcbe.so.json), which give every name and signature across every release;libWebAppMgr.socontainsBlinkWebViewandWebAppWaylandWindow, 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 sayswebos::WebViewBaseis exactly a vptr plus one pimpl pointer.Three facts are load-bearing and are commented as such:
WebViewDelegatehas no virtual destructor — adding one shifts every slot by two.WebAppWindowDelegatedoes have one, so the two classes are not symmetric.-fno-rttiand 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
GMainContexton its browser UI thread, so work queued there beforeWebOSMain()runs on that thread once Chromium is up. That is where windows and web views may first be created, and it is also howweb/hybriddrives 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 withstd::__cxx11::basic_stringthere without statically linking a newer runtime. webOS 5 replaced the freeWebOSMain()with awebos::WebOSMainclass and changedWebViewBase's constructor; webOS 6 added a second parallel API underneva_app_runtime. Thewebos::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.pakcompiled 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/cberenders a page full-screen at 1920x1080 and registers with LSM and SAM as the foreground card;web/hybridruns both views from one process and switches in both directions, with the page's exit button reaching native code throughTitleChanged, 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++);page.htmloverfile://needs three settings — withoutSetAllowUniversalAccessFromFileUrlsthe renderer is killed mid-load withbad IPC message, reason 114rather 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/uinputare not routed to the app by LSM. TheSDLK_RETURNand Back paths are wired but unexercised, and both READMEs say so. Lifecycle (SAM relaunch/close, background suspend) is not implemented either.web/cbe/README.mdandweb/hybrid/README.mdcarry 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