Files
orca/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellGeneration.swift
Jinwoo Hong b749091b67 feat(mobile): native shell view serving a mobile web generation from a private origin (OTA phase B, 3/4) (#21417)
* feat(mobile): declare the orca-mobile-web-shell TS surface

Two props and one event: a generation directory the TypeScript store owns, a
session id that scopes the private origin, and a load state. No module
functions and no reload — a retry is a remount under a new React key, which
rebuilds the WebView and reinstalls every fence.

The native event body is a flat dictionary, so parseMobileWebShellLoadState
rebuilds the union instead of asserting it and answers null for anything it
does not recognise.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): serve a generation from a private origin on iOS

A WKWebView behind a custom-scheme handler that answers only from a map built
once from the generation's manifest, with the CSP as a response header on the
document. The scheme handler reads on a serial background queue and keeps a
live-task set that stop() removes from: an asset is up to 10 MiB, and
delivering to a stopped task raises an Objective-C exception Swift cannot
catch.

Origin, request refusal, the manifest map and the policy header hold no WebKit
type, so tests/MobileWebShellChecks.swift compiles and runs them with swiftc,
no device.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): compare the iOS shell's applied props field by field

One joined string could not tell a directory ending in the separator from a
shorter one with a longer session id. Two fields have no separator to collide
on.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin that a string schemaVersion is not a manifest

The contract declares a number. The Kotlin side read it with optInt, which
coerces "1" to 1, so a manifest that widened the field would have been served;
this check covers the same shape on both platforms.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): serve a generation from a private origin on Android

A WebView behind shouldInterceptRequest, answering only from the same
manifest-built map as iOS, with the CSP as a response header on the document.
The origin host label is a slice of the session id's SHA-256, never of the
session id: Chromium lowercases an https host and java.net.URI reads null for
a label holding '_', which is how the reference 403'd every asset.

A main-frame failure is reported from a post() because Chromium commits its
own error document after onReceivedError returns. onRenderProcessGone destroys
the dead WebView and does not rebuild it, so the retry policy stays in one
place. clearCache(true) is never called: it is process-global and would wipe
the terminal WebView's cache too.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): untrack the shell module's gradle build output

The previous commit staged 312 files from android/build. mobile/.gitignore
anchors /android/ at the mobile root, so a module's own gradle output was
never covered.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): parse the shell load-state payload with a zod shape

The anti-slop gate rejects an `object` parameter and `Reflect.get`. zod reads
a shape key straight off the value, so the own-property strip stays.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): give the web shell one load-state machine per platform

A failure is terminal, and a repeat says nothing. Chromium commits its error
document after onReceivedError returns and a rule list compiles long after a
generation was refused, so both platforms could report over a failure the
caller had already acted on.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop the Android shell reporting ready over a failed document

onPageFinished ran after reportDocumentFailure's post and both emitted `ready`
and set the WebView visible again, putting Chromium's error page on screen. A
prop change after the renderer died now reports instead of going silent.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): publish the Android shell's served generation atomically

The map and the host it is keyed against were two plain fields written on the
main thread and read on Chromium's, so an interceptor could see a stale null
and 403 a good frame, or a new map against the previous host.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the iOS shell to one terminal load state

A rule list that failed to compile after a generation was already refused
emitted a second, contradictory reason. The document-failure flag it carried
is now the state machine's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop serving the previous generation after a failed prop update

Both platforms returned early with the old map still installed and the old page
still on screen, so a caller told the shell had failed was looking at a working
one from the generation before.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): serve the shell document at "/" and nowhere else

/index.html answered the same bytes without the policy header, which rides the
document response alone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the shell's response headers as a pure predicate

Which response carries the policy header was decided inside the two request
handlers, where no test without a device can reach it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the shell's path-length edge and its charset casing

Both limits were checked only from the rejecting side, so a one-off length and
an uppercase charset passed unnoticed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): state the Android shell's file-URL settings and what B4 must check

The two file-URL settings were left to their defaults, and the settings that
only a device can prove named nobody to prove them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): drop the shell module's unresolved entry points

Nothing imports the module by name, on either side; the TypeScript is reached
by path, as the notification-dismissal module's is.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop the iOS shell failing a document it cancelled itself

stopLoading on a prop update and every navigation the policy delegate refuses
reach the failure delegates as errors, so a healthy page reported `failed`,
lost its `ready`, and sent the caller to delete a good cached generation.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): answer when the iOS rule list store is missing

Optional-chaining past a nil store ran no completion handler, so the view
stayed at `loading` for good. The next prop update now reads the same terminal
isolation failure a compile failure sets.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a manifest whose schemaVersion is true or 1.0 on iOS

NSNumber bridges both to 1, so `as? Int` accepted a manifest Kotlin rejects.
Verified against JSONSerialization: objCType is c for true, d for 1.0, q for 1.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): drop an Android document failure the next load did not have

The report is deferred past Chromium's error document, so a prop update could
land between the decision and the report and fail the generation that had just
replaced the one that actually failed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): assert each blocked global's descriptor whole

contains("writable:false") passed on a WebSocket descriptor that had lost it,
because the serviceWorker copy still carried one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the Android shell's navigation and refusal decisions

Both lived inside the WebViewClient, which no suite compiles, so dropping the
navigation guard or answering a refusal with 200 changed nothing anyone could
see.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): name the domain a policy-cancelled frame load is reported under

