diff options
| author | Phil Crosby | 2014-08-27 00:50:00 -0700 | 
|---|---|---|
| committer | Phil Crosby | 2014-08-27 00:50:00 -0700 | 
| commit | 3e393b2143ca82796a923b5fe050eb688d1b85a6 (patch) | |
| tree | 2d79e0028cc1ba8eab7205a9fd5f6c2cef0cdae5 | |
| parent | 43caeb82d91e7cb88b085cb6e33a61a108322875 (diff) | |
| parent | b599492ea27c8c6bab38e87ef343968f5fcf58e7 (diff) | |
| download | vimium-3e393b2143ca82796a923b5fe050eb688d1b85a6.tar.bz2 | |
Merge pull request #1143 from smblott-github/passkeys
Passkeys: Pass some keys to the underlying web page without wholly disabling Vimium.
| -rw-r--r-- | background_scripts/main.coffee | 114 | ||||
| -rw-r--r-- | background_scripts/settings.coffee | 4 | ||||
| -rw-r--r-- | content_scripts/vimium_frontend.coffee | 78 | ||||
| -rw-r--r-- | icons/browser_action_partial.png | bin | 0 -> 34384 bytes | |||
| -rw-r--r-- | icons/icon48partial.png | bin | 0 -> 3815 bytes | |||
| -rw-r--r-- | pages/options.html | 25 | ||||
| -rw-r--r-- | pages/popup.coffee | 19 | ||||
| -rw-r--r-- | pages/popup.html | 6 | ||||
| -rw-r--r-- | tests/unit_tests/exclusion_test.coffee | 71 | ||||
| -rw-r--r-- | tests/unit_tests/test_chrome_stubs.coffee | 43 | 
10 files changed, 279 insertions, 81 deletions
| diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee index 431d9a31..dda1beae 100644 --- a/background_scripts/main.coffee +++ b/background_scripts/main.coffee @@ -69,27 +69,58 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) ->  getCurrentTabUrl = (request, sender) -> sender.tab.url  # -# Checks the user's preferences in local storage to determine if Vimium is enabled for the given URL. +# Checks the user's preferences in local storage to determine if Vimium is enabled for the given URL, and +# whether any keys should be passed through to the underlying page.  # -isEnabledForUrl = (request) -> -  # excludedUrls are stored as a series of URL expressions separated by newlines. -  excludedUrls = Settings.get("excludedUrls").split("\n") -  isEnabled = true -  for url in excludedUrls +root.isEnabledForUrl = isEnabledForUrl = (request) -> +  # Excluded URLs are stored as a series of URL expressions and optional passKeys, separated by newlines. +  # Lines for which the first non-blank character is "#" or '"' are comments. +  excludedLines = (line.trim() for line in Settings.get("excludedUrls").split("\n")) +  excludedSpecs = (line.split(/\s+/) for line in excludedLines when line and line.indexOf("#") != 0 and line.indexOf('"') != 0) +  for spec in excludedSpecs +    url = spec[0]      # The user can add "*" to the URL which means ".*"      regexp = new RegExp("^" + url.replace(/\*/g, ".*") + "$") -    isEnabled = false if request.url.match(regexp) -  { isEnabledForUrl: isEnabled } - -# Called by the popup UI. Strips leading/trailing whitespace and ignores empty strings. +    if request.url.match(regexp) +      passKeys = spec[1..].join("") +      if passKeys +        # Enabled, but not for these keys. +        return { isEnabledForUrl: true, passKeys: passKeys, matchingUrl: url } +      # Wholly disabled. +      return { isEnabledForUrl: false, passKeys: "", matchingUrl: url } +  # Enabled (the default). +  { isEnabledForUrl: true, passKeys: undefined, matchingUrl: undefined } + +# Called by the popup UI. Strips leading/trailing whitespace and ignores new empty strings.  If an existing +# exclusion rule has been changed, then the existing rule is updated.  Otherwise, the new rule is added.  root.addExcludedUrl = (url) ->    return unless url = url.trim() -  excludedUrls = Settings.get("excludedUrls") -  return if excludedUrls.indexOf(url) >= 0 +  parse = url.split(/\s+/) +  url = parse[0] +  passKeys = parse[1..].join(" ") +  newSpec = (if passKeys then url + " " + passKeys else url) -  excludedUrls += "\n" + url -  Settings.set("excludedUrls", excludedUrls) +  excludedUrls = Settings.get("excludedUrls").split("\n") +  excludedUrls.push(newSpec) + +  # Update excludedUrls. +  # Try to keep the list as unchanged as possible: same order, same comments, same blank lines. +  seenNew = false +  newExcludedUrls = [] +  for spec in excludedUrls +    spec = spec.trim() +    parse = spec.split(/\s+/) +    # Keep just one copy of the new exclusion rule. +    if parse.length and parse[0] == url +      if !seenNew +        newExcludedUrls.push(newSpec) +        seenNew = true +      continue +    # And just keep everything else. +    newExcludedUrls.push(spec) +     +  Settings.set("excludedUrls", newExcludedUrls.join("\n"))    chrome.tabs.query({ windowId: chrome.windows.WINDOW_ID_CURRENT, active: true },      (tabs) -> updateActiveState(tabs[0].id)) @@ -346,32 +377,39 @@ updateOpenTabs = (tab) ->    # Frames are recreated on refresh    delete framesForTab[tab.id] -# Updates the browserAction icon to indicated whether Vimium is enabled or disabled on the current page. -# Also disables Vimium if it is currently enabled but should be disabled according to the url blacklist. +setBrowserActionIcon = (tabId,path) -> +  chrome.browserAction.setIcon({ tabId: tabId, path: path }) + +# Updates the browserAction icon to indicate whether Vimium is enabled or disabled on the current page. +# Also propagates new enabled/disabled/passkeys state to active window, if necessary.  # This lets you disable Vimium on a page without needing to reload. -# -# Three situations are considered: -# 1. Active tab is disabled -> disable icon -# 2. Active tab is enabled and should be enabled -> enable icon -# 3. Active tab is enabled but should be disabled -> disable icon and disable vimium  updateActiveState = (tabId) ->    enabledIcon = "icons/browser_action_enabled.png"    disabledIcon = "icons/browser_action_disabled.png" -  chrome.tabs.get(tabId, (tab) -> -    # Default to disabled state in case we can't connect to Vimium, primarily for the "New Tab" page. -    chrome.browserAction.setIcon({ path: disabledIcon }) -    chrome.tabs.sendMessage(tabId, { name: "getActiveState" }, (response) -> -      isCurrentlyEnabled = (response? && response.enabled) -      shouldBeEnabled = isEnabledForUrl({url: tab.url}).isEnabledForUrl - -      if (isCurrentlyEnabled) -        if (shouldBeEnabled) -          chrome.browserAction.setIcon({ path: enabledIcon }) +  partialIcon = "icons/browser_action_partial.png" +  chrome.tabs.get tabId, (tab) -> +    chrome.tabs.sendMessage tabId, { name: "getActiveState" }, (response) -> +      console.log response +      if response +        isCurrentlyEnabled = response.enabled +        currentPasskeys = response.passKeys +        # TODO: +        # isEnabledForUrl is quite expensive to run each time we change tab.  Perhaps memoize it? +        shouldHaveConfig = isEnabledForUrl({url: tab.url}) +        shouldBeEnabled = shouldHaveConfig.isEnabledForUrl +        shouldHavePassKeys = shouldHaveConfig.passKeys +        if (shouldBeEnabled and shouldHavePassKeys) +          setBrowserActionIcon(tabId,partialIcon) +        else if (shouldBeEnabled) +          setBrowserActionIcon(tabId,enabledIcon)          else -          chrome.browserAction.setIcon({ path: disabledIcon }) -          chrome.tabs.sendMessage(tabId, { name: "disableVimium" }) +          setBrowserActionIcon(tabId,disabledIcon) +        # Propagate the new state only if it has changed. +        if (isCurrentlyEnabled != shouldBeEnabled || currentPasskeys != shouldHavePassKeys) +          chrome.tabs.sendMessage(tabId, { name: "setState", enabled: shouldBeEnabled, passKeys: shouldHavePassKeys })        else -        chrome.browserAction.setIcon({ path: disabledIcon }))) +        # We didn't get a response from the front end, so Vimium isn't running. +        setBrowserActionIcon(tabId,disabledIcon)  handleUpdateScrollPosition = (request, sender) ->    updateScrollPosition(sender.tab, request.scrollX, request.scrollY) @@ -500,6 +538,14 @@ handleKeyDown = (request, port) ->      console.log("checking keyQueue: [", keyQueue + key, "]")      keyQueue = checkKeyQueue(keyQueue + key, port.sender.tab.id, request.frameId)      console.log("new KeyQueue: " + keyQueue) +  # Tell the content script whether there are keys in the queue. +  # FIXME: There is a race condition here.  The behaviour in the content script depends upon whether this message gets +  # back there before or after the next keystroke. +  # That being said, I suspect there are other similar race conditions here, for example in checkKeyQueue(). +  # Steve (23 Aug, 14). +  chrome.tabs.sendMessage(port.sender.tab.id, +    name: "currentKeyQueue", +    keyQueue: keyQueue)  checkKeyQueue = (keysToCheck, tabId, frameId) ->    refreshedCompletionKeys = false diff --git a/background_scripts/settings.coffee b/background_scripts/settings.coffee index 175f3262..34d6e879 100644 --- a/background_scripts/settings.coffee +++ b/background_scripts/settings.coffee @@ -83,7 +83,11 @@ root.Settings = Settings =        """      excludedUrls:        """ +      # Disable Vimium on Gmail:        http*://mail.google.com/* + +      # Use Facebook's own j/k bindings: +      http*://www.facebook.com/* jk        """      # NOTE: If a page contains both a single angle-bracket link and a double angle-bracket link, then in      # most cases the single bracket link will be "prev/next page" and the double bracket link will be diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index 565c9e61..137b9d1a 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -13,8 +13,11 @@ findModeQueryHasResults = false  findModeAnchorNode = null  isShowingHelpDialog = false  keyPort = null -# Users can disable Vimium on URL patterns via the settings page. +# Users can disable Vimium on URL patterns via the settings page.  The following two variables +# (isEnabledForUrl and passKeys) control Vimium's enabled/disabled behaviour.  isEnabledForUrl = true +passKeys = null +keyQueue = null  # The user's operating system.  currentCompletionKeys = null  validFirstKeys = null @@ -115,42 +118,46 @@ initializePreDomReady = ->      getScrollPosition: -> scrollX: window.scrollX, scrollY: window.scrollY      setScrollPosition: (request) -> setScrollPosition request.scrollX, request.scrollY      executePageCommand: executePageCommand -    getActiveState: -> { enabled: isEnabledForUrl } -    disableVimium: disableVimium +    getActiveState: -> { enabled: isEnabledForUrl, passKeys: passKeys } +    setState: setState +    currentKeyQueue: (request) -> keyQueue = request.keyQueue    chrome.runtime.onMessage.addListener (request, sender, sendResponse) -> -    # in the options page, we will receive requests from both content and background scripts. ignore those +    # In the options page, we will receive requests from both content and background scripts. ignore those      # from the former.      return if sender.tab and not sender.tab.url.startsWith 'chrome-extension://' -    return unless isEnabledForUrl or request.name == 'getActiveState' +    return unless isEnabledForUrl or request.name == 'getActiveState' or request.name == 'setState' +    # These requests are delivered to the options page, but there are no handlers there. +    return if request.handler == "registerFrame" or request.handler == "frameFocused"      sendResponse requestHandlers[request.name](request, sender) -    # Ensure the sendResponse callback is freed.      false -# -# This is called once the background page has told us that Vimium should be enabled for the current URL. -# -initializeWhenEnabled = -> -  document.addEventListener("keydown", onKeydown, true) -  document.addEventListener("keypress", onKeypress, true) -  document.addEventListener("keyup", onKeyup, true) -  document.addEventListener("focus", onFocusCapturePhase, true) -  document.addEventListener("blur", onBlurCapturePhase, true) -  document.addEventListener("DOMActivate", onDOMActivate, true) -  enterInsertModeIfElementIsFocused() +# Wrapper to install event listeners.  Syntactic sugar. +installListener = (event, callback) -> document.addEventListener(event, callback, true)  # -# Used to disable Vimium without needing to reload the page. -# This is called if the current page's url is blacklisted using the popup UI. +# This is called once the background page has told us that Vimium should be enabled for the current URL. +# We enable/disable Vimium by toggling isEnabledForUrl.  The alternative, installing or uninstalling +# listeners, is error prone.  It's more difficult to keep track of the state.  # -disableVimium = -> -  document.removeEventListener("keydown", onKeydown, true) -  document.removeEventListener("keypress", onKeypress, true) -  document.removeEventListener("keyup", onKeyup, true) -  document.removeEventListener("focus", onFocusCapturePhase, true) -  document.removeEventListener("blur", onBlurCapturePhase, true) -  document.removeEventListener("DOMActivate", onDOMActivate, true) -  isEnabledForUrl = false +installedListeners = false +initializeWhenEnabled = (newPassKeys=undefined) -> +  isEnabledForUrl = true +  passKeys = passKeys if typeof(newPassKeys) != 'undefined' +  if (!installedListeners) +    installListener "keydown", (event) -> if isEnabledForUrl then onKeydown(event) else true +    installListener "keypress", (event) -> if isEnabledForUrl then onKeypress(event) else true +    installListener "keyup", (event) -> if isEnabledForUrl then onKeyup(event) else true +    installListener "focus", (event) -> if isEnabledForUrl then onFocusCapturePhase(event) else true +    installListener "blur", (event) -> if isEnabledForUrl then onBlurCapturePhase(event) +    installListener "DOMActivate", (event) -> if isEnabledForUrl then onDOMActivate(event) +    enterInsertModeIfElementIsFocused() +    installedListeners = true + +setState = (request) -> +  isEnabledForUrl = request.enabled +  passKeys = request.passKeys +  initializeWhenEnabled(passKeys) if isEnabledForUrl and !installedListeners  #  # The backend needs to know which frame has focus. @@ -321,6 +328,15 @@ extend window,        false +# Should this keyChar be passed to the underlying page? +# Keystrokes are *never* considered passKeys if the keyQueue is not empty.  So, for example, if 't' is a +# passKey, then 'gt' and '99t' will neverthless be handled by vimium. +# TODO: This currently only works for unmodified keys (so not for '<c-a>', or the like).  It's not clear if +# this is a problem or not.  I don't recall coming across a web page with modifier key bindings.  Such +# bindings might be too likely to conflict with browsers' native bindings. +isPassKey = ( keyChar ) -> +  !keyQueue and passKeys and 0 <= passKeys.indexOf keyChar +  handledKeydownEvents = []  # @@ -349,6 +365,9 @@ onKeypress = (event) ->          handleKeyCharForFindMode(keyChar)          DomUtils.suppressEvent(event)        else if (!isInsertMode() && !findMode) +        # Is this keyChar is to be passed to the underlying page? +        if (isPassKey keyChar) +          return undefined          if (currentCompletionKeys.indexOf(keyChar) != -1)            DomUtils.suppressEvent(event) @@ -431,6 +450,9 @@ onKeydown = (event) ->      else if (KeyboardUtils.isEscape(event))        keyPort.postMessage({ keyChar:"<ESC>", frameId:frameId }) +    else if isPassKey KeyboardUtils.getKeyChar(event) +      return undefined +    # Added to prevent propagating this event to other listeners if it's one that'll trigger a Vimium command.    # The goal is to avoid the scenario where Google Instant Search uses every keydown event to dump us    # back into the search box. As a side effect, this should also prevent overriding by other sites. @@ -466,7 +488,7 @@ checkIfEnabledForUrl = ->    chrome.runtime.sendMessage { handler: "isEnabledForUrl", url: url }, (response) ->      isEnabledForUrl = response.isEnabledForUrl      if (isEnabledForUrl) -      initializeWhenEnabled() +      initializeWhenEnabled(response.passKeys)      else if (HUD.isReady())        # Quickly hide any HUD we might already be showing, e.g. if we entered insert mode on page load.        HUD.hide() diff --git a/icons/browser_action_partial.png b/icons/browser_action_partial.pngBinary files differ new file mode 100644 index 00000000..e713f005 --- /dev/null +++ b/icons/browser_action_partial.png diff --git a/icons/icon48partial.png b/icons/icon48partial.pngBinary files differ new file mode 100644 index 00000000..088099b1 --- /dev/null +++ b/icons/icon48partial.png diff --git a/pages/options.html b/pages/options.html index b71625e8..07dcab1d 100644 --- a/pages/options.html +++ b/pages/options.html @@ -197,15 +197,30 @@          </tr>          <tr>            <td colspan="3"> -              Excluded URLs<br/> +              Excluded URLs and keys<br/>                <div class="help">                  <div class="example"> -                  e.g. http*://mail.google.com/*<br/> -                  This will disable Vimium on Gmail.<br/><br/> -                  Enter one URL per line.<br/> +                  <p> +                    To disable Vimium on a site, use:<br/> +                    <tt>http*://mail.google.com/*</tt><br/> +                    This will <i>wholly disable</i> Vimium on Gmail.<br/><br/> +                    To use Vimium together with a website's own<br/> +                    key bindings, use:<br/> +                    <tt>http*://mail.google.com/* jknpc</tt><br/> +                    This will <i>enable</i> Vimium on Gmail, but pass<br/> +                    the five listed keys through to Gmail itself.<br/><br/> +                    One entry per line.<br/> +                  </p>                  </div>                </div> -              <textarea id="excludedUrls"></textarea> +              <!-- Hack: fix a minimum size for the text area (below) so that it is +                   not too much smaller than its help text (above). --> +              <!-- FIXME: +                   This text area should really be broken out into an array +                   of separate inputs.  However, the whole options page really +                   needs a workover, so I'm leaving it like this, for now +                   (Steve, 23 Aug, 14). --> +              <textarea id="excludedUrls" style="min-height:180px"></textarea>            </td>          </tr>          <tbody id='advancedOptions'> diff --git a/pages/popup.coffee b/pages/popup.coffee index 6d7afafc..41fc17a9 100644 --- a/pages/popup.coffee +++ b/pages/popup.coffee @@ -1,10 +1,21 @@  onLoad = ->    document.getElementById("optionsLink").setAttribute "href", chrome.runtime.getURL("pages/options.html")    chrome.tabs.getSelected null, (tab) -> -    # The common use case is to disable Vimium at the domain level. -    # This regexp will match "http://www.example.com/" from "http://www.example.com/path/to/page.html". -    domain = tab.url.match(/[^\/]*\/\/[^\/]*\//) or tab.url -    document.getElementById("popupInput").value = domain + "*" +    # Check if we have an existing exclusing rule for this page. +    isEnabled = chrome.extension.getBackgroundPage().isEnabledForUrl(url: tab.url) +    if isEnabled.matchingUrl +      console.log isEnabled +      # There is an existing rule for this page. +      pattern = isEnabled.matchingUrl +      if isEnabled.passKeys +        pattern += " " + isEnabled.passKeys +      document.getElementById("popupInput").value = pattern +    else +      # No existing exclusion rule. +      # The common use case is to disable Vimium at the domain level. +      # This regexp will match "http://www.example.com/" from "http://www.example.com/path/to/page.html". +      domain = tab.url.match(/[^\/]*\/\/[^\/]*\//) or tab.url +      document.getElementById("popupInput").value = domain + "*"  onExcludeUrl = (e) ->    url = document.getElementById("popupInput").value diff --git a/pages/popup.html b/pages/popup.html index 8ccf7126..89f1f02a 100644 --- a/pages/popup.html +++ b/pages/popup.html @@ -6,14 +6,14 @@          padding: 0px;        } -      #vimiumPopup { width: 300px; } +      #vimiumPopup { width: 500px; }        #excludeControls {          padding: 10px;        }        #popupInput { -        width: 160px; +        width: 330px;        }        #excludeConfirm { @@ -54,7 +54,7 @@        <div id="excludeControls">          <input id="popupInput" type="text" />          <input id="popupButton" type="button" value="Exclude URL" /> -        <span id="excludeConfirm">Saved exclude pattern.</span> +        <span id="excludeConfirm">Saved.</span>        </div>        <div id="popupMenu"> diff --git a/tests/unit_tests/exclusion_test.coffee b/tests/unit_tests/exclusion_test.coffee new file mode 100644 index 00000000..33dbccd3 --- /dev/null +++ b/tests/unit_tests/exclusion_test.coffee @@ -0,0 +1,71 @@ + +require "./test_helper.js" +require "./test_chrome_stubs.js" + +# FIXME: +# Would like to do: +# extend(global, require "../../background_scripts/marks.js") +# But it looks like marks.coffee has never been included in a test before! +# Temporary fix... +root.Marks =  +  create: () -> true +  goto: +    bind: () -> true + +extend(global, require "../../lib/utils.js") +Utils.getCurrentVersion = -> '1.44' +extend(global,require "../../background_scripts/sync.js") +extend(global,require "../../background_scripts/settings.js") +Sync.init() +extend(global, require "../../background_scripts/commands.js") +extend(global, require "../../background_scripts/main.js") + +# These tests cover only the most basic aspects of excluded URLs and passKeys. +# +context "Excluded URLs and pass keys", +  setup -> +    Settings.set 'excludedUrls', 'http://mail.google.com/*\nhttp://www.facebook.com/* jk' + +  should "be disabled for excluded sites", -> +    rule = isEnabledForUrl({ url: 'http://mail.google.com/u/0/inbox' }) +    assert.isFalse rule.isEnableForUrl +    assert.isTrue rule.matchingUrl + +  should "be enabled, but with pass keys", -> +    rule = isEnabledForUrl({ url: 'http://www.facebook.com/pages' }) +    assert.isTrue rule.isEnabledForUrl +    assert.equal rule.passKeys, 'jk' +    assert.isTrue rule.matchingUrl + +  should "be enabled", -> +    rule = isEnabledForUrl({ url: 'http://www.twitter.com/pages' }) +    assert.isTrue rule.isEnabledForUrl +    assert.isFalse rule.passKeys + +  should "add a new excluded URL", -> +    rule = isEnabledForUrl({ url: 'http://www.example.com/page' }) +    assert.isTrue rule.isEnabledForUrl +    addExcludedUrl("http://www.example.com*") +    rule = isEnabledForUrl({ url: 'http://www.example.com/page' }) +    assert.isFalse rule.isEnabledForUrl +    assert.isFalse rule.passKeys +    assert.isTrue rule.matchingUrl + +  should "add a new excluded URL with passkeys", -> +    rule = isEnabledForUrl({ url: 'http://www.example.com/page' }) +    assert.isTrue rule.isEnabledForUrl +    addExcludedUrl("http://www.example.com/* jk") +    rule = isEnabledForUrl({ url: 'http://www.example.com/page' }) +    assert.isTrue rule.isEnabledForUrl +    assert.equal rule.passKeys, 'jk' +    assert.isTrue rule.matchingUrl + +  should "update an existing excluded URL with passkeys", -> +    rule = isEnabledForUrl({ url: 'http://www.facebook.com/page' }) +    assert.isTrue rule.isEnabledForUrl +    addExcludedUrl("http://www.facebook.com/* jknp") +    rule = isEnabledForUrl({ url: 'http://www.facebook.com/page' }) +    assert.isTrue rule.isEnabledForUrl +    assert.equal rule.passKeys, 'jknp' +    assert.isTrue rule.matchingUrl + diff --git a/tests/unit_tests/test_chrome_stubs.coffee b/tests/unit_tests/test_chrome_stubs.coffee index e9c48f31..9622f85f 100644 --- a/tests/unit_tests/test_chrome_stubs.coffee +++ b/tests/unit_tests/test_chrome_stubs.coffee @@ -2,26 +2,55 @@  #  # This is a stub for chrome.strorage.sync for testing.  # It does what chrome.storage.sync should do (roughly), but does so synchronously. +# It also provides stubs for a number of other chrome APIs.  # +global.window = {} +global.localStorage = {} +  global.chrome = -  runtime: {} +  runtime: +    getManifest: () -> +      version: "1.2.3" +    onConnect: +      addListener: () -> true +    onMessage: +      addListener: () -> true -  storage: +  tabs: +    onSelectionChanged: +      addListener: () -> true +    onUpdated: +      addListener: () -> true +    onAttached: +      addListener: () -> true +    onMoved: +      addListener: () -> true +    onRemoved: +      addListener: () -> true +    onActiveChanged: +      addListener: () -> true +    query: () -> true +  windows: +    onRemoved: +      addListener: () -> true +    getAll: () -> true + +  storage:      # chrome.storage.onChanged      onChanged:        addListener: (func) -> @func = func        # Fake a callback from chrome.storage.sync.        call: (key, value) -> -        chrome.runtime = { lastError: undefined } +        chrome.runtime.lastError = undefined          key_value = {}          key_value[key] = { newValue: value }          @func(key_value,'synced storage stub') if @func        callEmpty: (key) -> -        chrome.runtime = { lastError: undefined } +        chrome.runtime.lastError = undefined          if @func            items = {}            items[key] = {} @@ -32,7 +61,7 @@ global.chrome =        store: {}        set: (items, callback) -> -        chrome.runtime = { lastError: undefined } +        chrome.runtime.lastError = undefined          for own key, value of items            @store[key] = value          callback() if callback @@ -41,7 +70,7 @@ global.chrome =            global.chrome.storage.onChanged.call(key,value)        get: (keys, callback) -> -        chrome.runtime = { lastError: undefined } +        chrome.runtime.lastError = undefined          if keys == null            keys = []            for own key, value of @store @@ -53,7 +82,7 @@ global.chrome =          callback items if callback        remove: (key, callback) -> -        chrome. runtime = { lastError: undefined } +        chrome.runtime.lastError = undefined          if key of @store            delete @store[key]          callback() if callback | 
