Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Recent cleanup keeps `MainActivity` as the Android boundary while moving cluster
1. App starts and loads encrypted WebUI settings (`SettingsRepository`). The bundled dashboard origin default is blank so WebUI owns dashboard auto-detect and persistence.
2. WebView boots with hardened configuration, default HTTP cache behavior, DOM storage, and service-worker cache settings for WebUI-managed assets.
3. The Compose root fills the full window background, then applies `WindowInsets.safeDrawing` around the WebView shell and native snackbar so Android 15 edge-to-edge enforcement does not put content under status or navigation bars.
4. Android WebView compatibility shims stay scoped to Hermes WebUI. Android keeps native long-click enabled without a consuming listener so message text remains selectable while Hermes WebUI's own touch timer drives its action menus. A document-start hybrid viewport polyfill fixes the Android WebView bug where CSS viewport units (`vh`, `dvh`, `svh`, `lvh`) evaluate to `0px` instead of actual dimensions before WebUI boot code measures the page: the polyfill injects CSS custom properties (`--vh`, `--dvh`) with stable layout-viewport values plus separate visual-viewport height/top values for keyboard-constrained prompts, applies baseline CSS for root/layout containers, and uses generic collapse detection to find and repair elements that appear collapsed due to the viewport-unit bug. Generic repair changes only height constraints, preserving the element's original overflow contract so it neither creates a new clipping container nor loses an existing inline overflow declaration; visible repairs retain their measured constraints until hidden because repaired geometry cannot prove the underlying viewport-unit rule recovered. Approval and Clarify surfaces are excluded from generic repair and instead shift above the visual-viewport bottom before fitting the measured space below the titlebar/visual-viewport top, dropping below WebUI's preferred 180px floor when necessary and scrolling internally. Runtime application remains as a fallback for already-loaded content. Android also injects a document-start microphone fallback so WebUI voice input uses its MediaRecorder path instead of Web Speech API. Clarify keyboard compatibility keys its one-shot suppression to WebUI's current Clarify ID/signature (with a DOM fallback), so replacing a visible card starts a new focus contract: only that request's first automatic `#clarifyInput` focus is suppressed, while direct Android touches, hardware Tab navigation, the **Other** action, and later validation/error refocus remain available. Unrelated editable dialogs are never inspected or mutated. Attached-WebView instrumentation executes these focus, real-touch, geometry, and overflow contracts in required PR and release gates. The official dashboard is not rendered in an app WebView.
4. Android WebView compatibility shims stay scoped to Hermes WebUI. Android keeps native long-click enabled without a consuming listener so message text remains selectable while Hermes WebUI's own touch timer drives its action menus. Native WebView zoom gestures are enabled without deprecated on-screen controls, and a trusted-origin document-start shim overrides restrictive viewport directives so pinch-to-zoom remains available. A document-start hybrid viewport polyfill fixes the Android WebView bug where CSS viewport units (`vh`, `dvh`, `svh`, `lvh`) evaluate to `0px` instead of actual dimensions before WebUI boot code measures the page: the polyfill injects CSS custom properties (`--vh`, `--dvh`) with stable layout-viewport values plus separate visual-viewport height/top values for keyboard-constrained prompts, applies baseline CSS for root/layout containers, and uses generic collapse detection to find and repair elements that appear collapsed due to the viewport-unit bug. Generic repair changes only height constraints, preserving the element's original overflow contract so it neither creates a new clipping container nor loses an existing inline overflow declaration; visible repairs retain their measured constraints until hidden because repaired geometry cannot prove the underlying viewport-unit rule recovered. Approval and Clarify surfaces are excluded from generic repair and instead shift above the visual-viewport bottom before fitting the measured space below the titlebar/visual-viewport top, dropping below WebUI's preferred 180px floor when necessary and scrolling internally. Runtime application remains as a fallback for already-loaded content. Android also injects a document-start microphone fallback so WebUI voice input uses its MediaRecorder path instead of Web Speech API. Clarify keyboard compatibility keys its one-shot suppression to WebUI's current Clarify ID/signature (with a DOM fallback), so replacing a visible card starts a new focus contract: only that request's first automatic `#clarifyInput` focus is suppressed, while direct Android touches, hardware Tab navigation, the **Other** action, and later validation/error refocus remain available. Unrelated editable dialogs are never inspected or mutated. Attached-WebView instrumentation executes these focus, real-touch, geometry, and overflow contracts in required PR and release gates. The official dashboard is not rendered in an app WebView.
5. On the Hermes WebUI route, Android does not write `/api/dashboard/config` or overwrite WebUI's Official Hermes Dashboard setting. WebUI owns dashboard auto-detect, persistence, rendering, and behavior for the dashboard link in its rail/sidebar.
6. Official Hermes Dashboard links are treated as secondary browser surfaces. When Android has an explicitly configured local dashboard origin, it handles matching WebView new-window requests and dashboard-origin navigations by launching a Chrome Custom Tab with title/share UI minimized, instead of replacing the primary Hermes WebUI WebView. OAuth/OIDC callbacks are handled before this dashboard matching so a configured dashboard origin cannot steal `/auth/callback` from the primary Hermes WebView.
7. Hermes WebUI OAuth/OIDC sign-in stays inside Android once a trusted authorization code flow starts. Android parses the authorization request `redirect_uri`, keeps popup or top-level HTTP/HTTPS provider redirects in-app only when the declared callback returns to the configured Hermes WebUI origin, and loads the verified callback endpoint back into the primary WebView when it returns with a `code` or `error`. Scheme compatibility is asymmetric: an HTTP origin may upgrade to HTTPS for public-IP/proxy deployments, but an HTTPS origin and declared callback can never downgrade to HTTP. A separate bounded return state keeps the callback and all same-origin redirects in the primary WebView until a finished page proves it is Hermes WebUI through its bundle or shell DOM marker, covering popup callbacks, `onPageStarted` callback ordering, 302 chains, JavaScript redirects, and same-origin interstitials without allowing a dashboard Custom Tab match to steal the return. Callback URLs are never persisted as startup state. During the provider flow window, Android temporarily enables third-party cookies and restores the stricter default once the provider flow ends or times out.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,69 @@ class HermesWebUiCompatibilityTest {
}
}

