root = exports ? window
# The browser may have tabs already open. We inject the content scripts immediately so that they work straight
# away.
chrome.runtime.onInstalled.addListener ({ reason }) ->
  # See https://developer.chrome.com/extensions/runtime#event-onInstalled
  return if reason in [ "chrome_update", "shared_module_update" ]
  manifest = chrome.runtime.getManifest()
  # Content scripts loaded on every page should be in the same group. We assume it is the first.
  contentScripts = manifest.content_scripts[0]
  jobs = [ [ chrome.tabs.executeScript, contentScripts.js ], [ chrome.tabs.insertCSS, contentScripts.css ] ]
  # Chrome complains if we don't evaluate chrome.runtime.lastError on errors (and we get errors for tabs on
  # which Vimium cannot run).
  checkLastRuntimeError = -> chrome.runtime.lastError
  chrome.tabs.query { status: "complete" }, (tabs) ->
    for tab in tabs
      for [ func, files ] in jobs
        for file in files
          func tab.id, { file: file, allFrames: contentScripts.all_frames }, checkLastRuntimeError
currentVersion = Utils.getCurrentVersion()
frameIdsForTab = {}
portsForTab = {}
root.urlForTab = {}
# This is exported for use by "marks.coffee".
root.tabLoadedHandlers = {} # tabId -> function()
# A secret, available only within the current instantiation of Vimium.  The secret is big, likely unguessable
# in practice, but less than 2^31.
chrome.storage.local.set
  vimiumSecret: Math.floor Math.random() * 2000000000
completionSources =
  bookmarks: new BookmarkCompleter
  history: new HistoryCompleter
  domains: new DomainCompleter
  tabs: new TabCompleter
  searchEngines: new SearchEngineCompleter
completers =
  omni: new MultiCompleter [
    completionSources.bookmarks
    completionSources.history
    completionSources.domains
    completionSources.searchEngines
    ]
  bookmarks: new MultiCompleter [completionSources.bookmarks]
  tabs: new MultiCompleter [completionSources.tabs]
completionHandlers =
  filter: (completer, request, port) ->
    completer.filter request, (response) ->
      # We use try here because this may fail if the sender has already navigated away from the original page.
      # This can happen, for example, when posting completion suggestions from the SearchEngineCompleter
      # (which is done asynchronously).
      try
        port.postMessage extend request, extend response, handler: "completions"
  refresh: (completer, _, port) -> completer.refresh port
  cancel: (completer, _, port) -> completer.cancel port
handleCompletions = (sender) -> (request, port) ->
  completionHandlers[request.handler] completers[request.name], request, port
chrome.runtime.onConnect.addListener (port) ->
  if (portHandlers[port.name])
    port.onMessage.addListener portHandlers[port.name] port.sender, port
chrome.runtime.onMessage.addListener((request, sender, sendResponse) ->
  request = extend {count: 1, frameId: sender.frameId}, extend request, tab: sender.tab, tabId: sender.tab.id
  if (sendRequestHandlers[request.handler])
    sendResponse(sendRequestHandlers[request.handler](request, sender))
  # Ensure the sendResponse callback is freed.
  return false)
onURLChange = (details) ->
  chrome.tabs.sendMessage details.tabId, name: "checkEnabledAfterURLChange"
# Re-check whether Vimium is enabled for a frame when the url changes without a reload.
chrome.webNavigation.onHistoryStateUpdated.addListener onURLChange # history.pushState.
chrome.webNavigation.onReferenceFragmentUpdated.addListener onURLChange # Hash changed.
# Retrieves the help dialog HTML template from a file, and populates it with the latest keybindings.
getHelpDialogHtml = ({showUnboundCommands, showCommandNames, customTitle}) ->
  commandsToKey = {}
  for own key of Commands.keyToCommandRegistry
    command = Commands.keyToCommandRegistry[key].command
    commandsToKey[command] = (commandsToKey[command] || []).concat(key)
  replacementStrings =
    version: currentVersion
    title: customTitle || "Help"
    tip: if showCommandNames then "Tip: click command names to yank them to the clipboard." else " "
  for own group of Commands.commandGroups
    replacementStrings[group] =
        helpDialogHtmlForCommandGroup(group, commandsToKey, Commands.availableCommands,
                                      showUnboundCommands, showCommandNames)
  replacementStrings