WKErrorDomain has no frame-load codes: WKErrorCode stops at the app-bound
domain errors, and 102 belongs to the legacy WebKitErrorDomain. The iOS SDK
exports no symbol for it, so the assert that pinned one is gone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 05:03:30 -04:00

139 lines
6.0 KiB
Swift

import Foundation
struct MobileWebShellAsset {
let file: URL
let contentType: String
}
enum MobileWebShellGenerationError: Error {
case unreadable
}
/// The served surface of one activated generation: a request path to file map, built once from the
/// manifest before anything loads. Serving is a lookup in this map and never a path join at request
/// time, so "not in the manifest" is a refusal by construction rather than by sanitiser.
///
/// Asset bytes are not re-hashed here. The TypeScript store verified every byte against the
/// manifest before the activating rename, and the directory path is one the app owns and the page
/// can never influence. Framework-free so `swiftc` can check it.
struct MobileWebShellGeneration {
static let manifestName = "manifest.json"
static let manifestContentType = "application/json"
static let schemaVersion = 1
static let entrypoint = "index.html"
static let maxAssets = 256
static let maxAssetPathLength = 255
static let maxContentTypeLength = 128
let entries: [String: MobileWebShellAsset]
static func load(directoryPath: String) throws -> MobileWebShellGeneration {
guard directoryPath.hasPrefix("/") else { throw MobileWebShellGenerationError.unreadable }
let directory = URL(fileURLWithPath: directoryPath, isDirectory: true)
guard
let data = try? Data(contentsOf: directory.appendingPathComponent(manifestName))
else { throw MobileWebShellGenerationError.unreadable }
return try make(manifestData: data, directory: directory)
}
static func make(manifestData: Data, directory: URL) throws -> MobileWebShellGeneration {
let parsed = try? JSONSerialization.jsonObject(with: manifestData)
guard
let root = parsed as? [String: Any],
isPinnedSchemaVersion(root["schemaVersion"]),
let declaredEntrypoint = root["entrypoint"] as? String,
declaredEntrypoint == entrypoint,
let assets = root["assets"] as? [[String: Any]],
!assets.isEmpty,
assets.count <= maxAssets
else { throw MobileWebShellGenerationError.unreadable }
var entries: [String: MobileWebShellAsset] = [:]
for asset in assets {
guard
let path = asset["path"] as? String,
isServableAssetPath(path),
let contentType = asset["contentType"] as? String,
isServableContentType(contentType)
else { throw MobileWebShellGenerationError.unreadable }
entries["/\(path)"] = MobileWebShellAsset(
file: directory.appendingPathComponent(path, isDirectory: false),
contentType: contentType
)
}
// Removed, not copied: the document answers at "/" and nowhere else, so the one response that
// carries the policy header is the only way to reach those bytes.
guard let document = entries.removeValue(forKey: "/\(entrypoint)") else {
throw MobileWebShellGenerationError.unreadable
}
entries["/"] = document
// The manifest is written last and is not part of the content hash, so it is not in `assets`;
// the bootstrap page still reads it from its own origin.
entries["/\(manifestName)"] = MobileWebShellAsset(
file: directory.appendingPathComponent(manifestName, isDirectory: false),
contentType: manifestContentType
)
return MobileWebShellGeneration(entries: entries)
}
/// `as? Int` is not this check: NSNumber bridges `true` and `1.0` to 1, and the contract pins the
/// integer 1. JSONSerialization keeps the written form, so the number's own type answers it.
static func isPinnedSchemaVersion(_ value: Any?) -> Bool {
guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else {
return false
}
let numberType = String(cString: number.objCType)
guard numberType != "d", numberType != "f" else { return false }
return number.intValue == schemaVersion
}
/// Re-checked here rather than trusted: the schema that pins this shape is on the other side of
/// a file the native layer cannot see change.
static func isServableAssetPath(_ path: String) -> Bool {
guard !path.isEmpty, path.utf8.count <= maxAssetPathLength else { return false }
for segment in path.split(separator: "/", omittingEmptySubsequences: false) {
guard !segment.isEmpty, segment != ".", segment != ".." else { return false }
let valid = segment.allSatisfy { character in
character.isASCII &&
(character.isLetter || character.isNumber || character == "." || character == "_" ||
character == "-")
}
guard valid else { return false }
}
return true
}
/// This value becomes a response header, so it must not be able to carry a second header or a
/// parameter we did not intend. One lowercase type, one optional charset: the manifest
/// contract's only accepted spelling.
static func isServableContentType(_ contentType: String) -> Bool {
guard !contentType.isEmpty, contentType.utf8.count <= maxContentTypeLength else { return false }
var type = Substring(contentType)
if let separator = contentType.range(of: "; charset=") {
let charset = contentType[separator.upperBound...]
let validCharset = !charset.isEmpty && charset.allSatisfy { character in
character.isASCII &&
(("a"..."z").contains(character) || ("0"..."9").contains(character) || character == "-")
}
guard validCharset else { return false }
type = contentType[contentType.startIndex..<separator.lowerBound]
}
let halves = type.split(separator: "/", omittingEmptySubsequences: false)
guard halves.count == 2 else { return false }
return halves.allSatisfy(isMimeToken)
}
private static func isMimeToken(_ token: Substring) -> Bool {
guard
let first = token.first,
first.isASCII,
("a"..."z").contains(first) || ("0"..."9").contains(first)
else { return false }
return token.allSatisfy { character in
character.isASCII &&
(("a"..."z").contains(character) || ("0"..."9").contains(character) ||
character == "." || character == "+" || character == "-")
}
}
}