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 = {} 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, name) -> sender = port.sender senderTabId = sender.tab?.id # If this is a tab we've been waiting to open, execute any "tab loaded" handlers, e.g. to restore # the tab's scroll position. Wait until domReady before doing this; otherwise operations like restoring # the scroll position will not be possible. if (port.name == "domReady" && senderTabId != null) if (tabLoadedHandlers[senderTabId]) toCall = tabLoadedHandlers[senderTabId] # Delete first to be sure there's no circular events. delete tabLoadedHandlers[senderTabId] toCall.call() if (portHandlers[port.name]) port.onMessage.addListener portHandlers[port.name] sender, port chrome.runtime.onMessage.addListener((request, sender, sendResponse) -> if (sendRequestHandlers[request.handler]) sendResponse(sendRequestHandlers[request.handler](request, sender)) # Ensure the sendResponse callback is freed. return false) # Log messages to the extension's logging page, but only if that page is open. logMessage = do -> loggingPageUrl = chrome.runtime.getURL "pages/logging.html" console.log "Vimium logging URL:\n #{loggingPageUrl}" if loggingPageUrl? # Do not output URL for tests. (message, sender = null) -> for viewWindow in chrome.extension.getViews {type: "tab"} if viewWindow.location.pathname == "/pages/logging.html" # Don't log messages from the logging page itself. We do this check late because most of the time # it's not needed. if sender?.url != loggingPageUrl viewWindow.document.getElementById("log-text").value += "#{(new Date()).toISOString()}: #{message}\n" # # Used by the content scripts to get their full URL. This is needed for URLs like "view-source:http:# .." # because window.location doesn't know anything about the Chrome-specific "view-source:". # getCurrentTabUrl = (request, sender) -> sender.tab.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. # The source frame also informs us whether or not it has the focus, which allows us to track the URL of the # active frame. # root.isEnabledForUrl = isEnabledForUrl = (request, sender) -> urlForTab[sender.tab.id] = request.url if request.frameIsFocused rule = Exclusions.getRule(request.url) { isEnabledForUrl: not rule or rule.passKeys passKeys: rule?.passKeys or "" } 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. # This is called by options.coffee. root.helpDialogHtml = (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" 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] bindings = (commandsToKey[command] || [""]).join(", ") if (showUnboundCommands || commandsToKey[command]) isAdvanced = Commands.advancedCommands.indexOf(command) >= 0 description = availableCommands[command].description if bindings.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 "", Utils.escapeHtml(bindings), "" html.push "#{if description and bindings then ':' else ''}", description html.push("(#{command})") if showCommandNames else html.push "", Utils.escapeHtml(bindings) html.push("") # # Fetches the contents of a file bundled with this extension. # fetchFileContents = (extensionFileName) -> req = new XMLHttpRequest() req.open("GET", chrome.runtime.getURL(extensionFileName), false) # false => synchronous req.send() req.responseText TabOperations = # Opens the url in the current tab. openUrlInCurrentTab: (request, callback = (->)) -> chrome.tabs.getSelected null, (tab) -> callback = (->) unless typeof callback == "function" chrome.tabs.update tab.id, { url: Utils.convertToUrl(request.url) }, callback # Opens request.url in new tab and switches to it if request.selected is true. openUrlInNewTab: (request, callback = (->)) -> chrome.tabs.getSelected null, (tab) -> tabConfig = url: Utils.convertToUrl request.url index: tab.index + 1 selected: true windowId: tab.windowId openerTabId: tab.id callback = (->) unless typeof callback == "function" chrome.tabs.create tabConfig, callback openUrlInIncognito: (request, callback = (->)) -> callback = (->) unless typeof callback == "function" chrome.windows.create {url: Utils.convertToUrl(request.url), incognito: true}, callback # # Copies or pastes some data (request.data) to/from the clipboard. # We return null to avoid the return value from the copy operations being passed to sendResponse. # copyToClipboard = (request) -> Clipboard.copy(request.data); null pasteFromClipboard = (request) -> Clipboard.paste() # # 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 })) repeatFunction = (func, totalCount, currentCount, frameId) -> if (currentCount < totalCount) func( -> repeatFunction(func, totalCount, currentCount + 1, frameId), frameId) moveTab = (count) -> chrome.tabs.getAllInWindow null, (tabs) -> pinnedCount = (tabs.filter (tab) -> tab.pinned).length chrome.tabs.getSelected null, (tab) -> 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 # Start action functions # These are commands which are bound to keystroke which must be handled by the background page. They are # mapped in commands.coffee. BackgroundCommands = createTab: (callback) -> chrome.tabs.query { active: true, currentWindow: true }, (tabs) -> tab = tabs[0] 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. url = if tab.incognito then "chrome://newtab" else chrome.runtime.getURL url TabOperations.openUrlInNewTab { url }, callback duplicateTab: (count) -> chrome.tabs.getSelected null, (tab) -> createTab = (tab) -> chrome.tabs.duplicate tab.id, createTab if 0 < count-- createTab tab moveTabToNewWindow: (count) -> chrome.tabs.query {currentWindow: true}, (tabs) -> chrome.tabs.query {currentWindow: true, active: true}, (activeTabs) -> activeTabIndex = activeTabs[0].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: (count) -> selectTab "next", count previousTab: (count) -> selectTab "previous", count firstTab: (count) -> selectTab "first", count lastTab: (count) -> selectTab "last", count removeTab: (count) -> chrome.tabs.query {currentWindow: true}, (tabs) -> chrome.tabs.query {currentWindow: true, active: true}, (activeTabs) -> activeTabIndex = activeTabs[0].index startTabIndex = Math.max 0, Math.min activeTabIndex, tabs.length - count chrome.tabs.remove (tab.id for tab in tabs[startTabIndex...startTabIndex + count]) restoreTab: (callback) -> chrome.sessions.restore null, -> callback() unless chrome.runtime.lastError openCopiedUrlInCurrentTab: (request) -> TabOperations.openUrlInCurrentTab({ url: Clipboard.paste() }) openCopiedUrlInNewTab: (request) -> TabOperations.openUrlInNewTab({ url: Clipboard.paste() }) togglePinTab: (request) -> chrome.tabs.getSelected(null, (tab) -> chrome.tabs.update(tab.id, { pinned: !tab.pinned })) showHelp: (callback, frameId) -> chrome.tabs.getSelected(null, (tab) -> chrome.tabs.sendMessage(tab.id, { name: "toggleHelpDialog", dialogHtml: helpDialogHtml(), frameId:frameId })) moveTabLeft: (count) -> moveTab -count moveTabRight: (count) -> moveTab count nextFrame: (count,frameId) -> chrome.tabs.getSelected null, (tab) -> frameIdsForTab[tab.id] = cycleToFrame frameIdsForTab[tab.id], frameId, count chrome.tabs.sendMessage tab.id, name: "focusFrame", frameId: frameIdsForTab[tab.id][0], highlight: true closeTabsOnLeft: -> removeTabsRelative "before" closeTabsOnRight: -> removeTabsRelative "after" closeOtherTabs: -> removeTabsRelative "both" visitPreviousTab: (count) -> chrome.tabs.getSelected null, (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) -> chrome.tabs.query {currentWindow: true}, (tabs) -> chrome.tabs.query {currentWindow: true, active: true}, (activeTabs) -> activeTabIndex = activeTabs[0].index shouldDelete = switch direction when "before" (index) -> index < activeTabIndex when "after" (index) -> index > activeTabIndex when "both" (index) -> index != activeTabIndex toRemove = [] for tab in tabs if not tab.pinned and shouldDelete tab.index toRemove.push tab.id chrome.tabs.remove toRemove # Selects a tab before or after the currently selected tab. # - direction: "next", "previous", "first" or "last". selectTab = (direction, count = 1) -> chrome.tabs.getAllInWindow null, (tabs) -> return unless tabs.length > 1 chrome.tabs.getSelected null, (currentTab) -> toSelect = switch direction when "next" (currentTab.index + count) % tabs.length when "previous" (currentTab.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 # Here's how we set the page icon. The default is "disabled", so if we do nothing else, then we get the # grey-out disabled icon. Thereafter, we only set tab-specific icons, so there's no need to update the icon # when we visit a tab on which Vimium isn't running. # # For active tabs, when a frame starts, it requests its active state via isEnabledForUrl. We also check the # state every time a frame gets the focus. In both cases, the frame then updates the tab's icon accordingly. # # Exclusion rule changes (from either the options page or the page popup) propagate via the subsequent focus # change. In particular, whenever a frame next gets the focus, it requests its new state and sets the icon # accordingly. # setIcon = (request, sender) -> path = switch request.icon when "enabled" then "icons/browser_action_enabled.png" when "partial" then "icons/browser_action_partial.png" when "disabled" then "icons/browser_action_disabled.png" chrome.browserAction.setIcon tabId: sender.tab.id, path: path 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 # End action functions runBackgroundCommand = ({frameId, registryEntry, count}, sender) -> if registryEntry.passCountToFunction BackgroundCommands[registryEntry.command] count, frameId else if registryEntry.noRepeat BackgroundCommands[registryEntry.command] frameId else repeatFunction BackgroundCommands[registryEntry.command], count, 0, frameId openOptionsPageInNewTab = -> chrome.tabs.getSelected(null, (tab) -> chrome.tabs.create({ url: chrome.runtime.getURL("pages/options.html"), index: tab.index + 1 })) Frames = onConnect: (sender, port) -> [tabId, frameId] = [sender.tab.id, sender.frameId] (frameIdsForTab[tabId] ?= []).push frameId port.postMessage name: "registerFrameId", chromeFrameId: frameId port.onDisconnect.addListener -> if frameId == 0 # This is the top frame in the tab. delete frameIdsForTab[tabId] else if tabId of frameIdsForTab frameIdsForTab[tabId] = frameIdsForTab[tabId].filter (fId) -> fId != frameId # Sub-frames can connect before the top frame; so we can't rely on seeing the top frame at all. delete frameIdsForTab[tabId] if frameIdsForTab[tabId].length == 0 handleFrameFocused = (request, sender) -> [tabId, frameId] = [sender.tab.id, sender.frameId] # This might be the first time we've heard from this tab. frameIdsForTab[tabId] ?= [] # Cycle frameIdsForTab to the focused frame. 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) -> frames ||= [] # 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]...] # 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. bgLog = (request, sender) -> logMessage "#{sender.tab.id}/#{request.frameId} #{request.message}", sender # Port handler mapping portHandlers = completions: handleCompletions domReady: Frames.onConnect.bind Frames sendRequestHandlers = runBackgroundCommand: runBackgroundCommand getCurrentTabUrl: getCurrentTabUrl openUrlInNewTab: TabOperations.openUrlInNewTab openUrlInIncognito: TabOperations.openUrlInIncognito openUrlInCurrentTab: TabOperations.openUrlInCurrentTab openOptionsPageInNewTab: openOptionsPageInNewTab frameFocused: handleFrameFocused nextFrame: (request) -> BackgroundCommands.nextFrame 1, request.frameId copyToClipboard: copyToClipboard pasteFromClipboard: pasteFromClipboard isEnabledForUrl: isEnabledForUrl selectSpecificTab: selectSpecificTab createMark: Marks.create.bind(Marks) gotoMark: Marks.goto.bind(Marks) setIcon: setIcon sendMessageToFrames: sendMessageToFrames log: bgLog fetchFileContents: (request, sender) -> fetchFileContents request.fileName # We always remove chrome.storage.local/findModeRawQueryListIncognito on startup. chrome.storage.local.remove "findModeRawQueryListIncognito" # 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) -> 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. 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 TabOperations.openUrlInNewTab 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 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() root.TabOperations = TabOperations root.logMessage = logMessage