@Test
fun runtimeOriginGuard_staleHermesCallbackDoesNotMutateCurrentProviderPage() {
loadFixture(
body = "<div id=\"provider-page\">OAuth provider</div>",
baseUrl = "https://oauth.provider.test/"
)
val guardedPinchZoomScript = HermesWebUiScripts.buildOriginGuardedRuntimeScript(
trustedOrigin = "https://hermes.test",
script = HermesWebUiScripts.pinchZoomScript
)

// Models a delayed runtime fallback queued from a stale Hermes page callback: the
// current document is already the provider page when evaluateJavascript executes.
evaluate(guardedPinchZoomScript)

assertThat(evaluate("document.querySelector('meta[name=\"viewport\"]').content"))
.isEqualTo("\"width=device-width,initial-scale=1\"")
assertThat(evaluateBoolean("window.location.origin === 'https://oauth.provider.test'"))
.isTrue()
}

@Test
fun runtimeOriginGuard_executesOnConfiguredHermesOrigin() {
loadFixture(body = "<div>Hermes</div>")
val guardedPinchZoomScript = HermesWebUiScripts.buildOriginGuardedRuntimeScript(
trustedOrigin = "https://hermes.test",
script = HermesWebUiScripts.pinchZoomScript
)

evaluate(guardedPinchZoomScript)

assertThat(evaluate("document.querySelector('meta[name=\"viewport\"]').content"))
.isEqualTo("\"width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes\"")
}

@Test
fun runtimeOriginGuard_resistsProviderPageReplacingTheUrlConstructor() {
loadFixture(
body = "<div id=\"provider-page\">OAuth provider</div>",
baseUrl = "https://oauth.provider.test/"
)

// A hostile/foreign page can replace window.URL before the delayed evaluateJavascript
// runs. A guard that resolved its trusted origin through `new URL(...)` would get this
// page's own origin back and execute. The guard must compare a literal instead.
evaluate(
"""
window.URL = function() { return { origin: window.location.origin }; };
""".trimIndent()
)

val guardedPinchZoomScript = HermesWebUiScripts.buildOriginGuardedRuntimeScript(
trustedOrigin = "https://hermes.test",
script = HermesWebUiScripts.pinchZoomScript
)
evaluate(guardedPinchZoomScript)

assertThat(evaluate("document.querySelector('meta[name=\"viewport\"]').content"))
.isEqualTo("\"width=device-width,initial-scale=1\"")
assertThat(evaluateBoolean("window.location.origin === 'https://oauth.provider.test'"))
.isTrue()
}

