feat(napplet): SPA route fallback + external-link handoff in the host

Two nsite/napplet runtime improvements in the sandboxed WebView host,
both keeping the trust boundary intact:

- SPA fallback: a document navigation (Accept: text/html) to a route not
  in the manifest now serves the verified index.html instead of 404, so
  client-side-routed static sites survive deep links and refreshes.
  Missing sub-resources (js/css/images) still 404 — they don't accept
  html — so a broken asset never silently returns the page. The fallback
  only ever serves the manifest's own hash-verified index.html.

- External links: a link the user taps to an off-origin host is handed to
  the system browser via ACTION_VIEW instead of silently failing. Gated on
  a real user gesture (so a hostile site can't auto-redirect to spam-open
  the browser) and restricted to http/https (no arbitrary intent schemes).
  The sandbox WebView itself never navigates away from the internal origin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-21 19:29:43 +00:00
parent 0fb1ca473e
commit 76d9951d4f
@@ -242,7 +242,11 @@ class NappletHostActivity : ComponentActivity() {
if (!url.startsWith(ORIGIN)) return notFound()
if (url == SHELL_URL) return serveShell()
if (url == APP_BASE || url.startsWith(APP_BASE)) return serveAppResource(url)
if (url == APP_BASE || url.startsWith(APP_BASE)) {
// A document navigation accepts text/html; a sub-resource (js/css/img) does not.
val acceptsHtml = request.requestHeaders["Accept"]?.contains("text/html", ignoreCase = true) == true
return serveAppResource(url, acceptsHtml)
}
return notFound()
}
@@ -250,8 +254,19 @@ class NappletHostActivity : ComponentActivity() {
view: WebView,
request: WebResourceRequest,
): Boolean {
// Block any navigation away from our internal origin (e.g. applet link clicks).
return request.url.host != HOST
val uri = request.url
// Internal origin: let the WebView load it (it goes through shouldInterceptRequest).
if (uri.host == HOST) return false
// An external link the user actually tapped is handed to the system browser. A user
// gesture is required so a hostile site can't auto-redirect to spam-open the browser,
// and only http(s) is honored so it can't fire arbitrary intent schemes.
if (request.hasGesture() && (uri.scheme == "https" || uri.scheme == "http")) {
runCatching {
startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
// Never navigate the sandbox WebView away from our internal origin.
return true
}
}
@@ -267,7 +282,10 @@ class NappletHostActivity : ComponentActivity() {
)
}
private fun serveAppResource(url: String): WebResourceResponse {
private fun serveAppResource(
url: String,
acceptsHtml: Boolean,
): WebResourceResponse {
val requestPath =
url
.removePrefix(APP_BASE)
@@ -275,7 +293,16 @@ class NappletHostActivity : ComponentActivity() {
.substringBefore('#')
.let { if (it.isEmpty()) "/" else "/$it" }
val resolution = runBlocking { StaticSiteResolver.resolve(requestPath, paths, servers, fetch) }
var resolution = runBlocking { StaticSiteResolver.resolve(requestPath, paths, servers, fetch) }
// SPA fallback: a document navigation (Accept: text/html) to a route that isn't in the
// manifest falls back to the verified index.html, so client-side-routed sites survive deep
// links and refreshes. Missing sub-resources (js/css/images) still 404 — they don't accept
// html — so a broken asset never silently returns the page.
if (resolution !is StaticSiteResolution.Resolved && acceptsHtml && requestPath != "/") {
resolution = runBlocking { StaticSiteResolver.resolve("/", paths, servers, fetch) }
}
if (resolution !is StaticSiteResolution.Resolved) return notFound()
val (mime, charset) = splitContentType(resolution.contentType)