use crate::error::Result;
use crate::protocol::Page;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
const SHIM: &str = r#"(() => {
if (window.__pwRsFakeFs) return;
const state = {
saves: [], // { name, b64 }
openFiles: new Map(), // name -> b64
permission: 'granted',
};
const b64encode = (bytes) => {
let s = '';
bytes.forEach((b) => { s += String.fromCharCode(b); });
return btoa(s);
};
const b64decode = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const toBytes = async (data) => {
if (typeof data === 'string') return new TextEncoder().encode(data);
if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer());
if (data instanceof ArrayBuffer) return new Uint8Array(data);
if (ArrayBuffer.isView(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
// FileSystemWriteChunkType object form: { type: 'write', data }
if (data && data.type === 'write') return toBytes(data.data);
throw new TypeError('fake fs: unsupported write payload');
};
const makeHandle = (name) => ({
// Marks the object as a fake handle so the IndexedDB interception below
// can swap it for a serializable placeholder before structuredClone.
__pwRsFakeFsHandle: true,
kind: 'file',
name,
isSameEntry: async (other) => !!other && other.name === name,
queryPermission: async () => state.permission,
requestPermission: async () => {
if (state.permission === 'prompt') state.permission = 'granted';
return state.permission;
},
getFile: async () => {
const b64 = state.openFiles.get(name) ?? '';
return new File([b64decode(b64)], name);
},
createWritable: async () => {
const chunks = [];
return {
write: async (data) => { chunks.push(await toBytes(data)); },
seek: async () => {},
truncate: async () => {},
abort: async () => {},
close: async () => {
let total = 0;
chunks.forEach((c) => { total += c.length; });
const all = new Uint8Array(total);
let offset = 0;
chunks.forEach((c) => { all.set(c, offset); offset += c.length; });
const b64 = b64encode(all);
state.saves.push({ name, b64 });
state.openFiles.set(name, b64);
},
};
},
});
window.__pwRsFakeFs = {
lastSaved: () => (state.saves.length ? state.saves[state.saves.length - 1] : null),
setOpenFile: (name, b64) => { state.openFiles.set(name, b64); },
setPermission: (p) => { state.permission = p; },
};
// Real FileSystemFileHandle is [Serializable], so apps persist it in
// IndexedDB and re-query permission on the next load. Our fake carries
// methods that structuredClone rejects (DataCloneError), which would break
// that flow. Intercept put/add to store a serializable placeholder keyed by
// name, and get to rehydrate it into a live handle. Non-handle values pass
// through untouched, so the app's other IndexedDB usage is unaffected.
if (window.IDBObjectStore) {
const MARK = '__pwRsFakeFsHandleName';
const placeholder = (v) =>
(v && typeof v === 'object' && v.__pwRsFakeFsHandle) ? { [MARK]: v.name } : v;
const wrapWrite = (orig) =>
function (value, ...rest) { return orig.call(this, placeholder(value), ...rest); };
IDBObjectStore.prototype.put = wrapWrite(IDBObjectStore.prototype.put);
IDBObjectStore.prototype.add = wrapWrite(IDBObjectStore.prototype.add);
const realGet = IDBObjectStore.prototype.get;
IDBObjectStore.prototype.get = function (...args) {
const req = realGet.apply(this, args);
// Registered here, before the caller sets onsuccess, so the
// rehydrated value is in place when their handler reads req.result.
req.addEventListener('success', () => {
const val = req.result;
if (val && typeof val === 'object' && MARK in val) {
Object.defineProperty(req, 'result', {
configurable: true,
value: makeHandle(val[MARK]),
});
}
});
return req;
};
}
window.showSaveFilePicker = async (options) => {
if (state.permission === 'denied') {
throw new DOMException('fake fs: permission denied', 'NotAllowedError');
}
return makeHandle((options && options.suggestedName) || 'untitled');
};
window.showOpenFilePicker = async () => {
if (state.permission === 'denied') {
throw new DOMException('fake fs: permission denied', 'NotAllowedError');
}
const names = [...state.openFiles.keys()];
if (names.length === 0) {
throw new DOMException('fake fs: no open file seeded', 'AbortError');
}
return [makeHandle(names[names.length - 1])];
};
})()"#;
#[derive(Debug, Clone)]
pub struct FakeFileSystem {
page: Page,
}
impl FakeFileSystem {
pub(crate) async fn install(page: &Page) -> Result<Self> {
page.add_init_script(SHIM).await?;
page.evaluate_expression(SHIM).await?;
Ok(Self { page: page.clone() })
}
pub async fn last_saved_name(&self) -> Result<Option<String>> {
self.page
.evaluate(
"() => { const s = window.__pwRsFakeFs.lastSaved(); return s ? s.name : null; }",
None::<&()>,
)
.await
}
pub async fn last_saved_bytes(&self) -> Result<Option<Vec<u8>>> {
let b64: Option<String> = self
.page
.evaluate(
"() => { const s = window.__pwRsFakeFs.lastSaved(); return s ? s.b64 : null; }",
None::<&()>,
)
.await?;
b64.map(|s| {
BASE64
.decode(s)
.map_err(|e| crate::error::Error::ProtocolError(format!("fake fs base64: {e}")))
})
.transpose()
}
pub async fn set_open_file(&self, name: &str, bytes: &[u8]) -> Result<()> {
let arg = (name, BASE64.encode(bytes));
let _: Option<()> = self
.page
.evaluate(
"([name, b64]) => { window.__pwRsFakeFs.setOpenFile(name, b64); }",
Some(&arg),
)
.await?;
Ok(())
}
pub async fn set_permission(&self, state: &str) -> Result<()> {
let _: Option<()> = self
.page
.evaluate(
"(state) => { window.__pwRsFakeFs.setPermission(state); }",
Some(&state),
)
.await?;
Ok(())
}
pub async fn grant_permission(&self) -> Result<()> {
self.set_permission("granted").await
}
pub async fn seed_on_navigation(
&self,
name: &str,
bytes: &[u8],
permission: &str,
) -> Result<()> {
let to_js = |s: &str| {
serde_json::to_string(s).map_err(|e| {
crate::error::Error::ProtocolError(format!("fake fs seed encode: {e}"))
})
};
let name = to_js(name)?;
let b64 = to_js(&BASE64.encode(bytes))?;
let perm = to_js(permission)?;
let script = format!(
"(() => {{ const fs = window.__pwRsFakeFs; if (!fs) return; \
fs.setOpenFile({name}, {b64}); fs.setPermission({perm}); }})()"
);
self.page.add_init_script(&script).await
}
}