|
// ==UserScript== |
|
// @name GMStorage with Deep Proxy Fix and Recursive Defaults |
|
// @namespace https://wol.ph |
|
// @version 1.2 |
|
// @description GMStorage library: deep proxy, always-trigger listeners, recursive defaults. |
|
// @author YourName |
|
// @grant GM_getValue |
|
// @grant GM_setValue |
|
// @grant GM_listValues |
|
// @grant GM_addValueChangeListener |
|
// @grant GM_removeValueChangeListener |
|
// ==/UserScript== |
|
|
|
/** |
|
* GMStorage class |
|
* |
|
* Provides a proxy-based API for effortless reading and writing to GM storage, |
|
* with support for deep object updates, automatic synchronization between tabs, |
|
* and recursive application of default values. |
|
* |
|
* Usage: |
|
* // Create an instance with debugging enabled and default values: |
|
* const defaults = { |
|
* some_key: 42, |
|
* settings: { theme: 'dark', notifications: true }, |
|
* user: null |
|
* }; |
|
* // Assume storage already contains: { settings: { fontSize: 12, notifications: false } } |
|
* const storage = new GMStorage(defaults, true); |
|
* |
|
* // After initialization, storage will effectively contain: |
|
* // { |
|
* // some_key: 42, // Added from defaults |
|
* // settings: { theme: 'dark', fontSize: 12, notifications: false }, // Merged |
|
* // user: null // Added from defaults |
|
* // } |
|
* |
|
* // Setting and getting values: |
|
* storage.whatever = 123; |
|
* console.log(storage.whatever); // 123 |
|
* console.log(storage.settings.theme); // 'dark' |
|
* storage.settings.fontSize = 14; // Auto-saves the whole 'settings' object |
|
* |
|
* // One-shot awaiting the next update: |
|
* const newValue = await storage.listen('some_key'); |
|
* |
|
* // Continuous listening using async iteration: |
|
* for await (const value of storage.listen('settings')) { |
|
* console.log("Updated settings:", value); |
|
* } |
|
* // Continuous listening using callback: |
|
* storage.addListener('settings', (key, oldVal, newVal, remote) => { |
|
* console.log(`Settings changed (remote: ${remote}):`, oldVal, '->', newVal); |
|
* }); |
|
*/ |
|
class GMStorage { |
|
/** |
|
* @param {Object} [defaults={}] - An object containing default key/value pairs. Defaults are applied recursively to existing objects in storage. |
|
* @param {boolean} [debug=false] - Enable debug logging if true. |
|
*/ |
|
constructor(defaults = {}, debug = false) { |
|
this._debug = debug; |
|
this._log = debug ? (...args) => console.debug(...args) : () => {}; |
|
this._listenerMap = {}; |
|
this._pendingListeners = {}; |
|
this._alwaysListeners = {}; |
|
this._data = {}; |
|
this._suppressGlobal = false; // Flag to prevent self-notification via global patch |
|
|
|
// --- Global GM_setValue Patch (same as before) --- |
|
if (!GMStorage.__globalPatched) { |
|
GMStorage.__globalPatched = true; |
|
const originalGMSetValue = GM_setValue; |
|
GM_setValue = (key, value) => { |
|
const oldValue = GM_getValue(key); // Get value *before* setting |
|
originalGMSetValue(key, value); |
|
if (window.__gmStorageInstances) { |
|
for (const storage of window.__gmStorageInstances) { |
|
// Use try-catch as external scripts might modify _data unexpectedly |
|
try { |
|
// Check if the key exists in this instance's known keys (_data) |
|
// and if the notification isn't suppressed (i.e., it was an external call) |
|
if (!storage._suppressGlobal && key in storage._data) { |
|
// Pass correct old/new values to the internal update handler |
|
storage._handleRemoteUpdate(key, oldValue, value, false); // false indicates not truly remote, but external |
|
} |
|
} catch (e) { |
|
console.error("GMStorage: Error processing external GM_setValue notification", e); |
|
} |
|
} |
|
} |
|
}; |
|
} |
|
if (!window.__gmStorageInstances) { |
|
window.__gmStorageInstances = []; |
|
} |
|
window.__gmStorageInstances.push(this); |
|
// --- End Global Patch --- |
|
|
|
|
|
// --- Initialization with Recursive Defaults --- |
|
const existingKeys = new Set(GM_listValues()); |
|
|
|
// 1. Process all default keys |
|
for (const key in defaults) { |
|
if (!defaults.hasOwnProperty(key)) continue; // Skip inherited properties |
|
|
|
const defaultValue = defaults[key]; |
|
if (existingKeys.has(key)) { |
|
// Key exists in storage, potentially merge |
|
let currentValue = GM_getValue(key); |
|
if (this._isObject(currentValue) && this._isObject(defaultValue)) { |
|
// Both are objects, attempt recursive merge |
|
const changed = this._applyDefaultsRecursively(currentValue, defaultValue); |
|
if (changed) { |
|
this._log(`GMStorage: Applied defaults recursively to key '${key}', updating storage.`); |
|
this._suppressGlobal = true; |
|
try { |
|
GM_setValue(key, currentValue); // Save merged value |
|
} catch (e) { |
|
this._log(`GMStorage: Error saving merged defaults for key '${key}'`, e); |
|
// If saving fails, maybe revert currentValue? For now, just log. |
|
// currentValue = GM_getValue(key); // Re-fetch original? Risky. |
|
} |
|
this._suppressGlobal = false; |
|
} |
|
// Use the (potentially merged) currentValue for the cache |
|
this._data[key] = this._createDeepProxy(currentValue, key); |
|
} else { |
|
// Key exists, but cannot merge (e.g., different types or not objects) |
|
// Load the existing value directly into the cache. |
|
this._data[key] = this._isObject(currentValue) |
|
? this._createDeepProxy(currentValue, key) |
|
: currentValue; |
|
} |
|
existingKeys.delete(key); // Mark as handled |
|
} else { |
|
// Key does not exist in storage, set it from default |
|
this._log(`GMStorage: Initializing key '${key}' from defaults.`); |
|
this._suppressGlobal = true; |
|
try { |
|
GM_setValue(key, defaultValue); |
|
} catch (e) { |
|
this._log(`GMStorage: Error saving initial default for key '${key}'`, e); |
|
} |
|
this._suppressGlobal = false; |
|
this._data[key] = this._isObject(defaultValue) |
|
? this._createDeepProxy(defaultValue, key) |
|
: defaultValue; |
|
} |
|
// Add listener regardless of whether it was new or merged |
|
this._addChangeListener(key); |
|
} |
|
|
|
// 2. Process remaining keys that were in storage but not in defaults |
|
for (const key of existingKeys) { // Iterate remaining keys in the set |
|
const value = GM_getValue(key); |
|
this._data[key] = this._isObject(value) |
|
? this._createDeepProxy(value, key) |
|
: value; |
|
this._addChangeListener(key); |
|
} |
|
// --- End Initialization --- |
|
|
|
|
|
// Return the proxy wrapper for the instance |
|
return new Proxy(this, { |
|
get: (target, prop) => { |
|
// Allow access to internal methods/properties starting with '_' |
|
if (typeof prop === 'string' && prop.startsWith('_')) { |
|
return target[prop]; |
|
} |
|
// Also allow access to standard class methods like addListener, listen, clearListeners |
|
if (prop in target && typeof target[prop] === 'function') { |
|
// Ensure 'this' context is correct when calling methods via proxy |
|
return target[prop].bind(target); |
|
} |
|
// Access data properties |
|
if (prop in target._data) { |
|
return target._data[prop]; |
|
} |
|
// Handle symbols like Symbol.toStringTag if needed, or return undefined for others |
|
if (typeof prop === 'symbol') { |
|
return target[prop]; |
|
} |
|
return undefined; // Default for properties not found |
|
}, |
|
set: (target, prop, value) => { |
|
// Disallow setting internal properties directly |
|
if (typeof prop === 'string' && prop.startsWith('_')) { |
|
console.warn(`GMStorage: Attempted to set internal property '${prop}'. This is not allowed.`); |
|
return false; // Indicate failure |
|
} |
|
const oldValue = target._data[prop]; // Get old value from cache |
|
|
|
// Prepare the new value (proxy if object) |
|
const newValue = target._isObject(value) |
|
? target._createDeepProxy(value, prop) |
|
: value; |
|
|
|
target._data[prop] = newValue; // Update cache first |
|
|
|
// Persist to GM storage |
|
target._suppressGlobal = true; // Prevent self-notification from global patch |
|
try { |
|
GM_setValue(prop, value); // Save the raw value, not the proxy |
|
} catch(e) { |
|
target._log(`GMStorage: Error setting key '${String(prop)}' in GM_setValue`, e); |
|
// Optionally revert cache change? |
|
// target._data[prop] = oldValue; // Revert? Might cause inconsistencies. |
|
target._suppressGlobal = false; |
|
return false; // Indicate set failure |
|
} |
|
target._suppressGlobal = false; |
|
|
|
target._log( |
|
`GMStorage: Key '${String(prop)}' set locally. Old:`, oldValue, "New:", newValue |
|
); |
|
|
|
// Notify internal listeners (awaiters and always) |
|
target._notifyAwaiters(prop, newValue); // Notify with the proxied value |
|
target._notifyAlwaysListeners(prop, oldValue, newValue, false); // Notify with old cached value and new proxied value |
|
|
|
// Ensure a GM listener exists for potential remote changes |
|
if (!target._listenerMap[prop]) { |
|
target._addChangeListener(prop); |
|
} |
|
return true; // Indicate success |
|
}, |
|
deleteProperty: (target, prop) => { |
|
if (typeof prop === 'string' && prop.startsWith('_')) { |
|
console.warn(`GMStorage: Attempted to delete internal property '${prop}'. This is not allowed.`); |
|
return false; |
|
} |
|
if (prop in target._data) { |
|
const oldValue = target._data[prop]; |
|
delete target._data[prop]; // Delete from cache |
|
|
|
target._suppressGlobal = true; |
|
try { |
|
GM_deleteValue(prop); // Assuming GM_deleteValue exists or use GM_setValue(prop, undefined) |
|
} catch (e) { |
|
// Handle environments without GM_deleteValue if necessary |
|
try { GM_setValue(prop, undefined); } catch (e2) { |
|
target._log(`GMStorage: Error deleting key '${String(prop)}'`, e, e2); |
|
// Revert cache deletion? |
|
// target._data[prop] = oldValue; |
|
target._suppressGlobal = false; |
|
return false; // Indicate failure |
|
} |
|
} |
|
target._suppressGlobal = false; |
|
|
|
target._log(`GMStorage: Key '${String(prop)}' deleted.`); |
|
|
|
// Notify listeners about the deletion (newValue is undefined) |
|
target._notifyAwaiters(prop, undefined); |
|
target._notifyAlwaysListeners(prop, oldValue, undefined, false); |
|
|
|
// Optionally remove the GM listener if no longer needed? |
|
// if (target._listenerMap[prop]) { |
|
// GM_removeValueChangeListener(target._listenerMap[prop]); |
|
// delete target._listenerMap[prop]; |
|
//} |
|
return true; // Indicate success |
|
} |
|
return false; // Property didn't exist |
|
}, |
|
ownKeys: (target) => { |
|
// Combine keys from internal properties/methods and data cache |
|
const internalKeys = Reflect.ownKeys(target).filter(k => typeof k === 'string' && k.startsWith('_')); |
|
const methodKeys = Object.getOwnPropertyNames(Object.getPrototypeOf(target)) |
|
.filter(name => name !== 'constructor' && typeof target[name] === 'function'); |
|
const dataKeys = Reflect.ownKeys(target._data); |
|
// Use a Set to avoid duplicates if methods/internals somehow clash with data keys |
|
return [...new Set([...internalKeys, ...methodKeys, ...dataKeys])]; |
|
}, |
|
getOwnPropertyDescriptor: (target, prop) => { |
|
if (prop in target._data) { |
|
// Descriptor for data properties |
|
return { |
|
configurable: true, |
|
enumerable: true, |
|
value: target._data[prop], |
|
writable: true // Assuming data properties should be writable |
|
}; |
|
} |
|
// Descriptor for internal methods/properties (use Reflect) |
|
if (Reflect.has(target, prop)) { |
|
return Reflect.getOwnPropertyDescriptor(target, prop); |
|
} |
|
// Check prototype for methods |
|
const proto = Object.getPrototypeOf(target); |
|
if (Reflect.has(proto, prop)) { |
|
return Reflect.getOwnPropertyDescriptor(proto, prop); |
|
} |
|
return undefined; // Property not found |
|
}, |
|
has: (target, prop) => { |
|
// Check internal properties/methods first, then data cache |
|
return Reflect.has(target, prop) || (prop in target._data); |
|
} |
|
}); |
|
} |
|
|
|
/** |
|
* Recursively applies default values to a current value object. |
|
* Only adds keys from defaultValue that are missing in currentValue. |
|
* Recurses if both currentValue[key] and defaultValue[key] are objects. |
|
* Modifies currentValue in place. |
|
* |
|
* @param {Object} currentValue The object to apply defaults to. |
|
* @param {Object} defaultValue The object containing default values. |
|
* @returns {boolean} True if any changes were made to currentValue, false otherwise. |
|
* @private |
|
*/ |
|
_applyDefaultsRecursively(currentValue, defaultValue) { |
|
// Ensure both are actual objects (not null, arrays maybe handled depending on _isObject) |
|
if (!this._isObject(currentValue) || !this._isObject(defaultValue)) { |
|
return false; |
|
} |
|
|
|
let changed = false; |
|
|
|
for (const key in defaultValue) { |
|
if (defaultValue.hasOwnProperty(key)) { |
|
const defaultPropValue = defaultValue[key]; |
|
|
|
if (!currentValue.hasOwnProperty(key)) { |
|
// Key is missing in current object, add it from defaults |
|
currentValue[key] = this._cloneDeep(defaultPropValue); // Clone default value to avoid shared references |
|
changed = true; |
|
} else { |
|
// Key exists in current object. Recurse only if both properties are objects. |
|
const currentPropValue = currentValue[key]; |
|
if (this._isObject(currentPropValue) && this._isObject(defaultPropValue)) { |
|
// Recurse. If the recursive call returns true, it means a change was made deeper down. |
|
if (this._applyDefaultsRecursively(currentPropValue, defaultPropValue)) { |
|
changed = true; // Propagate change flag upwards |
|
} |
|
} |
|
// Otherwise (key exists, but not both objects, or primitive types), keep the current value. No change needed for this key. |
|
} |
|
} |
|
} |
|
return changed; // Return true if any changes were made at this level or below |
|
} |
|
|
|
|
|
/** |
|
* Returns a deep copy of a JSON-serializable object. |
|
* Handles potential circular references or non-serializable values gracefully by returning the original object on error. |
|
* @param {*} obj The object to clone. |
|
* @returns {*} A deep copy, or the original object if cloning fails. |
|
* @private |
|
*/ |
|
_cloneDeep(obj) { |
|
// Optimization: If it's not an object, it doesn't need cloning in this context. |
|
if (!this._isObject(obj)) { |
|
return obj; |
|
} |
|
try { |
|
// Basic deep clone using JSON methods. Sufficient for GM storage which requires JSON-serializable data. |
|
return JSON.parse(JSON.stringify(obj)); |
|
} catch (e) { |
|
this._log("GMStorage: _cloneDeep failed, returning original object.", e, obj); |
|
// Return the original object reference if cloning fails (e.g., circular references, functions, etc.) |
|
// This might lead to unintended shared references if the default object is complex and non-JSON-safe. |
|
return obj; |
|
} |
|
} |
|
|
|
/** |
|
* Checks whether a value is a plain object (and not null or an array). |
|
* @param {*} val The value to check. |
|
* @returns {boolean} True if 'val' is a plain object. |
|
* @private |
|
*/ |
|
_isObject(val) { |
|
// return val !== null && typeof val === 'object'; // Original version (includes arrays, etc.) |
|
return Object.prototype.toString.call(val) === '[object Object]'; // Stricter: only plain objects |
|
} |
|
|
|
/** |
|
* Creates a deep proxy for an object so that nested updates are persisted. |
|
* @param {Object} obj The object to wrap. |
|
* @param {string} topKey The corresponding top-level storage key. |
|
* @returns {Proxy} A deep proxy of the object. |
|
* @private |
|
*/ |
|
_createDeepProxy(obj, topKey) { |
|
const self = this; |
|
// Ensure the object passed is actually an object |
|
if (!this._isObject(obj)) { |
|
this._log(`GMStorage Warning: _createDeepProxy called with non-object for key '${topKey}'. Value:`, obj); |
|
return obj; // Return the non-object value as is |
|
} |
|
|
|
return new Proxy(obj, { |
|
get(target, prop, receiver) { |
|
// Optimization: Return early for symbols like Symbol.toStringTag |
|
if (typeof prop === 'symbol') { |
|
return Reflect.get(target, prop, receiver); |
|
} |
|
|
|
const value = Reflect.get(target, prop, receiver); // Use Reflect for better handling of getters/prototype chain |
|
|
|
// If the retrieved value is an object, wrap it in a proxy recursively. |
|
// Check if the value already has a proxy marker to prevent infinite loops (optional, maybe overkill) |
|
if (self._isObject(value)) { // && !value.__isGMStorageProxy) { |
|
// Object.defineProperty(value, '__isGMStorageProxy', { value: true, enumerable: false, configurable: true }); // Add marker |
|
return self._createDeepProxy(value, topKey); // Recursive call |
|
} |
|
return value; |
|
}, |
|
set(target, prop, value, receiver) { |
|
// Get the root object from the cache using topKey |
|
const rootObj = self._data[topKey]; |
|
if (!rootObj) { |
|
console.error(`GMStorage Error: Cannot find root object for key '${topKey}' during deep set.`); |
|
return false; // Indicate failure |
|
} |
|
|
|
// Clone the current root object *before* making changes to get an accurate "oldValue" for listeners. |
|
// Crucially, clone the *root* object, not just the nested target. |
|
const oldRootClone = self._cloneDeep(rootObj); |
|
const oldValueAtProp = Reflect.get(target, prop, receiver); // Get old value at the specific property being set |
|
|
|
// Apply the change to the target object (which is part of the nested structure within rootObj) |
|
const success = Reflect.set(target, prop, value, receiver); // Use Reflect.set |
|
|
|
if (success) { |
|
// If the set was successful, persist the entire modified root object. |
|
self._suppressGlobal = true; // Prevent self-notification from global patch |
|
try { |
|
GM_setValue(topKey, rootObj); // Save the *entire modified root object* |
|
} catch (e) { |
|
self._log(`GMStorage Error: Failed to save deep update for key '${topKey}'`, e); |
|
// Optionally revert the change in the target object? Complex. |
|
self._suppressGlobal = false; |
|
return false; // Indicate failure |
|
} |
|
self._suppressGlobal = false; |
|
|
|
self._log( |
|
`GMStorage [deep]: Key '${topKey}', property '${String(prop)}' set. Old value at prop:`, oldValueAtProp, "New value:", value |
|
); |
|
|
|
// Notify listeners with the updated ROOT object |
|
self._notifyAwaiters(topKey, rootObj); |
|
// Notify always listeners with the cloned OLD ROOT and the current ROOT |
|
self._notifyAlwaysListeners(topKey, oldRootClone, rootObj, false); |
|
} |
|
return success; |
|
}, |
|
deleteProperty(target, prop) { |
|
// Get the root object from the cache |
|
const rootObj = self._data[topKey]; |
|
if (!rootObj) { |
|
console.error(`GMStorage Error: Cannot find root object for key '${topKey}' during deep delete.`); |
|
return false; // Indicate failure |
|
} |
|
// Check if the property actually exists before proceeding |
|
if (!Reflect.has(target, prop)) { |
|
return true; // Property doesn't exist, delete operation is trivially successful |
|
} |
|
|
|
// Clone the root object *before* deletion for accurate "oldValue". |
|
const oldRootClone = self._cloneDeep(rootObj); |
|
|
|
// Perform the deletion on the target object |
|
const success = Reflect.deleteProperty(target, prop); |
|
|
|
if (success) { |
|
// Persist the change by saving the modified root object. |
|
self._suppressGlobal = true; |
|
try { |
|
GM_setValue(topKey, rootObj); |
|
} catch (e) { |
|
self._log(`GMStorage Error: Failed to save deep delete for key '${topKey}'`, e); |
|
// Optionally revert? Complex. |
|
self._suppressGlobal = false; |
|
return false; // Indicate failure |
|
} |
|
self._suppressGlobal = false; |
|
|
|
self._log( |
|
`GMStorage [deep]: Key '${topKey}', property '${String(prop)}' deleted.` |
|
); |
|
|
|
// Notify listeners with the updated ROOT object. |
|
self._notifyAwaiters(topKey, rootObj); |
|
// Notify always listeners with the cloned OLD ROOT and the current ROOT. |
|
self._notifyAlwaysListeners(topKey, oldRootClone, rootObj, false); |
|
} |
|
return success; |
|
}, |
|
// Add ownKeys and getOwnPropertyDescriptor for better compatibility if needed, |
|
// especially if iterating over proxied objects. |
|
ownKeys(target) { |
|
return Reflect.ownKeys(target); |
|
}, |
|
getOwnPropertyDescriptor(target, prop) { |
|
return Reflect.getOwnPropertyDescriptor(target, prop); |
|
}, |
|
has(target, prop) { |
|
return Reflect.has(target, prop); |
|
} |
|
}); |
|
} |
|
|
|
/** |
|
* Internal handler for processing updates received from GM_addValueChangeListener |
|
* or the patched global GM_setValue. |
|
* @param {string} key The storage key that changed. |
|
* @param {*} oldValueGM The old value provided by the GM listener or global patch. |
|
* @param {*} newValueGM The new value provided by the GM listener or global patch. |
|
* @param {boolean} remote True if the change came from GM_addValueChangeListener's `remote` flag. |
|
* @private |
|
*/ |
|
_handleRemoteUpdate(key, oldValueGM, newValueGM, remote) { |
|
// oldValueGM from GM might not accurately reflect the state before a deep nested change if |
|
// only GM_setValue was used externally on the root key. |
|
// We'll use our cached `this._data[key]` as the more reliable 'previous' state for listeners. |
|
const prevCachedValue = this._data[key]; |
|
|
|
// Determine the new value, creating a proxy if it's an object. |
|
const newValue = this._isObject(newValueGM) |
|
? this._createDeepProxy(newValueGM, key) // Create proxy for the new incoming object |
|
: newValueGM; |
|
|
|
// Update the local cache *after* potentially creating the proxy. |
|
this._data[key] = newValue; |
|
|
|
this._log( |
|
`GMStorage (${remote ? 'remote' : 'external'}): Key '${key}' changed.`, |
|
"Previous cached value:", prevCachedValue, // Log our cached value |
|
"New value:", newValue // Log the processed new value |
|
// Optionally log the values from GM for comparison: |
|
// "GM Old:", oldValueGM, |
|
// "GM New:", newValueGM |
|
); |
|
|
|
// Notify awaiters and always listeners. |
|
this._notifyAwaiters(key, newValue); |
|
// Crucially, pass the *previous cached value* as the `oldValue` argument to listeners, |
|
// as it's likely more accurate than `oldValueGM` for deep objects. |
|
this._notifyAlwaysListeners(key, prevCachedValue, newValue, remote); |
|
} |
|
|
|
|
|
/** |
|
* Adds a GM storage change listener for remote/external changes. |
|
* @param {string} key The storage key. |
|
* @private |
|
*/ |
|
_addChangeListener(key) { |
|
if (this._listenerMap[key]) return; // Don't add multiple listeners for the same key |
|
|
|
try { |
|
const listenerId = GM_addValueChangeListener(key, (name, oldValueGM, newValueGM, remote) => { |
|
// This callback handles *only truly remote* changes (from other tabs/scripts using GM API directly). |
|
// Changes from the *patched* GM_setValue in the same tab are handled by the patch itself. |
|
if (remote) { |
|
this._handleRemoteUpdate(name, oldValueGM, newValueGM, true); // true for remote |
|
} |
|
// We don't need to handle non-remote (!remote) changes here, because: |
|
// 1. If the change originated from *this* instance's proxy setters, _suppressGlobal prevents the global patch notification. |
|
// 2. If the change originated from an *external* GM_setValue call in the *same* tab/window context, |
|
// the global patch already called _handleRemoteUpdate with remote=false. |
|
// Processing non-remote events here would lead to double notifications for external same-tab sets. |
|
}); |
|
this._listenerMap[key] = listenerId; |
|
} catch (e) { |
|
this._log(`GMStorage Error: Failed to add GM value change listener for key '${key}'`, e); |
|
} |
|
} |
|
|
|
/** |
|
* Notifies all pending one-shot listeners waiting for the next update on a key. |
|
* @param {string} key The storage key. |
|
* @param {*} newValue The new value for the key (potentially proxied). |
|
* @private |
|
*/ |
|
_notifyAwaiters(key, newValue) { |
|
if (this._pendingListeners[key] && this._pendingListeners[key].length > 0) { |
|
this._log(`GMStorage: Notifying ${this._pendingListeners[key].length} awaiter(s) for key '${key}'`); |
|
// Resolve with a deep clone to prevent listeners from modifying the shared cached object? |
|
// const valueForListener = this._cloneDeep(newValue); // Consider implications |
|
for (const resolve of this._pendingListeners[key]) { |
|
try { |
|
resolve(newValue); // Pass the cached (potentially proxied) value |
|
} catch (e) { |
|
console.error(`GMStorage: Error resolving awaiter promise for key '${key}'`, e); |
|
} |
|
} |
|
this._pendingListeners[key] = []; // Clear listeners after resolving |
|
} |
|
} |
|
|
|
/** |
|
* Notifies all persistent listeners (registered via addListener) for a given key. |
|
* @param {string} key The storage key. |
|
* @param {*} oldValue The previous value (ideally from cache or clone before change). |
|
* @param {*} newValue The new value (potentially proxied). |
|
* @param {boolean} remote True if the change originated remotely. |
|
* @private |
|
*/ |
|
_notifyAlwaysListeners(key, oldValue, newValue, remote) { |
|
if (this._alwaysListeners[key] && this._alwaysListeners[key].length > 0) { |
|
this._log(`GMStorage: Notifying ${this._alwaysListeners[key].length} always listener(s) for key '${key}' (remote: ${remote})`); |
|
// Provide clones to listeners to prevent them from modifying the internal cache? |
|
// const oldClone = this._cloneDeep(oldValue); |
|
// const newClone = this._cloneDeep(newValue); |
|
for (const cb of this._alwaysListeners[key]) { |
|
try { |
|
// Pass the old and new values (potentially proxies) and the remote flag |
|
cb(key, oldValue, newValue, remote); |
|
} catch(e) { |
|
console.error(`GMStorage: Error in alwaysListener callback for key '${key}':`, e); |
|
} |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Returns a promise that resolves on the next update for the specified key. |
|
* @param {string} key The storage key. |
|
* @returns {Promise<*>} A promise that resolves with the new value. |
|
* @private |
|
*/ |
|
_listenOnce(key) { |
|
return new Promise((resolve) => { |
|
if (!this._pendingListeners[key]) { |
|
this._pendingListeners[key] = []; |
|
} |
|
this._pendingListeners[key].push(resolve); |
|
this._log(`GMStorage: Awaiter added for next update of key '${key}'`); |
|
}); |
|
} |
|
|
|
// --- Public API Methods --- |
|
|
|
/** |
|
* Adds a persistent listener callback for a given key. |
|
* The callback is triggered every time the value associated with the key is |
|
* updated, either locally, remotely, or via external GM_setValue calls. |
|
* The callback receives (key, oldValue, newValue, remote). |
|
* Note: `oldValue` is the value before the update (based on internal cache or clones), |
|
* `newValue` is the value after the update (potentially a proxy for objects). |
|
* |
|
* @param {string} key The storage key to listen to. |
|
* @param {Function} callback A function with signature `(key: string, oldValue: any, newValue: any, remote: boolean) => void`. |
|
*/ |
|
addListener(key, callback) { |
|
if (typeof callback !== 'function') { |
|
console.error(`GMStorage: addListener requires a function callback for key '${key}'.`); |
|
return; |
|
} |
|
if (!this._alwaysListeners[key]) { |
|
this._alwaysListeners[key] = []; |
|
} |
|
// Avoid adding the exact same callback multiple times |
|
if (!this._alwaysListeners[key].includes(callback)) { |
|
this._alwaysListeners[key].push(callback); |
|
this._log(`GMStorage: Added always listener for key '${key}'`); |
|
} else { |
|
this._log(`GMStorage: Listener already exists for key '${key}', not adding again.`); |
|
} |
|
} |
|
|
|
/** |
|
* Removes a previously added persistent listener callback for a specific key. |
|
* |
|
* @param {string} key The storage key the listener was attached to. |
|
* @param {Function} callback The exact callback function that was previously passed to addListener. |
|
* @returns {boolean} True if a listener was found and removed, false otherwise. |
|
*/ |
|
removeListener(key, callback) { |
|
if (this._alwaysListeners[key]) { |
|
const index = this._alwaysListeners[key].indexOf(callback); |
|
if (index > -1) { |
|
this._alwaysListeners[key].splice(index, 1); |
|
this._log(`GMStorage: Removed always listener for key '${key}'`); |
|
// Clean up the array if it becomes empty |
|
if (this._alwaysListeners[key].length === 0) { |
|
delete this._alwaysListeners[key]; |
|
} |
|
return true; |
|
} |
|
} |
|
this._log(`GMStorage: No matching always listener found to remove for key '${key}'`); |
|
return false; |
|
} |
|
|
|
/** |
|
* Returns the raw, non-proxied value for a given key from the storage. |
|
* This is a deep clone of the stored data to strip out any proxy wrappers. |
|
* |
|
* @param {string} key - The storage key. |
|
* @returns {*} The raw (non-proxied) value. |
|
*/ |
|
getRaw(key) { |
|
// If the stored value is an object, clone it to remove proxy wrappers. |
|
// Otherwise, just return the value directly. |
|
return this._isObject(this._data[key]) ? this._cloneDeep(this._data[key]) : this._data[key]; |
|
} |
|
|
|
/** |
|
* Returns an asynchronous listener object for a specific key. |
|
* This object can be awaited directly to get the *next* update (like `_listenOnce`), |
|
* or used in a `for await...of` loop to continuously receive all subsequent updates. |
|
* |
|
* Usage: |
|
* // One-shot await: |
|
* const nextValue = await storage.listen('some_key'); |
|
* |
|
* // Continuous iteration: |
|
* const listener = storage.listen('some_key'); |
|
* for await (const updatedValue of listener) { |
|
* console.log("Key updated:", updatedValue); |
|
* if (updatedValue.someCondition) break; // Stop listening |
|
* } |
|
* // Note: The loop will run indefinitely unless broken out of. |
|
* |
|
* @param {string} key The storage key to listen for updates. |
|
* @returns {AsyncListener} An object that is both thenable (for single await) and async iterable (for `for await`). |
|
*/ |
|
listen(key) { |
|
// Ensure the key is being monitored by GM |
|
if (!this._listenerMap[key] && !(key in this._data)) { |
|
// If the key isn't in our cache yet (maybe never set), |
|
// we might still want to listen for future remote sets. Add a listener. |
|
this._addChangeListener(key); |
|
} else if (!this._listenerMap[key] && key in this._data) { |
|
// Key is known, but somehow listener wasn't added (shouldn't happen with current logic, but be safe) |
|
this._addChangeListener(key); |
|
} |
|
return new AsyncListener(this, key); |
|
} |
|
|
|
/** |
|
* Clears *all* GM_addValueChangeListeners added by *this specific instance*. |
|
* Also clears internal pending awaiters and persistent listener callbacks. |
|
* This instance will stop receiving remote updates after this is called. |
|
* Note: This does NOT remove the global GM_setValue patch or affect other instances. |
|
*/ |
|
clearListeners() { |
|
this._log("GMStorage: Clearing all listeners for this instance."); |
|
// Remove GM listeners |
|
for (const key in this._listenerMap) { |
|
try { |
|
GM_removeValueChangeListener(this._listenerMap[key]); |
|
} catch (e) { |
|
this._log(`GMStorage Error: Failed to remove GM listener for key '${key}' (ID: ${this._listenerMap[key]})`, e); |
|
} |
|
delete this._listenerMap[key]; |
|
} |
|
|
|
// Clear pending promises (reject them?) |
|
for (const key in this._pendingListeners) { |
|
// Maybe reject promises instead of leaving them hanging? |
|
// for (const resolve of this._pendingListeners[key]) { /* Can't reject resolve */ } |
|
delete this._pendingListeners[key]; // Just clear the array |
|
} |
|
this._pendingListeners = {}; // Reset the main object |
|
|
|
// Clear persistent callbacks |
|
this._alwaysListeners = {}; |
|
} |
|
|
|
/** |
|
* Removes this instance from the global list used by the GM_setValue patch. |
|
* Call this when the storage instance is no longer needed to prevent memory leaks |
|
* and unnecessary processing in the global patch. Also calls `clearListeners`. |
|
*/ |
|
dispose() { |
|
this._log("GMStorage: Disposing instance."); |
|
this.clearListeners(); // Clear GM listeners and internal callbacks |
|
|
|
// Remove from global instance list |
|
if (window.__gmStorageInstances) { |
|
const index = window.__gmStorageInstances.indexOf(this); |
|
if (index > -1) { |
|
window.__gmStorageInstances.splice(index, 1); |
|
this._log("GMStorage: Removed instance from global list."); |
|
} |
|
} |
|
// Optionally, try to nullify internal state? |
|
this._data = {}; |
|
// Proxies make full cleanup hard, but this reduces footprint. |
|
} |
|
} |
|
|
|
// --- AsyncListener Helper Class (same as before) --- |
|
/** |
|
* AsyncListener class: Enables both promise-based (await) and async iterator usage |
|
* for listening to GMStorage updates. |
|
*/ |
|
class AsyncListener { |
|
/** |
|
* @param {GMStorage} storage The GMStorage instance. |
|
* @param {string} key The storage key to listen for. |
|
*/ |
|
constructor(storage, key) { |
|
this.storage = storage; |
|
this.key = key; |
|
this._iteratorActive = false; // Flag to prevent multiple concurrent iterations |
|
} |
|
|
|
/** |
|
* Thenable implementation for `await storage.listen('key')`. |
|
* Resolves with the *next* value update for the key. |
|
* @param {Function} resolve Resolve callback. |
|
* @param {Function} reject Reject callback. |
|
* @returns {Promise<*>} A promise that resolves with the next update value. |
|
*/ |
|
then(resolve, reject) { |
|
// Ensure 'this' context is correct when _listenOnce is called |
|
return this.storage._listenOnce(this.key).then(resolve, reject); |
|
} |
|
|
|
/** |
|
* Async iterator implementation for `for await (const value of storage.listen('key'))`. |
|
* Continuously yields successive update values for the key. |
|
* @returns {Object} An async iterator. |
|
*/ |
|
[Symbol.asyncIterator]() { |
|
// Prevent multiple simultaneous iterations on the same listener object |
|
if (this._iteratorActive) { |
|
throw new Error("GMStorage: Async iterator already in use for this listener instance."); |
|
} |
|
this._iteratorActive = true; |
|
|
|
const storage = this.storage; |
|
const key = this.key; |
|
const self = this; // Reference to this AsyncListener instance |
|
|
|
return { |
|
async next() { |
|
// Use _listenOnce to wait for the next update |
|
try { |
|
const value = await storage._listenOnce(key); |
|
// Check if the storage instance or key still seems valid (basic check) |
|
if (!storage || !(key in storage._data) && !(key in storage._pendingListeners) && !(key in storage._alwaysListeners)) { |
|
// If the key seems to have disappeared or storage disposed, end iteration |
|
self._iteratorActive = false; // Release lock |
|
return { value: undefined, done: true }; |
|
} |
|
return { value: value, done: false }; // Yield the value |
|
} catch (error) { |
|
// If _listenOnce somehow rejects (e.g., if storage is disposed and promises are rejected) |
|
console.error(`GMStorage: Error during async iteration for key '${key}'`, error); |
|
self._iteratorActive = false; // Release lock |
|
return { value: undefined, done: true }; // End iteration on error |
|
} |
|
}, |
|
// Optional: Implement a 'return' method to clean up when the loop breaks/exits early |
|
async return() { |
|
storage._log(`GMStorage: Async iterator for key '${key}' is ending.`); |
|
self._iteratorActive = false; // Release the lock |
|
// We don't need to explicitly remove listeners here, as _listenOnce only registers |
|
// temporary awaiters. Persistent listeners aren't tied to the iterator lifecycle. |
|
return { done: true }; |
|
}, |
|
// Optional: Implement 'throw' method for error propagation |
|
async throw(error) { |
|
console.error(`GMStorage: Error thrown into async iterator for key '${key}'`, error); |
|
self._iteratorActive = false; // Release the lock |
|
throw error; // Re-throw the error |
|
} |
|
}; |
|
} |
|
} |
|
|
|
// Example Usage (optional, for testing) |
|
/* |
|
(function() { |
|
'use strict'; |
|
|
|
const initialDefaults = { |
|
count: 0, |
|
user: { name: 'Guest', loggedIn: false, prefs: { theme: 'light' } }, |
|
settings: { notifications: true, volume: 50 }, |
|
lastVisit: null |
|
}; |
|
|
|
console.log("GMStorage Initializing..."); |
|
// Simulate existing data |
|
// GM_setValue('settings', { volume: 75, experimental: true }); |
|
// GM_setValue('user', { name: 'Alice', loggedIn: true }); |
|
|
|
|
|
const storage = new GMStorage(initialDefaults, true); // Enable debugging |
|
|
|
console.log("Initial storage state:", JSON.stringify({ |
|
count: storage.count, |
|
user: storage.user, |
|
settings: storage.settings, |
|
lastVisit: storage.lastVisit |
|
}, null, 2)); |
|
|
|
// --- Test Listeners --- |
|
storage.addListener('count', (key, oldVal, newVal, remote) => { |
|
console.log(`ALWAYS LISTENER: Count changed (Remote: ${remote})`, oldVal, '->', newVal); |
|
}); |
|
|
|
storage.addListener('user', (key, oldVal, newVal, remote) => { |
|
console.log(`ALWAYS LISTENER: User changed (Remote: ${remote})`, JSON.stringify(oldVal), '->', JSON.stringify(newVal)); |
|
}); |
|
|
|
// Test async iterator |
|
async function watchSettings() { |
|
console.log("Starting settings watch (for await)..."); |
|
try { |
|
for await (const settings of storage.listen('settings')) { |
|
console.log("FOR AWAIT: Settings updated:", JSON.stringify(settings)); |
|
// Example exit condition |
|
if (settings.volume > 90) { |
|
console.log("FOR AWAIT: Volume > 90, stopping watch."); |
|
break; |
|
} |
|
} |
|
} catch (e) { |
|
console.error("Settings watch loop error:", e); |
|
} finally { |
|
console.log("Settings watch loop finished."); |
|
} |
|
} |
|
// watchSettings(); // Uncomment to run the async iterator test |
|
|
|
// Test one-shot await |
|
async function getNextVisit() { |
|
console.log("Waiting for next 'lastVisit' update (await)..."); |
|
const nextVisit = await storage.listen('lastVisit'); |
|
console.log("AWAIT: Got next lastVisit:", nextVisit); |
|
} |
|
// getNextVisit(); // Uncomment to run the one-shot await test |
|
|
|
|
|
// --- Test Modifications --- |
|
console.log("--- Making changes ---"); |
|
|
|
// Simple set |
|
setTimeout(() => { |
|
console.log("Setting count = 1"); |
|
storage.count = 1; // Should trigger count listener |
|
}, 1000); |
|
|
|
// Set same value (should still trigger always listener) |
|
setTimeout(() => { |
|
console.log("Setting count = 1 (again)"); |
|
storage.count = 1; // Should trigger count listener again |
|
}, 2000); |
|
|
|
|
|
// Deep object modification |
|
setTimeout(() => { |
|
console.log("Setting user.prefs.theme = 'dark'"); |
|
if (storage.user && storage.user.prefs) { |
|
storage.user.prefs.theme = 'dark'; // Should trigger user listener |
|
} else { console.log("Cannot set theme, user/prefs missing"); } |
|
}, 3000); |
|
|
|
// Replace whole object |
|
setTimeout(() => { |
|
console.log("Setting user = { name: 'Bob', loggedIn: true }"); |
|
storage.user = { name: 'Bob', loggedIn: true }; // Should trigger user listener (no prefs now) |
|
}, 4000); |
|
|
|
// Modify settings (for the async iterator) |
|
setTimeout(() => { |
|
console.log("Setting settings.volume = 80"); |
|
if (storage.settings) { |
|
storage.settings.volume = 80; |
|
} |
|
}, 5000); |
|
|
|
// Set lastVisit (for the one-shot await) |
|
setTimeout(() => { |
|
console.log("Setting lastVisit = Date.now()"); |
|
storage.lastVisit = Date.now(); |
|
}, 6000); |
|
|
|
// Modify settings again (to potentially stop the iterator) |
|
setTimeout(() => { |
|
console.log("Setting settings.volume = 95"); |
|
if (storage.settings) { |
|
storage.settings.volume = 95; |
|
} |
|
}, 7000); |
|
|
|
// Simulate external set using global GM_setValue (triggers global patch) |
|
setTimeout(() => { |
|
console.log("Simulating external GM_setValue('count', 100)"); |
|
GM_setValue('count', 100); // Use the (potentially patched) global function |
|
}, 8000); |
|
|
|
// Test dispose |
|
// setTimeout(() => { |
|
// console.log("Disposing storage instance"); |
|
// storage.dispose(); |
|
// // Further changes should not trigger listeners of the disposed instance |
|
// // External sets might still trigger listeners of *other* instances if they exist. |
|
// GM_setValue('count', 500); |
|
// }, 9000); |
|
|
|
|
|
})(); |
|
*/ |