@Test
fun clarifyAutofocus_suppressesOnlyAutomaticClarifyFocus() {
loadFixture(
Expand Down Expand Up @@ -345,7 +408,7 @@ class HermesWebUiCompatibilityTest {
}

@SuppressLint("SetJavaScriptEnabled")
private fun loadFixture(body: String) {
private fun loadFixture(body: String, baseUrl: String = "https://hermes.test/") {
val loaded = CountDownLatch(1)
composeTestRule.setContent {
WebViewHost { view ->
Expand All @@ -360,7 +423,7 @@ class HermesWebUiCompatibilityTest {
}
}
view.loadDataWithBaseURL(
"https://hermes.test/",
baseUrl,
"<!doctype html><html><head><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"></head><body>$body</body></html>",
"text/html",
"UTF-8",
Expand Down
33 changes: 25 additions & 8 deletions app/src/main/java/com/hermeswebui/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ class MainActivity : ComponentActivity() {
private var pendingLocalNetworkPermissionAction: (() -> Unit)? = null
private var pendingLocalNetworkPermissionDeniedAction: (() -> Unit)? = null
private var viewportFixScriptHandler: ScriptHandler? = null
private var pinchZoomScriptHandler: ScriptHandler? = null
private var microphoneFallbackScriptHandler: ScriptHandler? = null
private var notificationBridgeScriptHandler: ScriptHandler? = null
private var routeRecoveryScriptHandler: ScriptHandler? = null
Expand Down Expand Up @@ -1626,15 +1627,24 @@ class MainActivity : ComponentActivity() {
}

private fun applyHermesWebUiRuntimeScripts(view: WebView) {
view.evaluateJavascript(HermesWebUiScripts.viewportFixScript, null)
view.evaluateJavascript(HermesWebUiScripts.microphoneFallbackScript, null)
view.evaluateJavascript(HermesWebUiScripts.suppressClarifyAutofocusScript, null)
view.evaluateJavascript(buildHermesWebUiNotificationBridgeScript(), null)
view.evaluateJavascript(buildHermesWebUiRouteRecoveryScript(), null)
if (EnableAppSettingsSidebarShim) {
view.evaluateJavascript(HermesWebUiScripts.appSettingsEntryScript, null)
val settings = viewModel.uiState.value.settings
val trustedOrigin = UrlOrigins.pageOrigin(settings.serverUrl) ?: return
val scripts = buildList {
add(HermesWebUiScripts.viewportFixScript)
add(HermesWebUiScripts.pinchZoomScript)
add(HermesWebUiScripts.microphoneFallbackScript)
add(HermesWebUiScripts.suppressClarifyAutofocusScript)
add(buildHermesWebUiNotificationBridgeScript())
add(buildHermesWebUiRouteRecoveryScript())
if (EnableAppSettingsSidebarShim) add(HermesWebUiScripts.appSettingsEntryScript)
add("window.__hermesAndroidHardwareKeyboard = ${isHardwareKeyboardAttached()};")
}
scripts.forEach { script ->
view.evaluateJavascript(
HermesWebUiScripts.buildOriginGuardedRuntimeScript(trustedOrigin, script),
null
)
}
syncHardwareKeyboardState(view)
}

private fun isHardwareKeyboardAttached(): Boolean {
Expand Down Expand Up @@ -1668,6 +1678,11 @@ class MainActivity : ComponentActivity() {
originRule,
HermesWebUiScripts.viewportFixScript
)
pinchZoomScriptHandler = addDocumentStartScript(
view,
originRule,
HermesWebUiScripts.pinchZoomScript
)
microphoneFallbackScriptHandler = addDocumentStartScript(
view,
originRule,
Expand Down Expand Up @@ -1705,13 +1720,15 @@ class MainActivity : ComponentActivity() {
private fun removeHermesWebUiDocumentStartFixes() {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return
viewportFixScriptHandler?.remove()
pinchZoomScriptHandler?.remove()
microphoneFallbackScriptHandler?.remove()
notificationBridgeScriptHandler?.remove()
routeRecoveryScriptHandler?.remove()
appSettingsEntryScriptHandler?.remove()
enterKeyNewlineScriptHandler?.remove()
suppressClarifyAutofocusScriptHandler?.remove()
viewportFixScriptHandler = null
pinchZoomScriptHandler = null
microphoneFallbackScriptHandler = null
notificationBridgeScriptHandler = null
routeRecoveryScriptHandler = null
Expand Down
Loading
Loading