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
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import Foundation

/// JavaScript builders and parameter normalization for the `browser storage.*`
/// control commands (`storage.get`, `storage.set`, `storage.clear`).
/// control commands (`storage.get`, `storage.set`, `storage.clear`), plus the
/// storage half of `browser.state.save` and `browser.state.load`.
///
/// Every string returned here is byte-identical to the script the corresponding
/// `v2BrowserStorage*` method previously assembled inline in `TerminalController`;
/// only the assembly moved. The owning `@MainActor` controller keeps the WebKit
/// Every string returned here started as the script the corresponding
/// `v2BrowserStorage*` or `v2BrowserState*` method previously assembled inline in
/// `TerminalController`.
/// The owning `@MainActor` controller keeps the WebKit
/// evaluation seam and the per-surface ref/workspace state, normalizes the raw
/// result via ``BrowserControlService/normalizeJSValue(_:isUndefinedSentinel:)``,
/// and composes the RPC reply, so the wire output is unchanged.
/// and composes the RPC reply.
extension BrowserControlService {
/// Normalizes the requested Web Storage area to either `"session"` or
/// `"local"`, defaulting to `"local"` for any unrecognized request.
Expand Down Expand Up @@ -40,7 +42,8 @@ extension BrowserControlService {
/// When `key` is `nil` the script returns every entry of the chosen storage
/// area as an object; otherwise it returns the single item. Returns
/// `{ ok: false, error: 'not_available' }` when the storage area is absent.
/// Byte-identical to the script previously inlined in `v2BrowserStorageGet`.
/// Whole-area entries are defined as own properties so keys that collide with
/// `Object.prototype` members, such as `__proto__`, are kept.
/// - Parameters:
/// - storageType: `"session"` or `"local"`, as returned by ``storageType(params:)``.
/// - key: the specific key to read, or `nil` to read the whole area.
Expand All @@ -58,7 +61,13 @@ extension BrowserControlService {
const out = {};
for (let i = 0; i < st.length; i++) {
const k = st.key(i);
out[k] = st.getItem(k);
// Define own properties so a literal "__proto__" key stays data.
Object.defineProperty(out, k, {
value: st.getItem(k),
enumerable: true,
configurable: true,
writable: true
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return { ok: true, value: out };
}
Expand Down Expand Up @@ -115,4 +124,64 @@ extension BrowserControlService {
})()
"""
}

/// Builds the `browser.state.save` page-world script.
///
/// Returns `{ local, session }`, each an object holding every entry of that
/// storage area, or an empty object when the area is absent.
/// - Returns: a self-invoking JavaScript expression.
public func storageSnapshotScript() -> String {
"""
(() => {
const readStorage = (st) => {
const out = {};
if (!st) return out;
for (let i = 0; i < st.length; i++) {
const k = st.key(i);
// Define own properties so a literal "__proto__" key stays data.
Object.defineProperty(out, k, {
value: st.getItem(k),
enumerable: true,
configurable: true,
writable: true
});
}
return out;
};
return {
local: readStorage(window.localStorage),
session: readStorage(window.sessionStorage)
};
})()
"""
}

/// Builds the `browser.state.load` page-world script.
///
/// Clears each storage area named in the payload and writes its entries,
/// coercing values to strings and writing `null` as the empty string. The
/// payload goes through `JSON.parse` rather than an object literal, because a
/// literal `"__proto__"` key in an object literal sets the prototype and
/// drops the saved entry.
/// - Parameter storageLiteral: the saved `{ local, session }` storage object,
/// already encoded as a JSON literal by the caller.
/// - Returns: a self-invoking JavaScript expression.
public func storageRestoreScript(storageLiteral: String) -> String {
let payloadText = jsonLiteral(storageLiteral)
return """
(() => {
const payload = JSON.parse(\(payloadText));
const apply = (st, data) => {
if (!st || !data || typeof data !== 'object') return;
st.clear();
for (const [k, v] of Object.entries(data)) {
st.setItem(String(k), v == null ? '' : String(v));
}
};
apply(window.localStorage, payload.local);
apply(window.sessionStorage, payload.session);
return true;
})()
"""
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import JavaScriptCore
import Testing
@testable import CmuxBrowser

Expand Down Expand Up @@ -47,7 +48,13 @@ struct BrowserControlServiceStorageScriptsTests {
const out = {};
for (let i = 0; i < st.length; i++) {
const k = st.key(i);
out[k] = st.getItem(k);
// Define own properties so a literal "__proto__" key stays data.
Object.defineProperty(out, k, {
value: st.getItem(k),
enumerable: true,
configurable: true,
writable: true
});
}
return { ok: true, value: out };
}
Expand Down Expand Up @@ -106,6 +113,71 @@ struct BrowserControlServiceStorageScriptsTests {
#expect(script.contains("return { ok: true, value: st.getItem(String(key)) };"))
}

/// Runs a storage script against in-memory Web Storage fixtures and returns
/// the JSON-serialized result. Fixture entries are built from key/value pairs
/// so prototype-sensitive keys such as `__proto__` are stored as real keys.
private func runStorageScript(_ script: String) -> String? {
let context = JSContext()!
context.evaluateScript("""
const makeStorage = (pairs) => {
const entries = new Map(pairs);
return {
get length() { return entries.size; },
key: (i) => Array.from(entries.keys())[i] ?? null,
getItem: (k) => entries.has(k) ? entries.get(k) : null,
setItem: (k, v) => { entries.set(k, v); },
clear: () => { entries.clear(); },
dump: () => Array.from(entries)
};
};
var window = {
localStorage: makeStorage([["regular", "local-control"], ["__proto__", "local-kept"]]),
sessionStorage: makeStorage([["regular", "session-control"], ["__proto__", "session-kept"]])
};
""")
return context.evaluateScript("JSON.stringify(\(script))")?.toString()
}

@Test("storageGetScript keeps a __proto__ key in whole-area reads", arguments: ["local", "session"])
func storageGetWholeAreaKeepsProtoKey(storageType: String) {
let script = service.storageGetScript(storageType: storageType, key: nil)
#expect(
runStorageScript(script)
== #"{"ok":true,"value":{"regular":"\#(storageType)-control","__proto__":"\#(storageType)-kept"}}"#
)
}

@Test("storageGetScript reads a __proto__ key when requested directly", arguments: ["local", "session"])
func storageGetSingleProtoKey(storageType: String) {
let script = service.storageGetScript(storageType: storageType, key: "__proto__")
#expect(runStorageScript(script) == #"{"ok":true,"value":"\#(storageType)-kept"}"#)
}

@Test("storageSnapshotScript keeps a __proto__ key in both storage areas")
func storageSnapshotKeepsProtoKey() {
#expect(
runStorageScript(service.storageSnapshotScript())
== #"{"local":{"regular":"local-control","__proto__":"local-kept"},"session":{"regular":"session-control","__proto__":"session-kept"}}"#
)
}

@Test("storageRestoreScript writes a saved __proto__ key back to both storage areas")
func storageRestoreWritesProtoKey() {
let restore = service.storageRestoreScript(
storageLiteral: #"{"local":{"regular":"saved-local","__proto__":"saved-local-proto"},"session":{"regular":"saved-session","__proto__":"saved-session-proto"}}"#
)
let script = """
(() => {
\(restore);
return { local: window.localStorage.dump(), session: window.sessionStorage.dump() };
})()
"""
#expect(
runStorageScript(script)
== #"{"local":[["regular","saved-local"],["__proto__","saved-local-proto"]],"session":[["regular","saved-session"],["__proto__","saved-session-proto"]]}"#
)
}

@Test("storageSetScript writes the value literal verbatim")
func storageSet() {
let script = service.storageSetScript(storageType: "local", key: "k", valueLiteral: "\"v\"")
Expand Down
35 changes: 2 additions & 33 deletions Sources/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11280,23 +11280,7 @@ class TerminalController {
}

return v2BrowserWithPanelContext(params: params) { ctx in
let storageScript = """
(() => {
const readStorage = (st) => {
const out = {};
if (!st) return out;
for (let i = 0; i < st.length; i++) {
const k = st.key(i);
out[k] = st.getItem(k);
}
return out;
};
return {
local: readStorage(window.localStorage),
session: readStorage(window.sessionStorage)
};
})()
"""
let storageScript = v2BrowserControl.storageSnapshotScript()

let storageValue: Any
switch v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: ctx.surfaceId, script: storageScript, timeout: 10.0) {
Expand Down Expand Up @@ -11405,22 +11389,7 @@ class TerminalController {
guard let storage = raw["storage"] as? [String: Any] else {
return true
}
let storageLiteral = v2JSONLiteral(storage)
let script = """
(() => {
const payload = \(storageLiteral);
const apply = (st, data) => {
if (!st || !data || typeof data !== 'object') return;
st.clear();
for (const [k, v] of Object.entries(data)) {
st.setItem(String(k), v == null ? '' : String(v));
}
};
apply(window.localStorage, payload.local);
apply(window.sessionStorage, payload.session);
return true;
})()
"""
let script = v2BrowserControl.storageRestoreScript(storageLiteral: v2JSONLiteral(storage))
switch v2RunBrowserJavaScript(
context.webView,
browserPanel: context.browserPanel,
Expand Down
Loading