From 71f9ecc32b2b0bb3ec5224be6fe328a934ae93ec Mon Sep 17 00:00:00 2001
From: dragonwocky <thedragonring.bod@gmail.com>
Date: Mon, 22 Apr 2024 22:58:50 +1000
Subject: [PATCH] feat: custom userscripts in-app and in-browser!

firefox support will involve some tweaking, manifest v3 not fully supported yet
---
 src/core/menu/menu.mjs |  2 +-
 src/core/mod.json      |  7 +++++++
 src/init.js            | 10 ++++++++--
 src/load.mjs           |  2 +-
 src/manifest.json      |  6 ++----
 src/worker.js          | 43 ++++++++++++++++++++++++++++++++++++++++--
 6 files changed, 60 insertions(+), 10 deletions(-)

diff --git a/src/core/menu/menu.mjs b/src/core/menu/menu.mjs
index d74b08d..1473ba2 100644
--- a/src/core/menu/menu.mjs
+++ b/src/core/menu/menu.mjs
@@ -185,7 +185,7 @@ const renderMenu = async () => {
 const importApi = () => {
     return (_apiImport ??= (async () => {
       const api = globalThis.__enhancerApi;
-      if (typeof api === "undefined") await import("../../common/system.js");
+      if (typeof api === "undefined") await import("../../api/system.js");
       await import("../../load.mjs").then((i) => i.default);
     })());
   },
diff --git a/src/core/mod.json b/src/core/mod.json
index 9d26a76..89318c9 100644
--- a/src/core/mod.json
+++ b/src/core/mod.json
@@ -61,6 +61,13 @@
       "label": "Advanced",
       "_autoremoveIfSectionEmpty": false
     },
+    {
+      "type": "file",
+      "label": "Custom JavaScript",
+      "key": "customScript",
+      "description": "Executes the uploaded userscript within Notion. Requires <a href='https://developer.chrome.com/docs/extensions/reference/api/userScripts#developer_mode_for_extension_users'>developer mode</a> to be enabled in your browser's extension settings to run in Chromium-based browsers.",
+      "extensions": ["js"]
+    },
     {
       "type": "toggle",
       "key": "developerMode",
diff --git a/src/init.js b/src/init.js
index a88e2e4..c691a0b 100644
--- a/src/init.js
+++ b/src/init.js
@@ -19,7 +19,7 @@ if (isElectron()) {
 
   const { enhancerUrl } = globalThis.__enhancerApi,
     { getMods, isEnabled, modDatabase } = globalThis.__enhancerApi,
-    API_LOADED = new Promise((res, rej) => {
+    API_LOADED = new Promise((res) => {
       const onReady = globalThis.__enhancerReady;
       globalThis.__enhancerReady = () => (onReady?.(), res());
     });
@@ -33,12 +33,18 @@ if (isElectron()) {
       contextBridge.exposeInMainWorld("__getEnhancerApi", __getApi);
 
       // load clientStyles, clientScripts
-      document.addEventListener("readystatechange", () => {
+      document.addEventListener("readystatechange", async () => {
         if (document.readyState !== "complete") return false;
         const $script = document.createElement("script");
         $script.type = "module";
         $script.src = enhancerUrl("load.mjs");
         document.head.append($script);
+
+        // register user-provided javascript for execution in-app
+        const { webFrame } = require("electron"),
+          db = await modDatabase("0f0bf8b6-eae6-4273-b307-8fc43f2ee082"),
+          customScript = (await db.get("customScript"))?.content;
+        if (customScript) webFrame.executeJavaScript(customScript);
       });
     }
 
diff --git a/src/load.mjs b/src/load.mjs
index ab38dea..1d47870 100644
--- a/src/load.mjs
+++ b/src/load.mjs
@@ -43,7 +43,7 @@ export default (async () => {
   // the dom must be re-imported
 
   await Promise.all([
-    IS_ELECTRON || import(enhancerUrl("common/registry.js")),
+    IS_ELECTRON || import(enhancerUrl("api/registry.js")),
     import(enhancerUrl("api/interface.mjs")),
     import(enhancerUrl("api/state.js")),
   ]);
diff --git a/src/manifest.json b/src/manifest.json
index b6875ae..292f06d 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -19,15 +19,13 @@
   "permissions": [
     "tabs",
     "storage",
+    "userScripts",
     "clipboardRead",
     "clipboardWrite",
     "unlimitedStorage"
   ],
   "host_permissions": ["*://*.notion.so/*"],
   "web_accessible_resources": [
-    {
-      "matches": ["*://*.notion.so/*"],
-      "resources": ["/*"]
-    }
+    { "matches": ["*://*.notion.so/*"], "resources": ["/*"] }
   ]
 }
diff --git a/src/worker.js b/src/worker.js
index 9d0d253..8328e48 100644
--- a/src/worker.js
+++ b/src/worker.js
@@ -71,7 +71,7 @@ const initDatabase = async () => {
             value = JSON.parse(__statements.select.get(key)?.value);
           } catch {}
         } else value = (await chrome.storage.local.get([key]))[key];
-        return value ?? args.fallbacks[args.key];
+        return value ?? args.fallbacks?.[args.key];
       }
       case "set": {
         const key = namespaceify(args.key),
@@ -89,7 +89,6 @@ const initDatabase = async () => {
           ? (__transactions.remove(keys), true)
           : chrome.storage.local.remove(keys);
       }
-
       case "export": {
         // returns key/value pairs within scope w/out namespace
         // prefix e.g. to streamline importing from one profile and
@@ -149,6 +148,39 @@ if (IS_ELECTRON) {
         .forEach((tab) => chrome.tabs.reload(tab.id));
     };
 
+  const userScriptsAvailable = () => {
+      // manifest v3 userscripts require developer mode to be
+      // enabled in the browser's extension settings
+      try {
+        chrome.userScripts;
+        return true;
+      } catch {
+        return false;
+      }
+    },
+    registerCustomScript = async () => {
+      if (!userScriptsAvailable()) return;
+      // enhancer apis are not available in the worker in-browser,
+      // manual steps are required to get nested values from the db
+      const key = "customScript",
+        matches = ["*://*.notion.so/*"],
+        coreId = "0f0bf8b6-eae6-4273-b307-8fc43f2ee082",
+        profileId =
+          (await queryDatabase([], "get", { key: "activeProfile" })) ??
+          (await queryDatabase([], "get", { key: "profileIds" }))?.[0] ??
+          "default",
+        customScript = await queryDatabase([profileId, coreId], "get", { key }),
+        existingScripts = await chrome.userScripts.getScripts({ ids: [key] }),
+        code = customScript?.content || "";
+      if (existingScripts[0]) {
+        if (code === existingScripts[0]?.code) return;
+        chrome.userScripts.update([{ id: key, matches, js: [{ code }] }]);
+      } else if (code) {
+        chrome.userScripts.register([{ id: key, matches, js: [{ code }] }]);
+      }
+    };
+  registerCustomScript();
+
   chrome.action.onClicked.addListener(openEnhancerMenu);
   // long-lived connection for rapid two-way messaging
   // b/w client and worker, primarily used for db wrapper:
@@ -163,6 +195,13 @@ if (IS_ELECTRON) {
         const { namespace, query, args } = message.data,
           res = await queryDatabase(namespace, query, args);
         if (invocation) port.postMessage({ invocation, message: res });
+        // re-register userscript on updates:
+        // profile change, db import, file upload, file deletion
+        const customScriptChanged =
+          query === "import" ||
+          (query === "set" &&
+            ["activeProfile", "customScript"].includes(args.key));
+        if (customScriptChanged) registerCustomScript();
       }
       if (message === "load-complete") {
         if (!openMenuInTabs.has(tabId)) return;