fix(mobile): accept three packaged documents and drop stale generations on close

Codex review of the native store: Swift and Kotlin both accept the mermaid
frame as a third document, remove superseded generations once their last
session closes, and the session hook retries once per build id so a
desktop upgrade gets its own recovery.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-07 18:09:42 -04:00
parent a4d7bfb554
commit f7745cf033
4 changed files with 72 additions and 6 deletions
@@ -173,7 +173,11 @@ internal class MobileWebPackageStore internal constructor(
@Synchronized
fun closeSession(sessionId: String) {
sessions.remove(sessionId)
val session = sessions.remove(sessionId) ?: return
runCatching {
val generations = File(File(cacheRoot, session.hostKey), "generations")
removeOtherGenerations(generations, session.hostKey, requestedBuildId(generations, null))
}
}
@Synchronized
@@ -329,7 +333,7 @@ internal class MobileWebPackageStore internal constructor(
val assets = parsedAssets(assetValues)
require(
assets.values.sumOf { it.byteLength } == declaredTotalBytes &&
assets.values.count { it.role == "document" } in 1..2 &&
assets.values.count { it.role == "document" } in 1..MOBILE_WEB_DOCUMENT_PATHS.size &&
entrypoint == "index.html" &&
assets[entrypoint]?.role == "document"
) { "mobile_web_manifest_invalid" }
@@ -51,6 +51,31 @@ class MobileWebPackageStoreTest {
)
}
@Test
fun commitsAllPackagedDocuments() {
val store = jvmMobileWebPackageStore(temporary.newFolder())
val paths = listOf("index.html", "markdown-editor.html", "mermaid-frame.html")
val fixture = mobileWebStoreFixture { manifest ->
val asset = manifest.getJSONArray("assets").getJSONObject(0)
manifest.put("assets", org.json.JSONArray(paths.map { path ->
JSONObject(asset.toString()).put("path", path)
}))
manifest.put("totalBytes", manifest.getInt("totalBytes") * paths.size)
}
paths.forEach { path ->
store.writeStagedAsset(
"paired-host", fixture.buildId, path, Base64.getEncoder().encodeToString(fixture.bytes)
)
}
store.commitGeneration("paired-host", fixture.buildId, fixture.manifestJson)
val session = store.openSession("paired-host", fixture.buildId, 1)
paths.forEach { path ->
val asset = store.readAsset(session.getValue("sessionId"), path)
assertArrayEquals(fixture.bytes, asset.bytes)
assertTrue(asset.isDocument)
}
}
@Test
fun rejectsManifestsThatDoNotHashToTheBuildId() {
val root = temporary.newFolder()
@@ -363,7 +388,6 @@ class MobileWebPackageStoreTest {
store.closeSession(previousSession.getValue("sessionId"))
store.closeSession(active.getValue("sessionId"))
store.commitFixture("paired-host", current)
assertEquals(
listOf(current.buildId),
@@ -11,6 +11,7 @@ enum MobileWebPackageStoreTests {
defer { try? FileManager.default.removeItem(at: root) }
try commitsAndReadsExactGeneration(root: root.appendingPathComponent("verified"))
try commitsAllPackagedDocuments(root: root.appendingPathComponent("all-documents"))
try rejectsMalformedManifests(root: root.appendingPathComponent("manifests"))
acceptsOnlyExactCanonicalAssetPaths()
acceptsOnlyExactSha256Tokens()
@@ -94,6 +95,36 @@ enum MobileWebPackageStoreTests {
precondition(staged?.isEmpty != false)
}
private static func commitsAllPackagedDocuments(root: URL) throws {
let store = MobileWebPackageStore(cacheRoot: root)
let paths = ["index.html", "markdown-editor.html", "mermaid-frame.html"]
let fixture = try mobileWebStoreFixture { manifest in
let asset = (manifest["assets"] as! [[String: Any]])[0]
manifest["assets"] = paths.map { path in
var document = asset
document["path"] = path
return document
}
manifest["totalBytes"] = (manifest["totalBytes"] as! Int) * paths.count
}
for path in paths {
try store.writeStagedAsset(
hostIdentity: "paired-host", buildId: fixture.buildId, path: path,
dataBase64: fixture.bytes.base64EncodedString()
)
}
try store.commitGeneration(
hostIdentity: "paired-host", buildId: fixture.buildId, manifestJson: fixture.manifestJson
)
let session = try store.openSession(
hostIdentity: "paired-host", buildId: fixture.buildId, bridgeVersion: 1
)
for path in paths {
let asset = try store.readAsset(sessionId: session["sessionId"]!, path: path)
precondition(asset.data == fixture.bytes && asset.isDocument)
}
}
private static func rejectsMalformedManifests(root: URL) throws {
let store = MobileWebPackageStore(cacheRoot: root)
let valid = try mobileWebStoreFixture()
@@ -526,7 +557,6 @@ enum MobileWebPackageStoreTests {
store.closeSession(sessionId: previousSession["sessionId"]!)
store.closeSession(sessionId: active["sessionId"]!)
try mobileWebStoreCommit(store: store, host: "paired-host", fixture: current)
let generations = mobileWebStoreHostRoot(cacheRoot: root, host: "paired-host")
.appendingPathComponent("generations")
@@ -210,7 +210,15 @@ final class MobileWebPackageStore {
func closeSession(sessionId: String) {
locked {
_ = sessions.removeValue(forKey: sessionId)
guard let session = sessions.removeValue(forKey: sessionId),
let root = try? cacheRoot()
else { return }
let generations = root.appendingPathComponent(session.hostKey, isDirectory: true)
.appendingPathComponent("generations", isDirectory: true)
guard let newest = try? requestedBuildId(generations: generations, buildId: nil) else { return }
try? removeOtherGenerations(
generations: generations, hostKey: session.hostKey, keeping: newest, cacheRoot: root
)
}
}
@@ -403,7 +411,7 @@ final class MobileWebPackageStore {
}
guard
totalBytes == declaredTotalBytes,
(1...2).contains(documentCount),
(1...mobileWebDocumentPaths.count).contains(documentCount),
entrypoint == "index.html",
records[entrypoint]?.role == "document"
else {