TabFS/extension/background.js
Omar Rizwan cc53bdd98d make debugger/ subfolder. remove lastFocused storage (didn't need?)
make test reload on start. test seems to work
2020-12-25 13:40:18 -08:00

525 lines
17 KiB
JavaScript

const TESTING = (typeof chrome === 'undefined');
const unix = {
EPERM: 1,
ENOENT: 2,
ESRCH: 3,
EINTR: 4,
EIO: 5,
ENXIO: 6,
ENOTSUP: 45,
ETIMEDOUT: 110, // not on macOS (?)
// Unix file types
S_IFMT: 0170000, // type of file mask
S_IFIFO: 010000, // named pipe (fifo)
S_IFCHR: 020000, // character special
S_IFDIR: 040000, // directory
S_IFBLK: 060000, // block special
S_IFREG: 0100000, // regular
S_IFLNK: 0120000, // symbolic link
S_IFSOCK: 0140000, // socket
}
class UnixError extends Error {
constructor(error) { super(); this.name = "UnixError"; this.error = error; }
}
function pathComponent(path, i) {
const components = path.split('/');
return components[i >= 0 ? i : components.length + i];
}
function sanitize(s) { return s.replace(/[^A-Za-z0-9_\-\.]/gm, '_'); }
const stringToUtf8Array = (function() {
const encoder = new TextEncoder("utf-8");
return str => encoder.encode(str);
})();
const utf8ArrayToString = (function() {
const decoder = new TextDecoder("utf-8");
return utf8 => decoder.decode(utf8);
})();
const TabManager = {
tabState: {},
debugTab: async function(tabId) {
this.tabState[tabId] = this.tabState[tabId] || {};
if (this.tabState[tabId].debugging) {
this.tabState[tabId].debugging += 1;
} else {
await new Promise((resolve, reject) => chrome.debugger.attach({tabId}, "1.3", async () => {
if (chrome.runtime.lastError) {
if (chrome.runtime.lastError.message.indexOf('Another debugger is already attached') !== -1) {
chrome.debugger.detach({tabId}, async () => {
await TabManager.debugTab(tabId);
resolve();
});
} else {
reject(chrome.runtime.lastError); return;
}
return;
}
this.tabState[tabId].debugging = 1; resolve();
}));
}
},
enableDomainForTab: async function(tabId, domain) {
this.tabState[tabId] = this.tabState[tabId] || {};
if (this.tabState[tabId][domain]) { this.tabState[tabId][domain] += 1;
} else {
await sendDebuggerCommand(tabId, `${domain}.enable`, {});
this.tabState[tabId][domain] = 1;
}
}
};
function sendDebuggerCommand(tabId, method, commandParams) {
return new Promise((resolve, reject) =>
chrome.debugger.sendCommand({tabId}, method, commandParams, result => {
if (result) { resolve(result); } else { reject(chrome.runtime.lastError); }
})
);
}
const BrowserState = { scriptsForTab: {} };
(function() {
if (TESTING) return;
chrome.debugger.onEvent.addListener((source, method, params) => {
console.log(source, method, params);
if (method === "Page.") {
// we're gonna assume we're always plugged into both Page and Debugger.
BrowserState.scriptsForTab[source.tabId] = [];
} else if (method === "Debugger.scriptParsed") {
BrowserState.scriptsForTab[source.tabId] = BrowserState.scriptsForTab[source.tabId] || [];
BrowserState.scriptsForTab[source.tabId].push(params);
// FIXME: clear these out when page changes in tab (how?)
}
});
})();
const router = {};
const Cache = {
// used when you open a file to cache the content we got from the browser
// until you close that file.
store: {}, nextHandle: 0,
storeObject(object) {
const handle = ++this.nextHandle;
this.store[handle] = object;
return handle;
},
getObjectForHandle(handle) { return this.store[handle]; },
removeObjectForHandle(handle) { delete this.store[handle]; }
};
function toUtf8Array(stringOrArray) {
if (typeof stringOrArray == 'string') { return stringToUtf8Array(stringOrArray); }
else { return stringOrArray; }
}
const defineFile = (getData, setData) => ({
async getattr({path}) {
return {
st_mode: unix.S_IFREG | 0444 | (setData ? 0222 : 0),
st_nlink: 1,
st_size: toUtf8Array(await getData(path)).length
};
},
async open({path}) { return { fh: Cache.storeObject(toUtf8Array(await getData(path))) }; },
async read({path, fh, size, offset}) {
return { buf: String.fromCharCode(...Cache.getObjectForHandle(fh).slice(offset, offset + size)) }
},
async write({path, fh, offset, buf}) {
let arr = Cache.getObjectForHandle(fh);
const bufarr = stringToUtf8Array(buf);
if (offset + bufarr.length > arr.length) {
const newArr = new Uint8Array(offset + bufarr.length);
newArr.set(arr); arr = newArr;
}
for (let i = 0; i < bufarr.length; i++) { arr[offset + i] = bufarr[i]; }
// I guess caller should override write() if they want to actually
// patch and not just re-set the whole string (for example,
// if they want to hot-reload just one function the user modified)
await setData(path, utf8ArrayToString(arr)); return { size: bufarr.length };
},
async release({fh}) { Cache.removeObjectForHandle(fh); return {}; },
async truncate({path, size}) {
// TODO: weird case if they truncate while the file is open
// (but `echo hi > foo.txt`, the main thing I care about, uses
// O_TRUNC which thankfully doesn't do that)
let arr = toUtf8Array(await getData(path));
if (size > arr.length) {
const newArr = new Uint8Array(size);
newArr.set(arr); arr = newArr;
}
await setData(path, utf8ArrayToString(arr.slice(0, size))); return {};
}
});
router["/tabs/create"] = {
async write({path, buf}) {
const url = buf.trim();
await browser.tabs.create({url});
return {size: stringToUtf8Array(buf).length};
},
async truncate({path, size}) { return {}; }
}
router["/tabs/by-id"] = {
async readdir() {
const tabs = await browser.tabs.query({});
return { entries: [".", "..", ...tabs.map(tab => String(tab.id))] };
}
};
// title.txt
// url.txt
// text.txt
// TODO: document.html
// TODO: console
// TODO: mem (?)
// TODO: cpu (?)
// TODO: dom/ ?
// TODO: globals/ ?
// screenshot.png (FIXME: how to keep from blocking when unfocused?)
// TODO: archive.mhtml ?
// TODO: printed.pdf
// control
// resources/
// TODO: scripts/ TODO: allow creation, eval immediately
(function() {
const withTab = (readHandler, writeHandler) => defineFile(async path => {
const tabId = parseInt(pathComponent(path, -2));
const tab = await browser.tabs.get(tabId);
return readHandler(tab);
}, writeHandler ? async (path, buf) => {
const tabId = parseInt(pathComponent(path, -2));
await browser.tabs.update(tabId, writeHandler(buf));
} : undefined);
const fromScript = code => defineFile(async path => {
const tabId = parseInt(pathComponent(path, -2));
return (await browser.tabs.executeScript(tabId, {code}))[0];
});
router["/tabs/by-id/*/url.txt"] = withTab(tab => tab.url + "\n", buf => ({ url: buf }));
router["/tabs/by-id/*/title.txt"] = withTab(tab => tab.title + "\n");
router["/tabs/by-id/*/text.txt"] = fromScript(`document.body.innerText`);
// router["/tabs/by-id/*/console"] = {
// open() {
// // inject the console
// },
// read() {
//
// },
// write() {
// // what does this even do?
// }
// }
})();
router["/tabs/by-id/*/screenshot.png"] = defineFile(async path => {
// FIXME: replace with captureTab
// FIXME: hide if tab is not focused
const tabId = parseInt(pathComponent(path, -2));
await TabManager.debugTab(tabId); await TabManager.enableDomainForTab(tabId, "Page");
const {data} = await sendDebuggerCommand(tabId, "Page.captureScreenshot");
return Uint8Array.from(atob(data), c => c.charCodeAt(0));
});
router["/tabs/by-id/*/control"] = {
// echo remove >> mnt/tabs/by-id/1644/control
async write({path, buf}) {
const tabId = parseInt(pathComponent(path, -2));
const command = buf.trim();
// can use `discard`, `remove`, `reload`, `goForward`, `goBack`...
// see https://developer.chrome.com/extensions/tabs
await browser.tabs[command](tabId);
return {size: stringToUtf8Array(buf).length};
},
async truncate({path, size}) { return {}; }
};
// debugger-API-dependent (Chrome-only)
(function() {
router["/tabs/by-id/*/debugger/resources"] = {
async readdir({path}) {
const tabId = parseInt(pathComponent(path, -2));
await TabManager.debugTab(tabId); await TabManager.enableDomainForTab(tabId, "Page");
const {frameTree} = await sendDebuggerCommand(tabId, "Page.getResourceTree", {});
return { entries: [".", "..", ...frameTree.resources.map(r => sanitize(String(r.url).slice(0, 200)))] };
}
};
router["/tabs/by-id/*/debugger/resources/*"] = defineFile(async path => {
const [tabId, suffix] = [parseInt(pathComponent(path, -3)), pathComponent(path, -1)];
await TabManager.debugTab(tabId); await TabManager.enableDomainForTab(tabId, "Page");
const {frameTree} = await sendDebuggerCommand(tabId, "Page.getResourceTree", {});
for (let resource of frameTree.resources) {
const resourceSuffix = sanitize(String(resource.url).slice(0, 200));
if (resourceSuffix === suffix) {
let {base64Encoded, content} = await sendDebuggerCommand(tabId, "Page.getResourceContent", {
frameId: frameTree.frame.id,
url: resource.url
});
if (base64Encoded) { return Uint8Array.from(atob(content), c => c.charCodeAt(0)); }
return content;
}
}
throw new UnixError(unix.ENOENT);
});
router["/tabs/by-id/*/debugger/scripts"] = {
async opendir({path}) {
const tabId = parseInt(pathComponent(path, -2));
await TabManager.debugTab(tabId); await TabManager.enableDomainForTab(tabId, "Debugger");
return { fh: 0 };
},
async readdir({path}) {
const tabId = parseInt(pathComponent(path, -2));
return { entries: [".", "..", ...BrowserState.scriptsForTab[tabId].map(params => sanitize(params.url).slice(0, 200) + "_" + params.scriptId)] };
}
};
router["/tabs/by-id/*/debugger/scripts/*"] = defineFile(async path => {
const [tabId, suffix] = [parseInt(pathComponent(path, -3)), pathComponent(path, -1)];
await TabManager.debugTab(tabId);
await TabManager.enableDomainForTab(tabId, "Page");
await TabManager.enableDomainForTab(tabId, "Debugger");
const parts = path.split("_"); const scriptId = parts[parts.length - 1];
const {scriptSource} = await sendDebuggerCommand(tabId, "Debugger.getScriptSource", {scriptId});
return scriptSource;
}, async (path, buf) => {
const [tabId, suffix] = [parseInt(pathComponent(path, -3)), pathComponent(path, -1)];
await TabManager.debugTab(tabId); await TabManager.enableDomainForTab(tabId, "Debugger");
const parts = path.split("_"); const scriptId = parts[parts.length - 1];
await sendDebuggerCommand(tabId, "Debugger.setScriptSource", {scriptId, scriptSource: buf});
return {};
});
})();
router["/tabs/by-title"] = {
getattr() {
return {
st_mode: unix.S_IFDIR | 0777, // writable
st_nlink: 3,
st_size: 0,
};
},
async readdir() {
const tabs = await browser.tabs.query({});
return { entries: [".", "..", ...tabs.map(tab => sanitize(String(tab.title).slice(0, 200)) + "_" + String(tab.id))] };
}
};
router["/tabs/by-title/*"] = {
// a symbolic link to /tabs/by-id/[id for this tab]
async readlink({path}) {
const parts = path.split("_"); const tabId = parts[parts.length - 1];
return { buf: "../by-id/" + tabId };
},
async unlink({path}) { // you can delete a by-title/TAB to close that tab
const parts = path.split("_"); const tabId = parseInt(parts[parts.length - 1]);
await browser.tabs.remove(tabId);
return {};
}
};
router["/tabs/last-focused"] = {
// a symbolic link to /tabs/by-id/[id for this tab]
async readlink({path}) {
const id = (await browser.tabs.query({ active: true, currentWindow: true }))[0].id;
return { buf: "by-id/" + id };
}
}
router["/extensions"] = {
async readdir() {
const infos = await browser.management.getAll();
return { entries: [".", "..", ...infos.map(info => `${sanitize(info.name)}_${info.id}`)] };
}
};
router["/extensions/*/enabled"] = { ...defineFile(async path => {
const parts = pathComponent(path, -2).split('_'); const extensionId = parts[parts.length - 1];
const info = await browser.management.get(extensionId);
return String(info.enabled) + '\n';
}, async (path, buf) => {
const parts = pathComponent(path, -2).split('_'); const extensionId = parts[parts.length - 1];
await browser.management.setEnabled(extensionId, buf.trim() === "true");
// suppress truncate so it doesn't accidentally flip the state when you do, e.g., `echo true >`
}), truncate() { return {}; } };
router["/runtime/reload"] = {
async write({path, buf}) {
await browser.runtime.reload();
return {size: stringToUtf8Array(buf).length};
},
async truncate({path, size}) { return {}; }
};
// Ensure that there are routes for all ancestors. This algorithm is
// probably not correct, but whatever. Basically, you need to start at
// the deepest level, fill in all the parents 1 level up that don't
// exist yet, then walk up one level at a time. It's important to go
// one level at a time so you know (for each parent) what all the
// children will be.
for (let i = 10; i >= 0; i--) {
for (let path of Object.keys(router).filter(key => key.split("/").length === i)) {
path = path.substr(0, path.lastIndexOf("/"));
if (path == '') path = '/';
if (!router[path]) {
function depth(p) { return p === '/' ? 0 : (p.match(/\//g) || []).length; }
// find all direct children
let entries = Object.keys(router)
.filter(k => k.startsWith(path) && depth(k) === depth(path) + 1)
.map(k => k.substr((path === '/' ? 0 : path.length) + 1).split('/')[0]);
entries = [".", "..", ...new Set(entries)];
router[path] = { readdir() { return { entries }; } };
}
}
// I also think it would be better to compute this stuff on the fly,
// so you could patch more routes in at runtime, but I need to think
// a bit about how to make that work with wildcards.
}
if (TESTING) { // I wish I could color this section with... a pink background, or something.
const assert = require('assert');
(async () => {
assert.deepEqual(await router['/tabs/by-id/*'].readdir(), { entries: ['.', '..', 'url.txt', 'title.txt', 'text.txt', 'screenshot.png', 'resources', 'scripts', 'control'] });
assert.deepEqual(await router['/'].readdir(), { entries: ['.', '..', 'extensions', 'tabs', 'runtime'] });
assert.deepEqual(await router['/tabs'].readdir(), { entries: ['.', '..', 'create', 'by-id', 'by-title', 'last-focused'] });
assert.deepEqual(findRoute('/tabs/by-id/TABID/url.txt'), router['/tabs/by-id/*/url.txt']);
})()
}
// fill in default implementations of fs ops
for (let key in router) {
// if readdir -> directory -> add getattr, opendir, releasedir
if (router[key].readdir) {
router[key] = {
getattr() {
return {
st_mode: unix.S_IFDIR | 0755,
st_nlink: 3,
st_size: 0,
};
},
opendir({path}) { return { fh: 0 }; },
releasedir({path}) { return {}; },
...router[key]
};
} else if (router[key].readlink) {
router[key] = {
async getattr({path}) {
const st_size = (await this.readlink({path})).buf.length + 1;
return {
st_mode: unix.S_IFLNK | 0444,
st_nlink: 1,
// You _must_ return correct linkee path length from getattr!
st_size
};
},
...router[key]
};
} else if (router[key].read || router[key].write) {
router[key] = {
async getattr() {
return {
st_mode: unix.S_IFREG | ((router[key].read && 0444) || (router[key].write && 0222)),
st_nlink: 1,
st_size: 100 // FIXME
};
},
open() {
return { fh: 0 };
},
release() {
return {};
},
...router[key]
};
}
}
console.log(router);
function findRoute(path) {
let pathSegments = path.split("/");
if (pathSegments[pathSegments.length - 1].startsWith("._")) {
throw new UnixError(unix.ENOTSUP); // Apple Double file for xattrs
}
let routingPath = "";
for (let segment of pathSegments) {
if (routingPath === "/") { routingPath = ""; }
if (router[routingPath + "/*"]) {
routingPath += "/*";
} else if (router[routingPath + "/" + segment]) {
routingPath += "/" + segment;
} else {
throw new UnixError(unix.ENOENT);
}
}
return router[routingPath];
}
let port;
async function onMessage(req) {
if (req.buf) req.buf = atob(req.buf);
console.log('req', req);
let response = { op: req.op, error: unix.EIO };
let didTimeout = false, timeout = setTimeout(() => {
// timeout is very useful because some operations just hang
// (like trying to take a screenshot, until the tab is focused)
didTimeout = true; console.error('timeout');
port.postMessage({ op: req.op, error: unix.ETIMEDOUT });
}, 1000);
/* console.time(req.op + ':' + req.path);*/
try {
response = await findRoute(req.path)[req.op](req);
response.op = req.op;
if (response.buf) { response.buf = btoa(response.buf); }
} catch (e) {
console.error(e);
response = {
op: req.op,
error: e instanceof UnixError ? e.error : unix.EIO
}
}
/* console.timeEnd(req.op + ':' + req.path);*/
if (!didTimeout) {
clearTimeout(timeout);
console.log('resp', response);
port.postMessage(response);
}
};
function tryConnect() {
port = chrome.runtime.connectNative('com.rsnous.tabfs');
port.onMessage.addListener(onMessage);
port.onDisconnect.addListener(p => {console.log('disconnect', p)});
}
if (!TESTING) {
tryConnect();
}