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 @@
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