#
# Generates HTML for a given set of commands. commandGroups are defined in commands.js
#
helpDialogHtmlForCommandGroup = (group, commandsToKey, availableCommands,
    showUnboundCommands, showCommandNames) ->
  html = []
  for command in Commands.commandGroups[group]
    keys = commandsToKey[command] || []
    bindings = ("#{Utils.escapeHtml key}" for key in keys).join ", "
    if (showUnboundCommands || commandsToKey[command])
      isAdvanced = Commands.advancedCommands.indexOf(command) >= 0
      description = availableCommands[command].description
      if keys.join(", ").length < 12
        helpDialogHtmlForCommand html, isAdvanced, bindings, description, showCommandNames, command
      else
        # If the length of the bindings is too long, then we display the bindings on a separate row from the
        # description.  This prevents the column alignment from becoming out of whack.
        helpDialogHtmlForCommand html, isAdvanced, bindings, "", false, ""
        helpDialogHtmlForCommand html, isAdvanced, "", description, showCommandNames, command
  html.join("\n")
helpDialogHtmlForCommand = (html, isAdvanced, bindings, description, showCommandNames, command) ->
  html.push "
"
  if description
    html.push "| #{bindings}"
    html.push " |  | ", description
    html.push("(#{command})") if showCommandNames
  else
    html.push " | ", bindings
  html.push(" | 
")
# Cache "content_scripts/vimium.css" in chrome.storage.local for UI components.
do ->
  req = new XMLHttpRequest()
  req.open "GET", chrome.runtime.getURL("content_scripts/vimium.css"), true # true -> asynchronous.
  req.onload = ->
    {status, responseText} = req
    chrome.storage.local.set vimiumCSSInChromeStorage: responseText if status == 200
  req.send()
TabOperations =
  # Opens the url in the current tab.
  openUrlInCurrentTab: (request) ->
    chrome.tabs.update request.tabId, url: Utils.convertToUrl request.url
  # Opens request.url in new tab and switches to it.
  openUrlInNewTab: (request, callback = (->)) ->
    tabConfig =
      url: Utils.convertToUrl request.url
      index: request.tab.index + 1
      selected: true
      windowId: request.tab.windowId
      openerTabId: request.tab.id
    chrome.tabs.create tabConfig, callback
toggleMuteTab = do ->
  muteTab = (tab) -> chrome.tabs.update tab.id, {muted: !tab.mutedInfo.muted}
  ({tab: currentTab, registryEntry}) ->
    if registryEntry.options.all? or registryEntry.options.other?
      # If there are any audible, unmuted tabs, then we mute them; otherwise we unmute any muted tabs.
      chrome.tabs.query {audible: true}, (tabs) ->
        if registryEntry.options.other?
          tabs = (tab for tab in tabs when tab.id != currentTab.id)
        audibleUnmutedTabs = (tab for tab in tabs when tab.audible and not tab.mutedInfo.muted)
        if 0 < audibleUnmutedTabs.length
          muteTab tab for tab in audibleUnmutedTabs
        else
          muteTab tab for tab in tabs when tab.mutedInfo.muted
    else
      muteTab currentTab
#
# Selects the tab with the ID specified in request.id
#
selectSpecificTab = (request) ->
  chrome.tabs.get(request.id, (tab) ->
    chrome.windows.update(tab.windowId, { focused: true })
    chrome.tabs.update(request.id, { selected: true }))
moveTab = ({count, tab, registryEntry}) ->
  count = -count if registryEntry.command == "moveTabLeft"
  chrome.tabs.getAllInWindow null, (tabs) ->
    pinnedCount = (tabs.filter (tab) -> tab.pinned).length
    minIndex = if tab.pinned then 0 else pinnedCount
    maxIndex = (if tab.pinned then pinnedCount else tabs.length) - 1
    chrome.tabs.move tab.id,
      index: Math.max minIndex, Math.min maxIndex, tab.index + count
mkRepeatCommand = (command) -> (request) ->
  if 0 < request.count--
    command request, (request) -> (mkRepeatCommand command) request
# These are commands which are bound to keystrokes which must be handled by the background page. They are
# mapped in commands.coffee.
BackgroundCommands =
  createTab: mkRepeatCommand (request, callback) ->
    request.url ?= do ->
      url = Settings.get "newTabUrl"
      if url == "pages/blank.html"
        # "pages/blank.html" does not work in incognito mode, so fall back to "chrome://newtab" instead.
        if request.tab.incognito then "chrome://newtab" else chrome.runtime.getURL newTabUrl
      else
        url
    TabOperations.openUrlInNewTab request, (tab) -> callback extend request, {tab, tabId: tab.id}
  duplicateTab: mkRepeatCommand (request, callback) ->
    chrome.tabs.duplicate request.tabId, (tab) -> callback extend request, {tab, tabId: tab.id}
  moveTabToNewWindow: ({count, tab}) ->
    chrome.tabs.query {currentWindow: true}, (tabs) ->
      activeTabIndex = tab.index
      startTabIndex = Math.max 0, Math.min activeTabIndex, tabs.length - count
      [ tab, tabs... ] = tabs[startTabIndex...startTabIndex + count]
      chrome.windows.create {tabId: tab.id, incognito: tab.incognito}, (window) ->
        chrome.tabs.move (tab.id for tab in tabs), {windowId: window.id, index: -1}
  nextTab: (request) -> selectTab "next", request
  previousTab: (request) -> selectTab "previous", request
  firstTab: (request) -> selectTab "first", request
  lastTab: (request) -> selectTab "last", request
  removeTab: ({count, tab}) ->
    chrome.tabs.query {currentWindow: true}, (tabs) ->
      activeTabIndex = tab.index
      startTabIndex = Math.max 0, Math.min activeTabIndex, tabs.length - count
      chrome.tabs.remove (tab.id for tab in tabs[startTabIndex...startTabIndex + count])
  restoreTab: mkRepeatCommand (request, callback) -> chrome.sessions.restore null, callback request
  openCopiedUrlInCurrentTab: (request) -> TabOperations.openUrlInCurrentTab extend request, url: Clipboard.paste()
  openCopiedUrlInNewTab: (request) -> @createTab extend request, url: Clipboard.paste()
  togglePinTab: ({tab}) -> chrome.tabs.update tab.id, {pinned: !tab.pinned}
  toggleMuteTab: toggleMuteTab
  moveTabLeft: moveTab
  moveTabRight: moveTab
  nextFrame: ({count, frameId, tabId}) ->
    frameIdsForTab[tabId] = cycleToFrame frameIdsForTab[tabId], frameId, count
    chrome.tabs.sendMessage tabId, name: "focusFrame", frameId: frameIdsForTab[tabId][0], highlight: true
  closeTabsOnLeft: (request) -> removeTabsRelative "before", request
  closeTabsOnRight: (request) -> removeTabsRelative "after", request
  closeOtherTabs: (request) -> removeTabsRelative "both", request
  visitPreviousTab: ({count, tab}) ->
    tabIds = BgUtils.tabRecency.getTabsByRecency().filter (tabId) -> tabId != tab.id
    if 0 < tabIds.length
      selectSpecificTab id: tabIds[(count-1) % tabIds.length]
# Remove tabs before, after, or either side of the currently active tab
removeTabsRelative = (direction, {tab: activeTab}) ->
  chrome.tabs.query {currentWindow: true}, (tabs) ->
    shouldDelete =
      switch direction
        when "before"
          (index) -> index < activeTab.index
        when "after"
          (index) -> index > activeTab.index
        when "both"
          (index) -> index != activeTab.index
    chrome.tabs.remove (tab.id for tab in tabs when not tab.pinned and shouldDelete tab.index)
# Selects a tab before or after the currently selected tab.
# - direction: "next", "previous", "first" or "last".
selectTab = (direction, {count, tab}) ->
  chrome.tabs.getAllInWindow null, (tabs) ->
    if 1 < tabs.length
      toSelect =
        switch direction
          when "next"
            (tab.index + count) % tabs.length
          when "previous"
            (tab.index - count + count * tabs.length) % tabs.length
          when "first"
            Math.min tabs.length - 1, count - 1
          when "last"
            Math.max 0, tabs.length - count
      chrome.tabs.update tabs[toSelect].id, selected: true
chrome.tabs.onUpdated.addListener (tabId, changeInfo, tab) ->
  return unless changeInfo.status == "loading" # Only do this once per URL change.
  cssConf =
    allFrames: true
    code: Settings.get("userDefinedLinkHintCss")
    runAt: "document_start"
  chrome.tabs.insertCSS tabId, cssConf, -> chrome.runtime.lastError
# Symbolic names for the three browser-action icons.
ENABLED_ICON = "icons/browser_action_enabled.png"
DISABLED_ICON = "icons/browser_action_disabled.png"
PARTIAL_ICON = "icons/browser_action_partial.png"
# Convert the three icon PNGs to image data.
iconImageData = {}
for icon in [ENABLED_ICON, DISABLED_ICON, PARTIAL_ICON]
  iconImageData[icon] = {}
  for scale in [19, 38]
    do (icon, scale) ->
      canvas = document.createElement "canvas"
      canvas.width = canvas.height = scale
      # We cannot do the rest of this in the tests.
      unless chrome.areRunningVimiumTests? and chrome.areRunningVimiumTests
        context = canvas.getContext "2d"
        image = new Image
        image.src = icon
        image.onload = ->
          context.drawImage image, 0, 0, scale, scale
          iconImageData[icon][scale] = context.getImageData 0, 0, scale, scale
          document.body.removeChild canvas
        document.body.appendChild canvas
Frames =
  onConnect: (sender, port) ->
    [tabId, frameId] = [sender.tab.id, sender.frameId]
    port.onDisconnect.addListener -> Frames.unregisterFrame {tabId, frameId}
    port.postMessage handler: "registerFrameId", chromeFrameId: frameId
    # Return our onMessage handler for this port.
    (request, port) =>
      this[request.handler] {request, tabId, frameId, port}
  registerFrame: ({tabId, frameId, port}) ->
    frameIdsForTab[tabId].push frameId unless frameId in frameIdsForTab[tabId] ?= []
    (portsForTab[tabId] ?= {})[frameId] = port
  unregisterFrame: ({tabId, frameId}) ->
    # FrameId 0 is the top/main frame.  We never unregister that frame.  If the tab is closing, then we tidy
    # up elsewhere.  If the tab is navigating to a new page, then a new top frame will be along soon.
    # This mitigates against the unregister and register messages arriving out of order. See #2125.
    if 0 < frameId
      if tabId of frameIdsForTab
        frameIdsForTab[tabId] = (fId for fId in frameIdsForTab[tabId] when fId != frameId)
      if tabId of portsForTab
        delete portsForTab[tabId][frameId]
    HintCoordinator.unregisterFrame tabId, frameId
  isEnabledForUrl: ({request, tabId, port}) ->
    urlForTab[tabId] = request.url if request.frameIsFocused
    enabledState = Exclusions.isEnabledForUrl request.url
    if request.frameIsFocused
      chrome.browserAction.setIcon tabId: tabId, imageData: do ->
        enabledStateIcon =
          if not enabledState.isEnabledForUrl
            DISABLED_ICON
          else if 0 < enabledState.passKeys.length
            PARTIAL_ICON
          else
            ENABLED_ICON
        iconImageData[enabledStateIcon]
    port.postMessage extend request, enabledState
  domReady: ({tabId, frameId}) ->
    if frameId == 0
      tabLoadedHandlers[tabId]?()
      delete tabLoadedHandlers[tabId]
  linkHintsMessage: ({request, tabId, frameId}) ->
    HintCoordinator.onMessage tabId, frameId, request
handleFrameFocused = ({tabId, frameId}) ->
  frameIdsForTab[tabId] ?= []
  frameIdsForTab[tabId] = cycleToFrame frameIdsForTab[tabId], frameId
  # Inform all frames that a frame has received the focus.
  chrome.tabs.sendMessage tabId, name: "frameFocused", focusFrameId: frameId
# Rotate through frames to the frame count places after frameId.
cycleToFrame = (frames, frameId, count = 0) ->
  # We can't always track which frame chrome has focussed, but here we learn that it's frameId; so add an
  # additional offset such that we do indeed start from frameId.
  count = (count + Math.max 0, frames.indexOf frameId) % frames.length
  [frames[count..]..., frames[0...count]...]
HintCoordinator =
  tabState: {}
  onMessage: (tabId, frameId, request) ->
    if request.messageType of this
      this[request.messageType] tabId, frameId, request
    else
      # If there's no handler here, then the message is forwarded to all frames in the sender's tab.
      @sendMessage request.messageType, tabId, request
  # Post a link-hints message to a particular frame's port. We catch errors in case the frame has gone away.
  postMessage: (tabId, frameId, messageType, port, request = {}) ->
    try
      port.postMessage extend request, {handler: "linkHintsMessage", messageType}
    catch
      @unregisterFrame tabId, frameId
  # Post a link-hints message to all participating frames.
  sendMessage: (messageType, tabId, request = {}) ->
    for own frameId, port of @tabState[tabId].ports
      @postMessage tabId, parseInt(frameId), messageType, port, request
  prepareToActivateMode: (tabId, originatingFrameId, {modeIndex, isVimiumHelpDialog}) ->
    @tabState[tabId] = {frameIds: frameIdsForTab[tabId][..], hintDescriptors: {}, originatingFrameId, modeIndex}
    @tabState[tabId].ports = extend {}, portsForTab[tabId]
    @sendMessage "getHintDescriptors", tabId, {modeIndex, isVimiumHelpDialog}
  # Receive hint descriptors from all frames and activate link-hints mode when we have them all.
  postHintDescriptors: (tabId, frameId, {hintDescriptors}) ->
    if frameId in @tabState[tabId].frameIds
      @tabState[tabId].hintDescriptors[frameId] = hintDescriptors
      @tabState[tabId].frameIds = @tabState[tabId].frameIds.filter (fId) -> fId != frameId
      if @tabState[tabId].frameIds.length == 0
        for own frameId, port of @tabState[tabId].ports
          if frameId of @tabState[tabId].hintDescriptors
            hintDescriptors = extend {}, @tabState[tabId].hintDescriptors
            # We do not send back the frame's own hint descriptors.  This is faster (approx. speedup 3/2) for
            # link-busy sites like reddit.
            delete hintDescriptors[frameId]
            @postMessage tabId, parseInt(frameId), "activateMode", port,
              originatingFrameId: @tabState[tabId].originatingFrameId
              hintDescriptors: hintDescriptors
              modeIndex: @tabState[tabId].modeIndex
  # If an unregistering frame is participating in link-hints mode, then we need to tidy up after it.
  unregisterFrame: (tabId, frameId) ->
    if @tabState[tabId]?
      if @tabState[tabId].ports?[frameId]?
        delete @tabState[tabId].ports[frameId]
      if @tabState[tabId].frameIds? and frameId in @tabState[tabId].frameIds
        # We fake an empty "postHintDescriptors" because the frame has gone away.
        @postHintDescriptors tabId, frameId, hintDescriptors: []
# Port handler mapping
portHandlers =
  completions: handleCompletions
  frames: Frames.onConnect.bind Frames
sendRequestHandlers =
  runBackgroundCommand: (request) -> BackgroundCommands[request.registryEntry.command] request
  getHelpDialogHtml: getHelpDialogHtml
  # getCurrentTabUrl is used by the content scripts to get their full URL, because window.location cannot help
  # with Chrome-specific URLs like "view-source:http:..".
  getCurrentTabUrl: ({tab}) -> tab.url
  openUrlInNewTab: (request) -> TabOperations.openUrlInNewTab request
  openUrlInIncognito: (request) -> chrome.windows.create incognito: true, url: Utils.convertToUrl request.url
  openUrlInCurrentTab: TabOperations.openUrlInCurrentTab
  openOptionsPageInNewTab: (request) ->
    chrome.tabs.create url: chrome.runtime.getURL("pages/options.html"), index: request.tab.index + 1
  frameFocused: handleFrameFocused
  nextFrame: BackgroundCommands.nextFrame
  copyToClipboard: Clipboard.copy.bind Clipboard
  pasteFromClipboard: Clipboard.paste.bind Clipboard
  selectSpecificTab: selectSpecificTab
  createMark: Marks.create.bind(Marks)
  gotoMark: Marks.goto.bind(Marks)
  # Send a message to all frames in the current tab.
  sendMessageToFrames: (request, sender) -> chrome.tabs.sendMessage sender.tab.id, request.message
  # For debugging only. This allows content scripts to log messages to the extension's logging page.
  log: ({frameId, message}, sender) -> BgUtils.log "#{frameId} #{message}", sender
# We always remove chrome.storage.local/findModeRawQueryListIncognito on startup.
chrome.storage.local.remove "findModeRawQueryListIncognito"
# Tidy up tab caches when tabs are removed.  Also remove chrome.storage.local/findModeRawQueryListIncognito if
# there are no remaining incognito-mode windows.  Since the common case is that there are none to begin with,
# we first check whether the key is set at all.
chrome.tabs.onRemoved.addListener (tabId) ->
  delete cache[tabId] for cache in [frameIdsForTab, urlForTab, portsForTab, HintCoordinator.tabState]
  chrome.storage.local.get "findModeRawQueryListIncognito", (items) ->
    if items.findModeRawQueryListIncognito
      chrome.windows.getAll null, (windows) ->
        for window in windows
          return if window.incognito
        # There are no remaining incognito-mode tabs, and findModeRawQueryListIncognito is set.
        chrome.storage.local.remove "findModeRawQueryListIncognito"
# Convenience function for development use.
window.runTests = -> open(chrome.runtime.getURL('tests/dom_tests/dom_tests.html'))
#
# Begin initialization.
#
# Show notification on upgrade.
do showUpgradeMessage = ->
  # Avoid showing the upgrade notification when previousVersion is undefined, which is the case for new
  # installs.
  Settings.set "previousVersion", currentVersion  unless Settings.get "previousVersion"
  if Utils.compareVersions(currentVersion, Settings.get "previousVersion" ) == 1
    notificationId = "VimiumUpgradeNotification"
    notification =
      type: "basic"
      iconUrl: chrome.runtime.getURL "icons/vimium.png"
      title: "Vimium Upgrade"
      message: "Vimium has been upgraded to version #{currentVersion}. Click here for more information."
      isClickable: true
    if chrome.notifications?.create?
      chrome.notifications.create notificationId, notification, ->
        unless chrome.runtime.lastError
          Settings.set "previousVersion", currentVersion
          chrome.notifications.onClicked.addListener (id) ->
            if id == notificationId
              chrome.tabs.getSelected null, (tab) ->
                TabOperations.openUrlInNewTab {tab, tabId: tab.id, url: "https://github.com/philc/vimium#release-notes"}
    else
      # We need to wait for the user to accept the "notifications" permission.
      chrome.permissions.onAdded.addListener showUpgradeMessage
# The install date is shown on the logging page.
chrome.runtime.onInstalled.addListener ({reason}) ->
  unless reason in ["chrome_update", "shared_module_update"]
    chrome.storage.local.set installDate: new Date().toString()
extend root, {TabOperations, Frames}