Search code examples
javascriptgoogle-chrome-extensionsafari-extensionsafari6

Is there a simple conversion method for converting chrome message passing to the safari message passing syntax?


My Chrome extension uses message passing to retrieve various values from the extension's built in localstorage area on the background page.

The thing I love about chrome message passing, is it lets you include a callback function inside the sendMessage call, like so:

chrome.runtime.sendMessage({greeting: "hello"}, function(response) {
    console.log(response.farewell);   
});

and the corresponding message receiving code would look like the following (example code from the Chrome extensions documentation):

chrome.runtime.onMessage.addListener(
  function(request, sender, sendResponse) {
    console.log(sender.tab ?
                "from a content script:" + sender.tab.url :
                "from the extension");
    if (request.greeting == "hello")
      sendResponse({farewell: "goodbye"});
  });

I'm trying to convert my extension to the Safari extension format, but I can't figure out how to map the Chrome sendMessage / onMessage functions to Safari's safari.self.tab.dispatchMessage(name, data) message handling functions

Is it even possible to include callback functions in Safari's dispatchMessage function call? If not, how should I work around this limitation?


Solution

  • In environments that only provide a one-way message passing system, you can always implement "message-with-callback" yourself, by associating an unique ID with every message, and storing the callbacks in a dictionary that maps these IDs to the callbacks.

    At the end of this answer, I've copy-pasted the Safari-specific storage module from the source code of my cross-browser Lyrics Here extension, which provides the following API:

    • config.getItem(key, callback)
    • config.setItem(key, value, callback)
    • config.removeItem(key, callback)
    • config.clear(callback)

    The callback of getItem contains the value associated with the key (if found). The other callbacks receive a boolean that tells whether the operation succeeded or not.

    The source code is annotated and contains snippets to handle a few edge-cases. Feel free to shoot a few questions at me if there's anything unclear.

    config-safari.js (AMD)

    // Adapter for maintaining preferences (Safari 5+)
    // All methods are ASYNCHRONOUS
    define('config-safari', function() {
        var config = {};
        var callbacks = {};
        ['getItem', 'setItem', 'removeItem', 'clear'].forEach(function(methodName) {
            config[methodName] = function() {
                var args = [].slice.call(arguments);
                var callback = args.pop();
                var messageID = Math.random();
                callbacks[messageID] = callback;
                
                var message = {
                    type: methodName,
                    messageID: messageID,
                    args: args
                };
                safari.self.tab.dispatchMessage('config-request', message);
            };
        });
        config.init = function() {
            if (typeof safari === 'undefined') {
                // Safari bug: safari is undefined when current context is an iframe
                // and src="javascript:''"
                // This error is only expected on YouTube.
                // config.getItem is triggered in main, so we just redefine
                // it. Don't overwrite setItem, etc., so that errors are thrown
                // when these methods are used.
                config.getItem = function(key, callback){
                    callback();
                };
                return;
            }
            safari.self.addEventListener('message', function(event) {
                if (event.name === 'config-reply') {
                    var messageID = event.message.messageID;
                    var callback = callbacks[messageID];
                    // Check if callback exists. It may not exist when the script is
                    // activated in multiple frames, because every frame receives the message
                    if (callback) {
                        delete callbacks[messageID];
                        callback(event.message.result);
                    }
                }
            }, true);
        };
        return config;
    });
    

    Fragment of global.html:

    <script>
    (function(exports) {
        var config = {};
        config.getItem = function(key, callback) {
            var result = safari.extension.settings.getItem(key);
            if (typeof result === 'string') {
                try {
                    result = JSON.parse(result);
                } catch (e) {
                    // Extremely unlikely to happen, but don't neglect the possibility
                    console.log('config.getItem error: ' + e);
                    result = undefined;
                }
            }
            callback(result);
        };
        // callback's argument: true on success, false otherwise
        config.setItem = function(key, value, callback) {
            var success = false;
            try {
                value = JSON.stringify(value);
                // Safari (5.1.5) does not enforce the database quota,
                // let's enforce it manually (ok, set the quota per key, since
                // the performance issue only occur when a specific key has an outrageous high value)
                // 1 MB should be sufficient.
                if (value.length > 1e6) {
                    throw new Error('QUOTA_EXCEEDED_ERR: length=' + value.length);
                }
                safari.extension.settings.setItem(key, value);
                success = true;
            } catch (e) {
                console.log('config.setItem error: ' + e);
            }
            callback(success);
        };
        // callback's argument: true on success, false otherwise
        config.removeItem = function(key, callback) {
            safari.extension.settings.removeItem(key);
            callback(true);
        };
        // callback's argument: true on success, false otherwise
        config.clear = function(callback) {
            safari.extension.settings.clear();
            callback(true);
        };
    
        // config's message handler
        function handleConfigRequest(event) {
            var args = event.message.args;
            // Last argument: Always a callback
            args.push(function(result) {
                // Note: All of the config methods send a callback only once
                // Behavior for calling the callback twice is undefined.
                
                // Send a reply to trigger the callback at the sender's end
                event.target.page.dispatchMessage('config-reply', {
                    messageID: event.message.messageID,
                    result: result
                });
            });
            config[event.message.type].apply(config, args);
        }
        
        // Export
        exports.handleConfigRequest = handleConfigRequest;
    })(window);
    </script>
    <script>
    safari.application.addEventListener('message', function(event) {
        switch (event.name) {
            case 'config-request':
                handleConfigRequest(event);
                break;
            /* ... other things removed ... */
        }
    }, true);
    </script>