Skip to content

Instantly share code, notes, and snippets.

@dotproto
Last active July 2, 2026 18:02
Show Gist options
  • Select an option

  • Save dotproto/2117d1e30da2ad0c80d882b3475000db to your computer and use it in GitHub Desktop.

Select an option

Save dotproto/2117d1e30da2ad0c80d882b3475000db to your computer and use it in GitHub Desktop.
Ugly, no good, very bad test extension exploring ways for an extension to back up files to the file system.

Recently I was looking for ways for WebExtensions to back up critical state. The contents of this gist are the very rough test extension I was using to explore options.

There are two main ways of interacting with this extension:

  1. The extensin's options page (options.html) has tests for working with <input type="file">, File System Access, and storing file handles in IndexedDB.
  2. The action context menu test ways to save files to disk using web APIs and the WebExtensions Downloads API.

Warning

File System Access tests may be destructive! Create a new temp directory to prevent accidetnally overwiting other files.

browser.runtime.onInstalled.addListener((_reason) => {
// Context menu registration
(async () => {
await browser.contextMenus.removeAll();
const contexts = ["action"];
browser.contextMenus.create({
id: "show-options",
title: "Options",
contexts,
});
browser.contextMenus.create({
id: "web-backup",
title: "Web Backup",
contexts,
});
browser.contextMenus.create({
id: "web-backup-tab",
parentId: "web-backup",
title: "Self-closing Tab (Chrome, Firefox, ~Safari)",
contexts,
});
browser.contextMenus.create({
id: "web-backup-bg",
parentId: "web-backup",
title: "Background Page (Firefox)",
contexts,
});
browser.contextMenus.create({
id: "web-backup-offscreen",
parentId: "web-backup",
title: "Offscreen API (Chrome)",
contexts,
});
browser.contextMenus.create({
id: "download",
title: "Download",
contexts,
});
browser.contextMenus.create({
id: "download-with-data-url",
parentId: "download",
title: "Data URL (Chrome)",
contexts,
});
browser.contextMenus.create({
id: "download-with-object-url",
parentId: "download",
title: "Object URL (Firefox)",
contexts,
});
browser.contextMenus.create({
id: "download-combined",
parentId: "download",
title: "Combined (Chrome, Firefox)",
contexts,
});
})();
// Set up a reoccurring alarm to trigger a backup download
browser.alarms.create("cron-backup", { periodInMinutes: 1 } );
// doDownload();
});
// General event handlers
browser.contextMenus.onClicked.addListener((info, tab) => {
const name = info.menuItemId;
const cmd = commands.get(name);
if (typeof cmd === 'function')
cmd({info, tab, type: 'menu'});
});
browser.commands.onCommand.addListener((command, _tab) => {
const name = info.menuItemId;
const cmd = commands.get(name);
if (typeof cmd === 'function')
cmd({command, tab, type: 'command'});
});
const commands = new Map();
commands.set("show-options", function showOptions() {
browser.runtime.openOptionsPage();
});
// =============================================================================
// COMPAT: Does not work in WebKit; shows a confirmation prompt that disappears
// when the window closes. This grant does not appear to carry over between
// browser sessions, possibly because the extension's origin changes every
// session.
//
// CON: Flashes a new tab in the tab strip. It also adds the page to the user's
// tab history, meaning it can be reopened with Cmd/Ctrl+Shift+T
commands.set("web-backup-tab", function webBackupTab() {
browser.tabs.create({url: browser.runtime.getURL('web-backup.html')});
});
// COMPAT: Works in Gecko. Does not work in Chromium or WebKit.
commands.set("web-backup-bg", function webBackupBG() {
const backupData = `webBackupBG\n${new Date().toISOString()}`;
const backupDataURL = `data:text/plain,${encodeURIComponent(backupData)}`;
const backupFilename = `${(new Date().toISOString()).replaceAll(/[:.]/g, '-')}.txt`;
const anchor = document.createElement('a');
// Set the content of the file to download
anchor.href = backupDataURL;
// Sets the target filename
anchor.download = backupFilename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor); // Cleans up the DOM
});
// COMPAT: Works in Chromium. Does not work in Gecko or WebKit because they do
// not support the Offscreen API.
commands.set("web-backup-offscreen", function webBackupOffscreen() {
if (!browser.offscreen) {
throw new Error("The Offscreen API is not available in this browser.");
}
browser.offscreen.createDocument({
url: 'web-backup.html',
reasons: ['CLIPBOARD'],
justification: 'Triggers a download by "clicking" on an anchor element.',
});
});
browser.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'cron-backup') {
// doDownload();
}
});
// COMPAT: Does not work in Gecko because it doesn't allow data URLs to be
// downloaded.
commands.set("download-with-data-url", async function downloadWithDataURL() {
const backupData = `downloadWithDataURL\n${new Date().toISOString()}`;
const backupDataURL = `data:text/plain,${encodeURIComponent(backupData)}`;
const backupFilename = `${(new Date().toISOString()).replaceAll(/[:.]/g, '-')}.txt`;
const downloadId = browser.downloads.download({
conflictAction: 'uniquify',
url: backupDataURL,
filename: backupFilename,
});
});
// Combines `downloadWithObjectURL` and `downloadWithDataURL` into a single
// function. `finalBackupURL` assignment occurs unconditionally in a try/catch
// to avoid unnecessary allocations.
//
// COMPAT: Works in Chromium and Gecko. Does not work in WebKit because it does
// not support the Downloads API.
commands.set("download-combined", async function downloadCombined() {
const backupData = `downloadCombined\n${new Date().toISOString()}`;
const backupFilename = `${(new Date().toISOString()).replaceAll(/[:.]/g, '-')}.txt`;
let finalBackupURL;
let isObjectURL = false;
try {
const backupBlob = new Blob([backupData], { type: 'text/plain' });
finalBackupURL = URL.createObjectURL(backupBlob);;
isObjectURL = true;
} catch (err) {
finalBackupURL = `data:text/plain,${encodeURIComponent(backupData)}`
}
const downloadId = browser.downloads.download({
conflictAction: 'uniquify',
url: finalBackupURL,
filename: backupFilename,
});
if (isObjectURL)
URL.revokeObjectURL(finalBackupURL);
});
// COMPAT: Does not work in Chromium because `URL.createObjectURL()` is not
// available in service workers.
commands.set("download-with-object-url", async function downloadWithObjectURL() {
const backupData = `downloadWithObjectURL\n${new Date().toISOString()}`;
const backupBlob = new Blob([backupData], { type: 'text/plain' });
const backupObjectURL = URL.createObjectURL(backupBlob);
const backupFilename = `${(new Date().toISOString()).replaceAll(':', '-').replaceAll('.', '-')}.txt`;
const downloadId = await browser.downloads.download({
conflictAction: 'uniquify',
url: backupObjectURL,
filename: backupFilename,
});
// Prevent memory leaks by freeing the object URL after use
URL.revokeObjectURL(backupObjectURL);
});
{
"name": "Write latest TAB URL to FS",
"version": "0.1",
"description": "FOO",
"manifest_version": 3,
"action": {},
"background": {
"scripts": ["background.js"],
"service_worker": "background.js"
},
"permissions": [
"tabs",
"contextMenus",
"menus",
"alarms",
"downloads",
"offscreen"
],
"host_permissions": [
"<all_urls>"
],
"options_page": "options.html",
"commands": {
"show-options": {
"description": "Open the options page."
}
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Options</title>
<script defer src="options.js"></script>
</head>
<body>
<h1>Options</h1>
<label>Select backup file<br>
<input id="backup-location" type="file" />
<!-- <input id="backup-location" type="file" webkitdirectory directory /> -->
</label>
<button id="create-backup-file">Create backup file</button>
<button id="select-backup-file">Select backup file</button>
<button id="select-backup-directory">Select backup directory</button>
<div>
<h2>Drag and drop</h2>
<div id="drop-target" style="height: 200px; width: 600px; background: #eee; border-radius: 3px;">
Drop a FILE or DIRECTORY here.
</div>
</div>
<div>
<h2>Current backup file</h2>
<table>
<tbody>
<tr>
<th>Kind</th>
<td id="kind-value"></td>
</tr>
<tr>
<th>Name</th>
<td id="name-value"></td>
</tr>
<tr>
<th>IndexedDB key</th>
<td id="idb-key-value"></td>
</tr>
</tbody>
</table>
</div>
</body>
</html>
let db = new Promise((resolve, reject) => {
const request = indexedDB.open("FileStore");
request.onerror = reject;
request.onsuccess = (event) => {
resolve(event.target.result);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
const objectStore = db.createObjectStore("handles");
};
});
async function setStoredFileHandle(fileHandle, key) {
const transaction = (await db).transaction(["handles"], "readwrite");
const objectStore = transaction.objectStore("handles");
const objectStoreRequest = objectStore.put(fileHandle, key);
}
async function getStoredFileHandle(key) {
const transaction = (await db).transaction(["handles"], "readonly");
transaction.oncomplete = (e) => console.log('transaction complete', e);
transaction.onerror = (e) => console.error('transaction error', e);
const objectStore = transaction.objectStore("handles");
const request = objectStore.get(key);
const deferred = Promise.withResolvers();
request.onerror = deferred.reject;
request.onsuccess = (event) => {
const handle = request.result;
deferred.resolve(handle);
}
return deferred.promise;
}
async function checkFilePermissions(handle, {mode = "readwrite", autoRequest = true} = {}) {
const modes = ["read", "readwrite"]
if (!modes.includes(mode))
throw new Error(`The "mode" option must be either "read" or "readwrite", received "${mode}"`);
const options = { mode };
const currentGrant = await handle.queryPermission(options);
if (currentGrant === 'granted')
return 'granted';
if (autoRequest) {
const grantRequest = await handle.requestPermission(options);
if (grantRequest === 'granted') {
return 'granted';
}
}
return 'denied';
}
const inputLocation = document.getElementById('backup-location');
inputLocation.addEventListener("change", async (event) => {
const file = event.target.files[0];
const handle = await file.getAsFileSystemHandle();
showBackupFileInfo(handle, backupFileIDBKey);
readBackupFile(handle);
setStoredFileHandle(handle, backupFileIDBKey);
writeBackupFile(handle);
// console.log(event);
// let output = document.getElementById('listing');
// for (const file of event.target.files) {
// let item = document.createElement('li');
// item.textContent = file.webkitRelativePath;
// output.appendChild(item);
// }
});
const backupFileIDBKey = 'backup-file';
const backupDirectoryIDBKey = 'backup-dir';
(async function() {
const handle = await getStoredFileHandle(backupFileIDBKey);
showBackupFileInfo(handle, backupFileIDBKey);
checkFilePermissions(handle);
readBackupFile(handle);
// const dt = new DataTransfer();
// const file = await handle.getFile();
// dt.items.add(file);
// inputLocation.files = dt.files;
})();
const createBackupButton = document.getElementById('create-backup-file');
createBackupButton.addEventListener('click', async (event) => {
const handle = await createFile('backup.json');
if (!handle) return;
showBackupFileInfo(handle, backupFileIDBKey);
setStoredFileHandle(handle, backupFileIDBKey);
writeBackupFile(handle);
});
const selectBackupButton = document.getElementById('select-backup-file');
selectBackupButton.addEventListener('click', async (event) => {
const [handle] = await window.showOpenFilePicker({
types: [
{
description: "JSON files",
accept: {
'application/json': ['.json'],
}
}
],
excludeAcceptAllOption: true,
multiple: false,
});
if (!handle) return;
setStoredFileHandle(handle, backupFileIDBKey);
showBackupFileInfo(handle, backupFileIDBKey);
writeBackupFile(handle);
});
const selectBackupDirButton = document.getElementById('select-backup-directory');
selectBackupDirButton.addEventListener('click', async (event) => {
const dirHandle = await window.showDirectoryPicker();
setStoredFileHandle(dirHandle, backupDirectoryIDBKey);
const handle = await dirHandle.getFileHandle('back-it-up.json', {create: true});
if (!handle) return;
setStoredFileHandle(handle, backupFileIDBKey);
showBackupFileInfo(handle, backupFileIDBKey);
writeBackupFile(handle);
});
const kindDisplay = document.getElementById('kind-value');
const nameDisplay = document.getElementById('name-value');
const idbKeyDisplay = document.getElementById('idb-key-value');
function showBackupFileInfo(handle, key) {
kindDisplay.innerText = handle.kind;
nameDisplay.innerText = handle.name;
idbKeyDisplay.innerText = key;
}
async function createFile(suggestedName) {
try {
// NOTE: not available on Firefox or Safari
const handle = await window.showSaveFilePicker({
suggestedName,
types: [{
description: 'Application backup file',
accept: {
'application/json': ['.json'],
},
}],
});
return handle;
} catch (e) {
console.error('Unexpected error creating file.', e);
return null;
}
}
class SimpleFile {
async readAsText(fileHandle, encoding = "utf-8") {
return new Promise(async (resolve, reject) => {
const file = await fileHandle.getFile();
const reader = new FileReader();
reader.onload = (event) => resolve(event.target.result);
reader.onerror = reject;
reader.readAsText(file, encoding);
});
}
async readAsJSON(fileHandle, encoding = "utf-8") {
const file = await this.readAsText(fileHandle, encoding);
return JSON.parse(file);
}
// NOTE: Strings are automatically encoded as UTF-8, no special action needed.
async writeAsText(fileHandle, body, {encoding} = {}) {
const writable = await fileHandle.createWritable();
await writable.write(body);
await writable.close();
}
async writeAsJSON(fileHandle, body, {space = 0, encoding = 'utf-8'} = {}) {
return this.writeAsText(fileHandle, JSON.stringify(body, null, space))
}
}
const file = new SimpleFile();
function writeBackupFile(handle) {
file.writeAsJSON(handle, {greeting: "Hello, world!"});
}
async function readBackupFile(handle) {
const foo = await file.readAsJSON(handle);
console.log('backup file', foo);
}
// ---
const dropTarget = document.getElementById('drop-target');
dropTarget.addEventListener('dragenter', (event) => {
event.stopPropagation();
event.preventDefault();
})
dropTarget.addEventListener('dragover', (event) => {
event.stopPropagation();
event.preventDefault();
})
dropTarget.addEventListener('drop', async (event) => {
event.stopPropagation();
event.preventDefault();
const items = event.dataTransfer.items;
// The `getAsFileSystemHandle()` call must happen on the same tick as entry
// into the event handler.
//
// `item.webkitGetAsEntry()` is more broadly supported, but does not
// provide write access to files/directories.
const handlesPromises = [...items]
.filter((file) => file.kind === "file")
.map((file) => file.getAsFileSystemHandle());
const handles = await Promise.all(handlesPromises);
console.log(handles);
})
<!DOCTYPE html>
<script src="web-backup.js"></script>
const backupData = `webBackupTab\n${new Date().toISOString()}`;
const backupDataURL = `data:text/plain,${encodeURIComponent(backupData)}`;
const backupFilename = `${(new Date().toISOString()).replaceAll(/[:.]/g, '-')}.txt`;
const anchor = document.createElement('a');
// Set the content of the file to download
anchor.href = backupDataURL;
// Sets the target filename
anchor.download = backupFilename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor); // Cleans up the DOM
// WARNING: The fixed timeout here creates a race condition.
setTimeout(window.close, 1000);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment