diff --git a/extension/Userscripts Extension/Functions.swift b/extension/Userscripts Extension/Functions.swift
index adf7a688..b2c04f1c 100644
--- a/extension/Userscripts Extension/Functions.swift
+++ b/extension/Userscripts Extension/Functions.swift
@@ -1675,16 +1675,21 @@ func popupUpdateSingle(_ filename: String, _ url: String, _ subframeUrls: [Strin
// page
func getInitData() -> [String: Any]? {
- let manifest = getManifest()
guard let saveLocation = getSaveLocation() else {
err("getInitData failed at (1)")
return nil
}
+ return [
+ "saveLocation": saveLocation.path,
+ "version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "??",
+ "build": Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "??"
+ ]
+}
+
+func getLegacyData() -> [String: Any]? {
+ let manifest = getManifest()
var data:[String: Any] = manifest.settings
data["blacklist"] = manifest.blacklist
- data["saveLocation"] = saveLocation.path
- data["version"] = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "??"
- data["build"] = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "??"
return data
}
@@ -1945,11 +1950,6 @@ func popupInit() -> [String: String]? {
err("Failed to updateDeclarativeNetRequest in popupInit")
return nil
}
- let manifest = getManifest()
- guard let active = manifest.settings["active"] else {
- err("Failed at getManifest active in popupInit")
- return nil
- }
// pass some info in response
guard let saveLocation = getSaveLocation() else {
err("Failed at getSaveLocation in popupInit")
@@ -1959,7 +1959,6 @@ func popupInit() -> [String: String]? {
let requireLocation = getRequireLocation()
return [
- "active": active,
"saveLocation": saveLocation.absoluteString,
"documentsDirectory": documentsDirectory.absoluteString,
"requireLocation": requireLocation.absoluteString
diff --git a/extension/Userscripts Extension/Resources/content.js b/extension/Userscripts Extension/Resources/content.js
index 82156c87..8441aabb 100644
--- a/extension/Userscripts Extension/Resources/content.js
+++ b/extension/Userscripts Extension/Resources/content.js
@@ -241,7 +241,6 @@ function processJS(userscript) {
function wrapCode(preCode, code, filename) {
return `
(function() {
- "use strict";
${preCode}
(function() {
const US_filename = "${filename}";
@@ -323,7 +322,7 @@ function cspFallback(e) {
}
}
-browser.runtime.sendMessage({name: "REQ_USERSCRIPTS"}, response => {
+const injection = () => browser.runtime.sendMessage({name: "REQ_USERSCRIPTS"}, response => {
// cancel injection if errors detected
if (!response || response.error) {
console.error(response?.error || "REQ_USERSCRIPTS returned undefined");
@@ -331,9 +330,11 @@ browser.runtime.sendMessage({name: "REQ_USERSCRIPTS"}, response => {
}
// save response locally in case CSP events occur
data = response;
+ // combine regular and context-menu scripts
+ const scripts = [...data.files.js, ...data.files.menu];
// loop through each userscript and prepare for processing
- for (let i = 0; i < data.files.js.length; i++) {
- const userscript = data.files.js[i];
+ for (let i = 0; i < scripts.length; i++) {
+ const userscript = scripts[i];
userscript.preCode = "";
// pass references to the api methods as needed
const gmMethods = [];
@@ -451,7 +452,13 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
const item = data.files.menu[i];
if (item.scriptObject.filename === filename) {
console.info(`Injecting ${filename} %c(js)`, "color: #fff600");
- sendResponse({code: item.code});
+ sendResponse({
+ code: wrapCode(
+ item.preCode,
+ item.code,
+ filename
+ )
+ });
return;
}
}
@@ -460,3 +467,9 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
});
// listen for CSP violations
document.addEventListener("securitypolicyviolation", cspFallback);
+
+// should import settings.js and use `settingsStorage.get("global_active")`
+browser.storage.local.get("US_GLOBAL_ACTIVE").then(results => {
+ if (results?.US_GLOBAL_ACTIVE === false) return console.info("Userscripts off");
+ injection();
+});
\ No newline at end of file
diff --git a/extension/Userscripts Extension/SafariWebExtensionHandler.swift b/extension/Userscripts Extension/SafariWebExtensionHandler.swift
index 0c3bf641..1e79cce8 100644
--- a/extension/Userscripts Extension/SafariWebExtensionHandler.swift
+++ b/extension/Userscripts Extension/SafariWebExtensionHandler.swift
@@ -188,13 +188,22 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
}
else if name == "PAGE_INIT_DATA" {
#if os(macOS)
- if let settings = getInitData(), checkDefaultDirectories() {
- response.userInfo = [SFExtensionMessageKey: settings]
+ if let initData = getInitData(), checkDefaultDirectories() {
+ response.userInfo = [SFExtensionMessageKey: initData]
} else {
response.userInfo = [SFExtensionMessageKey: ["error": "failed to get init data"]]
}
#endif
}
+ else if name == "PAGE_LEGACY_IMPORT" {
+ #if os(macOS)
+ if let settings = getLegacyData() {
+ response.userInfo = [SFExtensionMessageKey: settings]
+ } else {
+ response.userInfo = [SFExtensionMessageKey: ["error": "failed to get legacy data"]]
+ }
+ #endif
+ }
else if name == "PAGE_ALL_FILES" {
#if os(macOS)
if let files = getAllFiles() {
diff --git a/src/page/App.svelte b/src/page/App.svelte
index 462b664d..ea6607ff 100644
--- a/src/page/App.svelte
+++ b/src/page/App.svelte
@@ -43,12 +43,7 @@
log.add("Requesting initialization data", "info", false);
const initData = await browser.runtime.sendNativeMessage({name: "PAGE_INIT_DATA"});
if (initData.error) return console.error(initData.error);
- for (const [key, value] of Object.entries(initData)) {
- if (value === "true" || value === "false") {
- initData[key] = JSON.parse(value);
- }
- }
- settings.set(initData);
+ await settings.init(initData);
state.add("items-loading");
state.remove("init");
diff --git a/src/page/Components/Settings.svelte b/src/page/Components/Settings.svelte
index b6b4d499..a8933271 100644
--- a/src/page/Components/Settings.svelte
+++ b/src/page/Components/Settings.svelte
@@ -68,7 +68,7 @@
state.remove("settings")}
+ on:click|self={() => state.remove("settings")}
in:fade={{duration: 150}}
out:fade={{duration: 150, delay: 75}}
>
@@ -120,11 +120,11 @@
Tab Size
diff --git a/src/page/store.js b/src/page/store.js
index f366e2c2..790704e3 100644
--- a/src/page/store.js
+++ b/src/page/store.js
@@ -1,5 +1,6 @@
import {writable} from "svelte/store";
import {uniqueId} from "./utils.js";
+import * as settingsStorage from "../shared/settings.js";
function notificationStore() {
const {subscribe, update} = writable([]);
@@ -76,40 +77,48 @@ export const state = stateStore();
function settingsStore() {
const {subscribe, update, set} = writable({});
- const updateSingleSetting = (key, value) => {
- update(settings => {
- settings[key] = value;
- // blacklist not stored in normal setting object in manifest, so handle differently
- if (key === "blacklist") {
- // update blacklist on swift side
- const message = {name: "PAGE_UPDATE_BLACKLIST", blacklist: value};
- browser.runtime.sendNativeMessage(message, response => {
- if (response.error) {
- log.add("Failed to save blacklist to disk", "error", true);
- }
- });
- return settings;
- }
- // settings are saved as strings on the swift side
- // convert all booleans to strings before dispatching
- const settingsClone = {...settings};
- for (const [key, value] of Object.entries(settingsClone)) {
- if (typeof value === "boolean") settingsClone[key] = value.toString();
- }
- // remove settings in clone that aren't save in user defaults
- delete settingsClone.blacklist;
- delete settingsClone.version;
- // update settings on swift side
- const message = {name: "PAGE_UPDATE_SETTINGS", settings: settingsClone};
+ const init = async initData => {
+ // import legacy settings data just one-time
+ await settingsStorage.legacy_import();
+ // for compatibility with legacy getting names only
+ // once all new name is used, use settingsStorage.get()
+ const settings = await settingsStorage.legacy_get();
+ console.info("store.js settingsStore init", initData, settings);
+ set(Object.assign({}, initData, settings));
+ // sync popup, backgound, etc... settings changes
+ settingsStorage.onChanged((settings, area) => {
+ console.log(`store.js storage.${area}.onChanged`, settings);
+ update(obj => Object.assign(obj, settings));
+ });
+ };
+ const reset = async keys => {
+ await settingsStorage.reset(keys);
+ // once all new name is used, use settingsStorage.get()
+ const settings = await settingsStorage.legacy_get();
+ console.info("store.js settingsStore reset", settings);
+ update(obj => Object.assign(obj, settings));
+ };
+ const updateSingleSetting_old = (key, value) => {
+ // blacklist not stored in normal setting object in manifest, so handle differently
+ if (key === "blacklist") {
+ // update blacklist on swift side
+ const message = {name: "PAGE_UPDATE_BLACKLIST", blacklist: value};
browser.runtime.sendNativeMessage(message, response => {
if (response.error) {
- log.add(response.error, "error", true);
+ log.add("Failed to save blacklist to disk", "error", true);
}
});
- return settings;
- });
+ }
+ };
+ const updateSingleSetting = (key, value) => {
+ update(settings => (settings[key] = value, settings));
+ // for compatibility with legacy setting names only
+ // once all new name is used, use settingsStorage.set()
+ settingsStorage.legacy_set({[key]: value}); // Durable Storage
+ // temporarily keep the old storage method until it is confirmed that all dependencies are removed
+ updateSingleSetting_old(key, value);
};
- return {subscribe, set, updateSingleSetting};
+ return {subscribe, set, init, reset, updateSingleSetting};
}
export const settings = settingsStore();
diff --git a/src/popup/App.svelte b/src/popup/App.svelte
index ef8a72e2..be50d240 100644
--- a/src/popup/App.svelte
+++ b/src/popup/App.svelte
@@ -12,6 +12,7 @@
import iconUpdate from "../shared/img/icon-update.svg";
import iconClear from "../shared/img/icon-clear.svg";
import iconRefresh from "../shared/img/icon-refresh.svg";
+ import * as settingsStorage from "../shared/settings.js";
let error = undefined;
let active = true;
@@ -50,14 +51,9 @@
$: if (platform) document.body.classList.add(platform);
- function toggleExtension(e) {
- e.preventDefault(); // prevent check state from changing on click
- disabled = true;
- browser.runtime.sendNativeMessage({name: "POPUP_TOGGLE_EXTENSION"}, response => {
- disabled = false;
- if (response.error) return error = response.error;
- active = !active;
- });
+ async function toggleExtension(e) {
+ await settingsStorage.set({"global_active": !active});
+ active = await settingsStorage.get("global_active");
}
function updateAll() {
@@ -259,9 +255,9 @@
loading = false;
disabled = false;
return;
- } else {
- active = init.initData.active === "true" ? true : false;
}
+
+ active = await settingsStorage.get("global_active");
// refresh session rules
browser.runtime.sendMessage({name: "REFRESH_SESSION_RULES"});
diff --git a/src/shared/dev.js b/src/shared/dev.js
index 1097378f..86ade13b 100644
--- a/src/shared/dev.js
+++ b/src/shared/dev.js
@@ -85,6 +85,12 @@ const _browser = {
console.info(`Got message: ${name}`);
let response = {};
if (name === "PAGE_INIT_DATA") {
+ response = {
+ saveLocation: "/Users/someone/Library/Directory",
+ version: "4.0.0",
+ build: "44"
+ };
+ } else if (name === "PAGE_LEGACY_IMPORT") {
response = {
active: "true",
autoCloseBrackets: "true",
@@ -94,13 +100,10 @@ const _browser = {
languageCode: "en",
lint: "true",
log: "false",
- saveLocation: "/Users/someone/Library/Directory",
showCount: "true",
showInvisibles: "true",
sortOrder: "lastModifiedDesc",
- tabSize: "4",
- version: "4.0.0",
- build: "44"
+ tabSize: "4"
};
} else if (name === "PAGE_ALL_FILES") {
response = [];
@@ -416,6 +419,10 @@ const _browser = {
},
set() {
return new Promise(resolve => setTimeout(() => resolve(), _browser.delay));
+ },
+ remove: () => new Promise(resolve => setTimeout(() => resolve(), _browser.delay)),
+ onChanged: {
+ addListener: () => undefined
}
}
}
diff --git a/src/shared/settings.js b/src/shared/settings.js
new file mode 100644
index 00000000..7024da75
--- /dev/null
+++ b/src/shared/settings.js
@@ -0,0 +1,682 @@
+// wrap a relatively independent settings storage with its own functions
+
+const storagePrefix = "US_";
+const storageKey = key => storagePrefix + key.toUpperCase();
+// const storageRef = async area => { // dynamic storage reference
+// browser.storage.sync.area = "sync";
+// browser.storage.local.area = "local";
+// if (area === "sync") return browser.storage.sync;
+// if (area === "local") return browser.storage.local;
+// const key = storageKey("settings_sync");
+// const result = await browser.storage.local.get(key);
+// if (result?.[key] === true) {
+// return browser.storage.sync;
+// } else {
+// return browser.storage.local;
+// }
+// };
+
+// https://developer.apple.com/documentation/safariservices/safari_web_extensions/assessing_your_safari_web_extension_s_browser_compatibility#3584139
+// since storage sync is not implemented in Safari, currently only returns using local storage
+const storageRef = async () => {
+ browser.storage.local.area = "local";
+ return browser.storage.local;
+};
+
+const settingDefault = deepFreeze({
+ name: "setting_default",
+ type: undefined,
+ local: false,
+ values: [],
+ default: undefined,
+ protect: false,
+ confirm: false,
+ platforms: ["macos", "ipados", "ios"],
+ langLabel: {},
+ langTitle: {},
+ group: "",
+ legacy: "",
+ nodeType: "",
+ nodeClass: {}
+});
+
+export const settingsDefine = deepFreeze([
+ {
+ name: "legacy_imported",
+ type: "number",
+ local: true,
+ default: 0,
+ protect: true,
+ platforms: ["macos"],
+ group: "Internal"
+ },
+ {
+ name: "language_code",
+ type: "string",
+ default: "en",
+ platforms: ["macos", "ipados", "ios"],
+ group: "Internal",
+ legacy: "languageCode"
+ },
+ {
+ name: "scripts_settings",
+ type: "object",
+ default: {},
+ platforms: ["macos", "ipados", "ios"],
+ langLabel: {
+ en: "Scripts update check active",
+ zh_hans: "脚本更新检查激活"
+ },
+ langTitle: {
+ en: "Whether to enable each single script update check",
+ zh_hans: "是否开启单个脚本更新检查"
+ },
+ group: "Internal",
+ nodeType: "Subpage"
+ },
+ // {
+ // name: "settings_sync",
+ // type: "boolean",
+ // local: true,
+ // default: false,
+ // protect: true,
+ // platforms: ["macos", "ipados", "ios"],
+ // langLabel: {
+ // en: "Sync settings",
+ // zh_hans: "同步设置"
+ // },
+ // langTitle: {
+ // en: "Sync settings across devices",
+ // zh_hans: "跨设备同步设置"
+ // },
+ // group: "General",
+ // nodeType: "Toggle"
+ // },
+ {
+ name: "toolbar_badge_count",
+ type: "boolean",
+ default: true,
+ platforms: ["macos", "ipados"],
+ langLabel: {
+ en: "Show Toolbar Count",
+ zh_hans: "工具栏图标显示计数徽章"
+ },
+ langTitle: {
+ en: "displays a badge on the toolbar icon with a number that represents how many enabled scripts match the url for the page you are on",
+ zh_hans: "简体中文描述"
+ },
+ group: "General",
+ legacy: "showCount",
+ nodeType: "Toggle"
+ },
+ {
+ name: "global_active",
+ type: "boolean",
+ local: true,
+ default: true,
+ platforms: ["macos"],
+ langLabel: {
+ en: "Enable Injection",
+ zh_hans: "启用注入"
+ },
+ langTitle: {
+ en: "toggle on/off script injection for the pages you visit",
+ zh_hans: "简体中文描述"
+ },
+ group: "General",
+ legacy: "active",
+ nodeType: "Toggle",
+ nodeClass: {red: false}
+ },
+ {
+ name: "global_scripts_update_check",
+ type: "boolean",
+ default: true,
+ platforms: ["macos", "ipados", "ios"],
+ langLabel: {
+ en: "Global scripts update check",
+ zh_hans: "全局脚本更新检查"
+ },
+ langTitle: {
+ en: "Whether to enable global periodic script update check",
+ zh_hans: "是否开启全局定期脚本更新检查"
+ },
+ group: "General",
+ nodeType: "Toggle"
+ },
+ {
+ name: "scripts_update_check_interval",
+ type: "number",
+ default: 86400000,
+ platforms: ["macos", "ipados", "ios"],
+ langLabel: {
+ en: "Scripts update check interval",
+ zh_hans: "脚本更新检查间隔"
+ },
+ langTitle: {
+ en: "The interval for script update check in background",
+ zh_hans: "脚本更新检查的间隔时间"
+ },
+ group: "General",
+ nodeType: "Toggle"
+ },
+ {
+ name: "scripts_update_check_lasttime",
+ type: "number",
+ default: 0,
+ platforms: ["macos", "ipados", "ios"],
+ langLabel: {
+ en: "Scripts update check lasttime",
+ zh_hans: "脚本更新上次检查时间"
+ },
+ langTitle: {
+ en: "The lasttime for script update check in background",
+ zh_hans: "后台脚本更新上次检查时间"
+ },
+ group: "Internal",
+ nodeType: "Toggle"
+ },
+ {
+ name: "scripts_auto_update",
+ type: "boolean",
+ default: false,
+ confirm: true,
+ platforms: ["macos", "ipados", "ios"],
+ langLabel: {
+ en: "Scripts silent auto update",
+ zh_hans: "脚本后台静默自动更新"
+ },
+ langTitle: {
+ en: "Script silently auto-updates in the background, which is dangerous and may introduce unconfirmed malicious code",
+ zh_hans: "脚本在后台静默自动更新,这是危险的,可能引入未经确认的恶意代码"
+ },
+ group: "General",
+ nodeType: "Toggle",
+ nodeClass: {warn: true}
+ },
+ {
+ name: "global_exclude_match",
+ type: "object",
+ default: [],
+ platforms: ["macos", "ipados", "ios"],
+ langLabel: {
+ en: "Global exclude match patterns",
+ zh_hans: "全局排除匹配模式列表"
+ },
+ langTitle: {
+ en: "this input accepts a comma separated list of @match patterns, a page url that matches against a pattern in this list will be ignored for script injection",
+ zh_hans: "简体中文描述"
+ },
+ group: "General",
+ legacy: "blacklist",
+ nodeType: "textarea",
+ nodeClass: {red: "blacklistError"}
+ },
+ {
+ name: "editor_close_brackets",
+ type: "boolean",
+ default: true,
+ platforms: ["macos"],
+ langLabel: {
+ en: "Auto Close Brackets",
+ zh_hans: "自动关闭括号"
+ },
+ langTitle: {
+ en: "toggles on/off auto closing of brackets in the editor, this affects the following characters: () [] {} \"\" ''",
+ zh_hans: "简体中文描述"
+ },
+ group: "Editor",
+ legacy: "autoCloseBrackets",
+ nodeType: "Toggle"
+ },
+ {
+ name: "editor_auto_hint",
+ type: "boolean",
+ default: true,
+ platforms: ["macos"],
+ langLabel: {
+ en: "Auto Hint",
+ zh_hans: "自动提示(Hint)"
+ },
+ langTitle: {
+ en: "automatically shows completion hints while editing",
+ zh_hans: "简体中文描述"
+ },
+ group: "Editor",
+ legacy: "autoHint",
+ nodeType: "Toggle"
+ },
+ {
+ name: "editor_list_sort",
+ type: "string",
+ values: ["nameAsc", "nameDesc", "lastModifiedAsc", "lastModifiedDesc"],
+ default: "lastModifiedDesc",
+ platforms: ["macos"],
+ langLabel: {
+ en: "Sort order",
+ zh_hans: "排序顺序"
+ },
+ langTitle: {
+ en: "Display order of items in sidebar",
+ zh_hans: "侧栏中项目的显示顺序"
+ },
+ group: "Editor",
+ legacy: "sortOrder",
+ nodeType: "Dropdown"
+ },
+ {
+ name: "editor_list_descriptions",
+ type: "boolean",
+ default: true,
+ platforms: ["macos"],
+ langLabel: {
+ en: "Show List Descriptions",
+ zh_hans: "显示列表项目描述"
+ },
+ langTitle: {
+ en: "show or hides the item descriptions in the sidebar",
+ zh_hans: "简体中文描述"
+ },
+ group: "Editor",
+ legacy: "descriptions",
+ nodeType: "Toggle"
+ },
+ {
+ name: "editor_javascript_lint",
+ type: "boolean",
+ default: false,
+ platforms: ["macos"],
+ langLabel: {
+ en: "Javascript Linter",
+ zh_hans: "Javascript Linter"
+ },
+ langTitle: {
+ en: "toggles basic Javascript linting within the editor",
+ zh_hans: "简体中文描述"
+ },
+ group: "Editor",
+ legacy: "lint",
+ nodeType: "Toggle"
+ },
+ {
+ name: "editor_show_whitespace",
+ type: "boolean",
+ default: true,
+ platforms: ["macos"],
+ langLabel: {
+ en: "Show whitespace characters",
+ zh_hans: "显示空白字符"
+ },
+ langTitle: {
+ en: "toggles the display of invisible characters in the editor",
+ zh_hans: "简体中文描述"
+ },
+ group: "Editor",
+ legacy: "showInvisibles",
+ nodeType: "Toggle"
+ },
+ {
+ name: "editor_tab_size",
+ type: "number",
+ values: [2, 4],
+ default: 4,
+ platforms: ["macos"],
+ langLabel: {
+ en: "Tab Size",
+ zh_hans: "制表符大小"
+ },
+ langTitle: {
+ en: "the number of spaces a tab is equal to while editing",
+ zh_hans: "简体中文描述"
+ },
+ group: "Editor",
+ legacy: "tabSize",
+ nodeType: "select"
+ }
+].reduce(settingsDefineReduceCallback, {}));
+
+// populate the settingsDefine with settingDefault
+// and convert settingsDefine to storageKey object
+function settingsDefineReduceCallback(settings, setting) {
+ setting.key = storageKey(setting.name);
+ settings[setting.key] = Object.assign({}, settingDefault, setting);
+ return settings;
+}
+
+// prevent settings define from being modified in any case
+// otherwise user settings may be lost in the worst case
+function deepFreeze(object) {
+ for (const p in object) {
+ if (typeof object[p] == "object") {
+ deepFreeze(object[p]);
+ }
+ }
+ return Object.freeze(object);
+}
+
+// export and define the operation method of settings storage
+// they are similar to browser.storage but slightly different
+
+export async function get(keys, area) {
+ if (![undefined, "local", "sync"].includes(area)) {
+ return console.error("Unexpected storage area:", area);
+ }
+ // validate setting value and fix surprises to default
+ const valueFix = (key, val) => {
+ if (!key || !Object.hasOwn(settingsDefine, key)) return;
+ const def = settingsDefine[key].default;
+ // check if value type conforms to settingsDefine
+ const type = settingsDefine[key].type;
+ if (typeof val != type) {
+ console.warn(`Unexpected ${key} value type '${typeof(val)}' should '${type}', fix to default`);
+ return def;
+ }
+ // check if value conforms to settingsDefine
+ const values = settingsDefine[key].values;
+ if (values.length && !values.includes(val)) {
+ console.warn(`Unexpected ${key} value '${val}' should one of '${values}', fix to default`);
+ return def;
+ }
+ // verified, pass original value
+ return val;
+ };
+ if (typeof keys == "string") { // [single setting]
+ const key = storageKey(keys);
+ // check if key exist in settingsDefine
+ if (!Object.hasOwn(settingsDefine, key)) {
+ return console.error("unexpected settings key:", key);
+ }
+ // check if only locally stored setting
+ settingsDefine[key].local === true && (area = "local");
+ const storage = await storageRef(area);
+ const result = await storage.get(key);
+ if (Object.hasOwn(result, key)) {
+ return valueFix(key, result[key]);
+ } else {
+ return settingsDefine[key].default;
+ }
+ }
+ const complexGet = async (settingsDefault, areaKeys) => {
+ const storage = await storageRef(area);
+ let local = {}, sync = {};
+ if (storage.area === "sync") {
+ if (areaKeys.sync.length) {
+ sync = await storage.get(areaKeys.sync);
+ }
+ if (areaKeys.local.length) {
+ const storage = await storageRef("local");
+ local = await storage.get(areaKeys.local);
+ }
+ } else {
+ local = await storage.get(areaKeys.all);
+ }
+ const result = Object.assign(settingsDefault, local, sync);
+ // revert settings object property name
+ return Object.entries(result).reduce((p, c) => (
+ p[settingsDefine[c[0]].name] = valueFix(...c), p
+ ), {});
+ };
+ if (Array.isArray(keys)) { // [muilt settings]
+ if (!keys.length) {
+ return console.error("Settings keys empty:", keys);
+ }
+ const settingsDefault = {};
+ const areaKeys = {local: [], sync: [], all: []};
+ for (const k of keys) {
+ const key = storageKey(k);
+ // check if key exist in settingsDefine
+ if (!Object.hasOwn(settingsDefine, key)) {
+ return console.error("unexpected settings key:", key);
+ }
+ settingsDefault[key] = settingsDefine[key].default;
+ // detach only locally stored settings
+ settingsDefine[key].local === true
+ ? areaKeys.local.push(key)
+ : areaKeys.sync.push(key);
+ // record all keys in case sync storage is not enabled
+ areaKeys.all.push(key);
+ }
+ return await complexGet(settingsDefault, areaKeys);
+ }
+ if (typeof keys == "undefined" || keys === null) { // [all settings]
+ const settingsDefault = {};
+ const areaKeys = {local: [], sync: [], all: []};
+ for (const key in settingsDefine) {
+ settingsDefault[key] = settingsDefine[key].default;
+ // detach only locally stored settings
+ settingsDefine[key].local === true
+ ? areaKeys.local.push(key)
+ : areaKeys.sync.push(key);
+ // record all keys in case sync storage is not enabled
+ areaKeys.all.push(key);
+ }
+ return await complexGet(settingsDefault, areaKeys);
+ }
+ return console.error("Unexpected keys type:", keys);
+}
+
+export async function set(keys, area) {
+ if (![undefined, "local", "sync"].includes(area)) {
+ return console.error("unexpected storage area:", area);
+ }
+ if (typeof keys != "object") {
+ return console.error("Unexpected keys type:", keys);
+ }
+ if (!Object.keys(keys).length) {
+ return console.error("Settings object empty:", keys);
+ }
+ const areaKeys = {local: {}, sync: {}, all: {}};
+ for (const k in keys) {
+ const key = storageKey(k);
+ // check if key exist in settingsDefine
+ if (!Object.hasOwn(settingsDefine, key)) {
+ return console.error("Unexpected settings keys:", key);
+ }
+ // check if value type conforms to settingsDefine
+ const type = settingsDefine[key].type;
+ if (typeof keys[k] != type) {
+ if (type === "number" && !isNaN(keys[k])) { // compatible with string numbers
+ keys[k] = Number(keys[k]); // still store it as a number type
+ } else {
+ return console.error(`Unexpected ${k} value type '${typeof(keys[k])}' should '${type}'`);
+ }
+ }
+ // check if value conforms to settingsDefine
+ const values = settingsDefine[key].values;
+ if (values.length && !values.includes(keys[k])) {
+ return console.error(`Unexpected ${k} value '${keys[k]}' should one of '${values}'`);
+ }
+ // detach only locally stored settings
+ settingsDefine[key].local === true
+ ? areaKeys.local[key] = keys[k]
+ : areaKeys.sync[key] = keys[k];
+ // record all keys in case sync storage is not enabled
+ areaKeys.all[key] = keys[k];
+ }
+ const storage = await storageRef(area);
+ // complexSet
+ try {
+ if (storage.area === "sync") {
+ if (Object.keys(areaKeys.sync).length) {
+ await storage.set(areaKeys.sync);
+ }
+ if (Object.keys(areaKeys.local).length) {
+ const storage = await storageRef("local");
+ await storage.set(areaKeys.local);
+ }
+ } else {
+ await storage.set(areaKeys.all);
+ }
+ return true;
+ } catch (error) {
+ return console.error(error);
+ }
+}
+
+export async function reset(keys, area) { // reset to default
+ if (![undefined, "local", "sync"].includes(area)) {
+ return console.error("unexpected storage area:", area);
+ }
+ if (typeof keys == "string") { // [single setting]
+ const key = storageKey(keys);
+ // check if key exist in settingsDefine
+ if (!Object.hasOwn(settingsDefine, key)) {
+ return console.error("unexpected settings key:", key);
+ }
+ // check if key is protected
+ if (settingsDefine[key].protect === true) {
+ return console.error("protected settings key:", key);
+ }
+ settingsDefine[key].local === true && (area = "local");
+ const storage = await storageRef(area);
+ return storage.remove(key);
+ }
+ const complexRemove = async areaKeys => {
+ const storage = await storageRef(area);
+ try {
+ if (storage.area === "sync") {
+ if (areaKeys.sync.length) {
+ await storage.remove(areaKeys.sync);
+ }
+ if (areaKeys.local.length) {
+ const storage = await storageRef("local");
+ await storage.remove(areaKeys.local);
+ }
+ } else {
+ await storage.remove(areaKeys.all);
+ }
+ return true;
+ } catch (error) {
+ return console.error(error);
+ }
+ };
+ if (Array.isArray(keys)) { // [muilt settings]
+ if (!keys.length) {
+ return console.error("Settings keys empty:", keys);
+ }
+ const areaKeys = {local: [], sync: [], all: []};
+ for (const k of keys) {
+ const key = storageKey(k);
+ // check if key exist in settingsDefine
+ if (!Object.hasOwn(settingsDefine, key)) {
+ return console.error("unexpected settings key:", key);
+ }
+ // check if key is protected
+ if (settingsDefine[key].protect === true) {
+ return console.error("protected settings key:", key);
+ }
+ // detach only locally stored settings
+ settingsDefine[key].local === true
+ ? areaKeys.local.push(key)
+ : areaKeys.sync.push(key);
+ // record all keys in case sync storage is not enabled
+ areaKeys.all.push(key);
+ }
+ return await complexRemove(areaKeys);
+ }
+ if (typeof keys == "undefined" || keys === null) { // [all settings]
+ const areaKeys = {local: [], sync: [], all: []};
+ for (const key in settingsDefine) {
+ // skip protected keys
+ if (settingsDefine[key].protect === true) continue;
+ // detach only locally stored settings
+ settingsDefine[key].local === true
+ ? areaKeys.local.push(key)
+ : areaKeys.sync.push(key);
+ // record all keys in case sync storage is not enabled
+ areaKeys.all.push(key);
+ }
+ return await complexRemove(areaKeys);
+ }
+ return console.error("Unexpected keys type:", keys);
+}
+
+// this function is convenient for the svelte store to update the state
+export function onChanged(callback) { // complex onChanged
+ if (typeof callback != "function") {
+ return console.error("Unexpected callback:", callback);
+ }
+ console.info("storage onChanged addListener");
+ const handle = (changes, area) => {
+ // console.log(`storage.${area}.onChanged`, changes);
+ try {
+ const settings = {};
+ for (const key in changes) {
+ if (!Object.hasOwn(settingsDefine, key)) continue;
+ settings[settingsDefine[key].name] = changes[key].newValue;
+ }
+ callback(settings, area);
+ } catch (error) {
+ console.error("onChanged callback:", error);
+ }
+ };
+ // comment for now same reason as `storageRef` function
+ // browser.storage.sync.onChanged.addListener(c => handle(c, "sync"));
+ browser.storage.local.onChanged.addListener(c => handle(c, "local"));
+}
+
+// the following functions are used only for compatibility transition periods
+// they are deliberately named in snake_case format
+// they will be deleted at some point in the future
+
+export async function legacy_get(keys) {
+ const result = await get(keys);
+ // console.log("legacy_get", keys, result);
+ for (const key in result) {
+ const legacy = settingsDefine[storageKey(key)]?.legacy;
+ if (legacy) result[legacy] = result[key];
+ }
+ return result;
+}
+
+export async function legacy_set(keys) {
+ if (typeof keys != "object") {
+ return console.error("Unexpected arg type:", keys);
+ }
+ if (!Object.keys(keys).length) {
+ return console.error("Settings object empty:", keys);
+ }
+ const settings = {};
+ for (const key in settingsDefine) {
+ const setting = settingsDefine[key];
+ if (!setting.legacy) continue;
+ if (setting.legacy in keys) {
+ settings[setting.name] = keys[setting.legacy];
+ }
+ }
+ // console.log("legacy_set", keys, settings);
+ return await set(settings);
+}
+
+export async function legacy_import() {
+ // if legacy data has already been imported, skip this process
+ const imported = await get("legacy_imported");
+ if (imported) return console.info("Legacy settings has already imported");
+ // start the one-time import process
+ const result = await browser.runtime.sendNativeMessage({name: "PAGE_LEGACY_IMPORT"});
+ if (result.error) return console.error(result.error);
+ console.info("Import settings data from legacy manifest file");
+ const settings = {};
+ for (const key in settingsDefine) {
+ const legacy = settingsDefine[key].legacy;
+ if (legacy in result) {
+ let value = result[legacy];
+ switch (settingsDefine[key].type) {
+ case "boolean": value = JSON.parse(value); break;
+ case "number": value = Number(value); break;
+ }
+ console.log(`Importing legacy setting: ${legacy}`, value);
+ settings[settingsDefine[key].name] = value;
+ }
+ }
+ // import complete tag, to ensure will only be import once
+ Object.assign(settings, {"legacy_imported": Date.now()});
+ if (await set(settings, "local")) {
+ console.info("Import legacy settings complete");
+ // send a message to the Swift layer to safely clean up legacy data
+ // browser.runtime.sendNativeMessage({name: "PAGE_LEGACY_IMPORTED"});
+ return true;
+ } else {
+ return console.error("Import legacy settings abort");
+ }
+}
\ No newline at end of file