├── .dockerignore ├── .eslintrc.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE.md └── workflows │ ├── codeql.yml │ ├── luacheck.yml │ ├── shellcheck.yml │ ├── stale.yml │ ├── test.yml │ └── vint.yml ├── .gitignore ├── .luacheckrc ├── .vintrc.yaml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE.md ├── README.md ├── SECURITY.md ├── TROUBLESHOOTING.md ├── autoload ├── firenvim.vim └── firenvimft.vim ├── firenvim.gif ├── lua ├── firenvim-utils.lua ├── firenvim-websocket.lua └── firenvim.lua ├── package-lock.json ├── package.json ├── plugin └── firenvim.vim ├── release.sh ├── src ├── EventEmitter.ts ├── FirenvimElement.ts ├── KeyHandler.ts ├── Neovim.ts ├── Stdin.ts ├── Stdout.ts ├── autofill.ts ├── background.ts ├── browserAction.html ├── browserAction.ts ├── content.ts ├── firenvim.d.ts ├── frame.ts ├── index.html ├── manifest.json ├── options.html ├── page.ts ├── renderer.ts ├── testing │ ├── background.ts │ ├── content.ts │ ├── frame.ts │ └── rpc.ts └── utils │ ├── configuration.ts │ ├── keys.ts │ └── utils.ts ├── static └── firenvim.svg ├── tests ├── _common.ts ├── _coverageserver.ts ├── _vimrc.ts ├── chrome.ts ├── firefox.ts └── pages │ ├── ace.html │ ├── chat.html │ ├── codemirror.html │ ├── contenteditable.html │ ├── disappearing.html │ ├── dynamic.html │ ├── dynamic_nested.html │ ├── focusnext.html │ ├── focusnext2.html │ ├── input.html │ ├── monaco.html │ ├── parentframe.html │ ├── resize.html │ └── simple.html ├── tsconfig.json └── webpack.config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | # Exclude docker related files so that the docker layer cache won't be 2 | # invalidated when editing them because the build context changes. 3 | Dockerfile 4 | .dockerignore 5 | .git 6 | *.md 7 | !ISSUE_TEMPLATE.md 8 | 9 | # Copied from gitignore 10 | node_modules/ 11 | npm-debug.log 12 | target/ 13 | tests/pages/github.html 14 | .nyc_output 15 | failures.txt 16 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/eslint-recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "globals": { 12 | "browser": "readonly" 13 | }, 14 | "ignorePatterns": ["target/*", "tests/*"], 15 | "parser": "@typescript-eslint/parser", 16 | "parserOptions": { 17 | "ecmaVersion": 12, 18 | "sourceType": "module" 19 | }, 20 | "plugins": [ 21 | "@typescript-eslint" 22 | ], 23 | "rules": { 24 | "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], 25 | "@typescript-eslint/no-explicit-any": "off", 26 | "@typescript-eslint/no-extra-semi": "off", 27 | "no-async-promise-executor": "off", 28 | "no-constant-condition": ["error", { "checkLoops": false }], 29 | "no-inner-declarations": "off", 30 | "no-unused-vars": "off", 31 | "prefer-spread": "off" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: glacambre 4 | liberapay: glacambre 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | - OS Version: 11 | - Browser Version: 12 | - Browser Addon Version: 13 | - Neovim Plugin Version: 14 | 15 | ### What I tried to do 16 | 17 | 18 | ### What happened 19 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: "50 22 * * 3" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ javascript ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.github/workflows/luacheck.yml: -------------------------------------------------------------------------------- 1 | name: Luacheck 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | luacheck: 13 | strategy: 14 | fail-fast: false 15 | 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@master 21 | - name: Install luacheck 22 | run: sudo apt-get install lua-check 23 | - name: Run luacheck 24 | run: luacheck . 25 | -------------------------------------------------------------------------------- /.github/workflows/shellcheck.yml: -------------------------------------------------------------------------------- 1 | name: Shellcheck 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | shellcheck: 13 | strategy: 14 | fail-fast: false 15 | 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@master 21 | - name: Run Shellcheck 22 | run: find . -iname "*.sh" -exec shellcheck "{}" "+" 23 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | schedule: 4 | - cron: '0 0 * * *' 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v4 11 | with: 12 | days-before-stale: 31 13 | days-before-close: 0 14 | stale-issue-message: '' 15 | stale-pr-message: '' 16 | close-issue-message: 'Closing this issue because it has been awaiting a response from its author for more than a month. Please provide the requested information and this issue will be re-opened.' 17 | only-issue-labels: 'blocked,awaiting-author' 18 | only-pr-labels: 'Stale' 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | env: 12 | TESTING: 1 13 | 14 | jobs: 15 | tslint: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@master 21 | - name: Install NPM dependencies 22 | run: npm ci 23 | - name: Install eslint 24 | run: npm install eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser 25 | - name: Run ESlint 26 | run: '"./node_modules/.bin/eslint" "./**/*.ts"' 27 | - name: Run addons-linter 28 | run: 'npm run build && npm run pack && "./node_modules/.bin/addons-linter" target/xpi/firefox-latest.xpi' 29 | 30 | test: 31 | needs: tslint 32 | 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | os: [ubuntu, macos, windows] 37 | browser: [firefox, chrome] 38 | neovim: [stable, nightly] 39 | 40 | runs-on: ${{ matrix.os }}-latest 41 | 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@master 45 | - name: Install ffmpeg (Linux) 46 | if: matrix.os == 'ubuntu' 47 | run: sudo apt update -y && sudo apt install -y ffmpeg 48 | - name: Install ffmpeg (MacOs) 49 | if: matrix.os == 'macos' 50 | run: curl -JL https://evermeet.cx/ffmpeg/get/zip --output ffmpeg.zip && unzip ffmpeg.zip && echo "$PWD" >> $GITHUB_PATH 51 | - name: Install ffmpeg (Windows) 52 | if: matrix.os == 'windows' 53 | run: choco install ffmpeg 54 | - name: Install Firefox Dev Edition (Linux) 55 | if: matrix.browser == 'firefox' && matrix.os == 'ubuntu' 56 | run: | 57 | sudo npm install -g get-firefox 58 | get-firefox --platform linux --branch devedition --extract --target $HOME 59 | echo "$HOME/firefox" >> $GITHUB_PATH 60 | - name: Install Firefox Dev Edition (MacOS) 61 | if: matrix.browser == 'firefox' && matrix.os == 'macos' 62 | run: | 63 | brew install --cask firefox@developer-edition 64 | echo "/Applications/Firefox Developer Edition.app/Contents/MacOS/" >> $GITHUB_PATH 65 | brew install geckodriver 66 | dirname "$(find /opt/homebrew/Cellar/geckodriver -iname geckodriver -type f -perm +111)" >> $GITHUB_PATH 67 | - name: Install Firefox Dev Edition (Windows) 68 | if: matrix.browser == 'firefox' && matrix.os == 'windows' 69 | run: | 70 | choco install firefox-dev --pre 71 | echo "C:\Program Files\Firefox Dev Edition" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append 72 | - name: Install Neovim 73 | uses: rhysd/action-setup-vim@v1.4.1 74 | with: 75 | neovim: true 76 | version: ${{ matrix.neovim }} 77 | - name: Install NPM dependencies 78 | timeout-minutes: 5 79 | run: npm ci 80 | - name: Build Firenvim 81 | timeout-minutes: 1 82 | run: npm run webpack -- --env=${{ matrix.browser }}-testing 83 | - name: Instrument Firenvim (Linux/Firefox/Stable only) 84 | if: matrix.os == 'ubuntu' && matrix.browser == 'firefox' && matrix.neovim == 'stable' 85 | run: npm run nyc -- instrument --in-place target/${{ matrix.browser }} 86 | - name: Pack Firenvim (Firefox) 87 | if: matrix.browser == 'firefox' 88 | run: npm run pack 89 | - name: Install Manifest 90 | run: npm run install_manifests 91 | - name: Test (Linux) 92 | timeout-minutes: 15 93 | if: matrix.os == 'ubuntu' 94 | # Multiple attempts to increase FPS to 60 have been made: use libx264, 95 | # using raw output... Nothing worked. Spending more effort on this will 96 | # only result in suffering and wasted time. 97 | run: xvfb-run --auto-servernum -s "-screen 0 920x690x24" sh -c '(sleep 3 && ffmpeg -loglevel error -t 240 -an -video_size 920x690 -framerate 30 -f x11grab -i $DISPLAY video.webm) & npm run jest -- ${{ matrix.browser }}' 98 | - name: Test (MacOS) 99 | if: matrix.os == 'macos' 100 | timeout-minutes: 15 101 | run: | 102 | ffmpeg -loglevel warning -t 240 -framerate 30 -f avfoundation -i "0:0" video.webm & 103 | exit_code=0 && npm run jest -- ${{ matrix.browser }} || exit_code=$? && sleep 20 && exit $exit_code 104 | - name: Test (Windows) 105 | if: matrix.os == 'windows' 106 | timeout-minutes: 15 107 | run: | 108 | Start-Job { ffmpeg -loglevel warning -t 240 -framerate 30 -f gdigrab -i desktop D:\a\firenvim\firenvim\video.webm } 109 | npm run jest -- ${{ matrix.browser }} 110 | Get-Job | Wait-Job 111 | - name: Artifact upload (failure only) 112 | uses: actions/upload-artifact@v4 113 | if: failure() 114 | with: 115 | name: ${{ matrix.os }}-${{ matrix.browser }}-${{ matrix.neovim }} 116 | path: | 117 | video.webm 118 | failures.txt 119 | retention-days: 2 120 | - name: Process Coverage Report (Coverage only) 121 | uses: glacambre/action-compare-coverage@master 122 | if: matrix.os == 'ubuntu' && matrix.browser == 'firefox' && matrix.neovim == 'stable' 123 | with: 124 | github_token: ${{secrets.GITHUB_TOKEN}} 125 | -------------------------------------------------------------------------------- /.github/workflows/vint.yml: -------------------------------------------------------------------------------- 1 | name: Vint 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | vint: 13 | strategy: 14 | fail-fast: false 15 | 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@master 21 | - name: Setup dependencies 22 | run: pip install setuptools vim-vint 23 | - name: Run Vimscript Linter 24 | run: vint . 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | target/ 4 | tests/pages/github.html 5 | .nyc_output 6 | failures.txt 7 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | globals = { "vim"; "bit" } 2 | -------------------------------------------------------------------------------- /.vintrc.yaml: -------------------------------------------------------------------------------- 1 | cmdargs: 2 | severity: style_problem 3 | color: true 4 | env: 5 | neovim: true 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Firenvim 2 | 3 | Thanks a lot for thinking about contributing to Firenvim! Please do not hesitate to ask me questions, either by opening [github issues](https://github.com/glacambre/firenvim/issues), joining the matrix [chat room](https://app.element.io/#/room/#firenvim:matrix.org) or by sending me emails (you can find my email by running `git log` in the git repository). 4 | 5 | ## Building Firenvim 6 | 7 | ### Using Docker 8 | 9 | Installing from source using docker requires docker 18.09 or higher for [BuildKit support](https://docs.docker.com/develop/develop-images/build_enhancements/). Older Docker versions will build the required files into the image, but will not copy them into the host. 10 | 11 | ```sh 12 | git clone https://github.com/glacambre/firenvim 13 | cd firenvim 14 | DOCKER_BUILDKIT=1 docker build . -t firenvim --output target 15 | ``` 16 | 17 | ### Without Docker 18 | 19 | Building without Docker requires NodeJS, npm, and Neovim >= 0.4. 20 | 21 | Install Firenvim like a regular vim plugin (either by changing your runtime path manually or by [using your favourite plugin manager](README.md#installing)). 22 | 23 | Then run the following commands: 24 | ```sh 25 | git clone https://github.com/glacambre/firenvim 26 | cd firenvim 27 | npm install 28 | npm run build 29 | npm run install_manifests 30 | ``` 31 | 32 | These commands should create three directories: `target/chrome`, `target/firefox` and `target/xpi`. 33 | 34 | ## Installing the addon 35 | 36 | ### Google Chrome/Chromium 37 | 38 | Go to `chrome://extensions`, enable "Developer mode", click on `Load unpacked` and select the `target/chrome` directory. 39 | 40 | ### Firefox 41 | 42 | There are multiple ways to install add-ons from files on Firefox. If you just want to use Firenvim, use the regular mode. If you want to test, debug and frequently change Firenvim's source code, use the dev mode. 43 | 44 | To install Firenvim in regular mode, go to `about:addons`, click on the cog icon, select `install addon from file` and select `target/xpi/firenvim-XXX.zip` (note: this might require setting `xpinstall.signatures.required` to false in `about:config`). 45 | 46 | To install Firenvim in dev mode, go to `about:debugging`, click "Load Temporary Add-On" and select `target/firefox/manifest.json`. 47 | 48 | ## Working on Firenvim 49 | 50 | `npm run build` is slow and performs lots of checks. In order to iterate faster, you can use `"$(npm bin)/webpack --env=firefox"` or `"$(npm bin)/webpack" --env=chrome` to build only for the target you care about. Make sure you click the "reload" button in your browser every time you reload Firenvim. 51 | 52 | Firenvim's architecture is briefly described in [SECURITY.md](SECURITY.md). Firenvim is a webextension (it is a good idea to keep the [webextension documentation](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions) at hand if you're not familiar with webextension development). Webextensions are split in multiple processes. These processes communicate by sending messages to each other and have different entry points. Firenvim's entry points are: 53 | 54 | - src/background.ts: Entry point of the background process. 55 | - src/content.ts: Entry point of the content process (firefox & chrome only). 56 | - src/frame.ts: Entry point of the Neovim Frame process. 57 | - src/browserAction.ts: Entry point of the browser action process. 58 | 59 | ### Background process 60 | 61 | The background process is started on browser startup and takes care of several tasks: 62 | 63 | - Starting the Neovim server and loading the settings from your init.vim. 64 | - Handling browser shortcuts such as ``, `` or `` that cannot be overridden by the Neovim Frame process. 65 | - Logging errors and sending them to the browserAction. 66 | - Forwarding messages from the Neovim Frame process to the content process and vice versa. 67 | 68 | ### Content 69 | 70 | A Content process is created for each new tab. The tasks it performs are: 71 | 72 | - Creating event listeners to detect when the user tries to interact with a "writable" element to then spawn a Neovim Frame process. 73 | - Retrieving the content of said element and sending it to the Neovim Frame process. 74 | - Detecting when the "writable" element disappears or is resized, to hide or resize the Neovim Frame. 75 | - Writing the content of the Neovim Frame back to the "writable" element. 76 | 77 | Reading and writing the content of "writable" elements requires interacting with different kinds of editors (CodeMirror, Ace, Monaco, Textareas, contenteditable...). This is handled by the [editor-adapter](https://github.com/glacambre/editor-adapter) library I created. 78 | 79 | ### Neovim Frame process 80 | 81 | Neovim Frame process are created for each "writable" element the user wants to interact with. The role of the Neovim Frame process is to connect to the Neovim server started by the background process. This is done with a websocket. Once the connection has been made, the Neovim Frame process forwards keypresses to the Neovim server and displays the resulting screen updates. Handling keypresses is performed in `src/input.ts` by relying on the KeyHandler instantiated in `src/frame.ts`. Updating the screen is performed by `src/renderer.ts`. 82 | The Neovim Frame process creates a `BufWrite` autocommand to detect when the buffer is written to the disk. When this happens, it sends a request to the Content process and asks it to update the content of the "writable" element. 83 | 84 | ### Browser Action process 85 | 86 | The browser action process corresponds to the small Firenvim button next to the URL bar. It is created every time the user clicks on the button. It displays errors, warnings and lets the background script know when users click on the button to reload their configuration or to disable firenvim in the current tab. 87 | 88 | ## Testing your changes 89 | 90 | The CI tests changes automatically, so running tests on your machine is not required. If you do want to test Firenvim on your machine, you will need to install either Geckodriver (firefox) or Chromedriver (Chrome & Chromium). Once that is done, run `npm run test-firefox` or `npm run test-chrome`. This will build the add-on in testing mode, load it in a browser and run a few tests to make sure nothing is particularly broken. 91 | 92 | Writing new tests is currently rather complicated, so feel free to let me handle that if you don't want to deal with it. 93 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine AS build-stage 2 | SHELL ["/bin/sh", "-o", "pipefail", "-c"] 3 | 4 | RUN apk add --no-cache neovim 5 | 6 | COPY . /firenvim 7 | WORKDIR /firenvim 8 | RUN npm install 9 | RUN npm run build 10 | RUN npm run install_manifests 11 | 12 | FROM scratch AS export-stage 13 | COPY --from=build-stage /firenvim/target / 14 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Firenvim Architecture and Security mitigations 2 | 3 | ## Architecture 4 | 5 | Webextensions are made of [several kinds of processes](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Anatomy_of_a_WebExtension) (also named "scripts"). Firenvim uses three kinds of scripts: 6 | - The [background script](src/background.ts). 7 | - The [content script](src/content.ts). 8 | - The ["frame" script](src/frame.ts). 9 | 10 | These scripts have different permissions. For example, the background script can start new processes on your computer but cannot access the content of your tabs. The content script has the opposite permissions. The frame script is just a kind of content script that executes in a frame. 11 | 12 | When you launch your browser (or install Firenvim), the background script starts a new NeoVim process and writes a randomly-generated 256-bit password to its stdin. The NeoVim process binds itself to a random TCP port and sends the port number to the background script by writing to stdout. 13 | 14 | When you open a new tab, the content script adds event listeners to text areas. When you focus one of the text areas the content script is listening to, it creates a new frame and places it on top of the text area. 15 | 16 | When it is created, the frame script asks the background script for the port and password of the NeoVim process it started earlier by using a webextension-only API. The frame script then creates a plaintext (as opposed to TLS) [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications) and sends the password as part of the websocket handshake. 17 | 18 | When the NeoVim process notices a new connection, it makes sure that: 19 | - The password is in the handshake. 20 | - The handshake really is a websocket handshake. 21 | 22 | If any of these conditions isn't met, the NeoVim process closes its socket and port and then shuts itself down. 23 | 24 | After a successful websocket handshake, the frame script and neovim process communicate with neovim's msgpack-rpc protocol. 25 | 26 | ## Threats 27 | 28 | ### Malicious page 29 | 30 | A malicious page could create an infinite amount of textareas and focus them all; this could result in PID and/or port and/or memory exhaustion. You can [sandbox firenvim](https://github.com/glacambre/firenvim/issues/238) to protect yourself from that. Finer-grained controls will be implemented some day. 31 | 32 | A malicious page could try to connect to the NeoVim process started by the background script with its own-websocket. However, it would have to guess the port and password the NeoVim process was started with in order to be able to send commands to NeoVim. 33 | 34 | A malicious page could try to send key events to the neovim frame. However, only the script inside the frame listens for key events and a page can't send key events to a child frame (and even then, the frame script makes sure that [events are trusted](https://developer.mozilla.org/en-US/docs/Web/API/Event/isTrusted)). 35 | 36 | A malicious page could try to send malicious messages to the frame with the [postMessage](https://developer.mozilla.org/en-US/docs/Web/API/Client/postMessage) api but the frame script doesn't listen for these. 37 | 38 | ### Malicious extensions 39 | 40 | A malicious extension can do everything a page can (and these attacks are mitigated in the same way). There's two more attack vectors to consider: 41 | 42 | A malicious extension cannot start neovim unless its id matches Firenvim's. I have no idea what Mozilla does in order to prevent an extension from stealing another extension's id. I assume they check extension ids when publishing extensions on addons.mozilla.org. However, if this is the only protection in place, this would mean that you're not safe from this kind of attack if you install your extensions from somewhere else. 43 | 44 | Another attack a malicious extension could attempt is to use the webrequest extension API in order to intercept Firenvim's websocket connection request, inspect its content, cancel it and then connect to Neovim while pretending it is Firenvim. However, this cannot work as the webrequest extension API does not offer the ability to intercept requests from other extensions. 45 | 46 | ### Malicious actors on LAN 47 | 48 | The neovim process binds itself to 127.0.0.1, so malicious actors on your LAN should be unable to interact with either your webextension or your neovim process. 49 | 50 | ### Malicious software on your computer 51 | 52 | Malicious software on your computer could try to connect to the neovim process but they would have to find out what port and password. This information lives either in firefox or neovim's RAM. If you're running malicious software that can read your RAM, you probably have bigger problems than a webextension that lets you use neovim from your browser. 53 | 54 | ## Sandboxing Firenvim 55 | 56 | If you want to sandbox Firenvim, you can do so with apparmor. [This github issue](https://github.com/glacambre/firenvim/issues/238) has a bit more information about that. 57 | -------------------------------------------------------------------------------- /TROUBLESHOOTING.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting Firenvim 2 | 3 | If you're having issues with Firenvim, here are the steps you can take in order to check if everything is correctly set up on your side. 4 | 5 | ## Make sure Flatpak and Snap are not preventing Firenvim from working 6 | 7 | If your browser is installed through Snap or Flatpak, sandboxing mechanisms might be preventing the browser from starting Neovim. You can confirm this by running: 8 | 9 | ``` 10 | flatpak permissions webextensions 11 | ``` 12 | 13 | If the output of this command shows that Snap/Flatpak are preventing Firenvim from running, you need to run `flatpak permission-set webextensions firenvim snap.firefox yes` to change that. 14 | 15 | ## Make sure the neovim plugin is installed 16 | 17 | Run neovim without any arguments and then try to run the following line: 18 | ``` 19 | call firenvim#install(0) 20 | ``` 21 | 22 | - If this results in `Installed native matifest for ${browser}` being printed, the firenvim plugin is correctly installed in neovim and you can move on to the next troubleshooting step. 23 | 24 | - If this results in `No config detected for ${browser}` and `${browser}` is the browser you want to use firenvim with, this might be because your browser configuration files are in a non-standard directory. If this is the case, you will need to either create a symbolic link from your browser configuration directory to the expected one ([firefox](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_manifests#Manifest_location), [chrome](https://developer.chrome.com/apps/nativeMessaging#native-messaging-host-location)), or force-install Firenvim with `call firenvim#install(1)` and copy the contents of the default browser configuration directory ([firefox](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_manifests#Manifest_location), [chrome](https://developer.chrome.com/apps/nativeMessaging#native-messaging-host-location)) to your custom one. 25 | 26 | - If this results in `Unknown function: firenvim#install` being printed, then firenvim is not correctly installed in neovim and this is likely a configuration error from your side. Check your configuration again. 27 | 28 | - If this results in `nvim version >= 0.4.0 required. Aborting`, you know what to do :). 29 | 30 | ## Make sure the firenvim script has been created 31 | 32 | Running `call firenvim#install(0)` should have created a shell or batch script in `$XDG_DATA_HOME/firenvim` (on linux/osx, this usually is `$HOME/.local/share/firenvim`, on windows it's `%LOCALAPPDATA%\firenvim`). Make sure that the script exists and that it is executable. Try running it in a shell, like this: 33 | ```sh 34 | echo 'abcde{}' | ${XDG_DATA_HOME:-${HOME}/.local/share}/firenvim/firenvim 35 | ``` 36 | This should print a json object the content of which is the current version of the firenvim neovim plugin. If it doesn't, please open a new github issue. 37 | 38 | ## Make sure the firenvim native manifest has been created 39 | 40 | Running `call firenvim#install(0)` should also have created a file named `firenvim.json` in your browser's configuration directory. Make sure it exists: 41 | 42 | - On Linux: 43 | * For Firefox: `$HOME/.mozilla/native-messaging-hosts` 44 | * For Chrome: `$HOME/.config/google-chrome/NativeMessagingHosts/` 45 | * For Chromium: `$HOME/.config/chromium/NativeMessagingHosts/` 46 | - On OSX: 47 | * For Firefox: `$HOME/Library/Application Support/Mozilla/NativeMessagingHosts` 48 | * For Chrome: `$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts` 49 | * For Chromium: `$HOME/Library/Application Support/Chromium/NativeMessagingHosts` 50 | - On Windows: in `%LOCALAPPDATA%\firenvim` and check that the following registry keys exist and point to the native manifest: 51 | * For Firefox: `HKEY_CURRENT_USER\SOFTWARE\Mozilla\NativeMessagingHosts\firenvim` 52 | * For Chrome/Chromium: `HKEY_CURRENT_USER\SOFTWARE\Google\Chrome\NativeMessagingHosts\firenvim` 53 | 54 | Also check the content of this json file to make sure that the `path` key points to the firenvim script you checked the existence of in the previous step. If the json file is missing or if the `path` attribute is wrong, please open a new github issue. 55 | 56 | ## Make sure the browser extension can communicate with neovim 57 | 58 | In your browser, open the background console. This requires the following steps: 59 | 60 | - On Firefox: 61 | * Go to `about:debugging` 62 | * Select `This Firefox` in the left column. 63 | * Find firenvim. 64 | * Click on the `inspect` button. 65 | * If the console already contains messages, empty it by clicking on the trash icon. 66 | - On Chrome/ium: 67 | * Go to `chrome://extensions` 68 | * Enable Developer mode (the button is in the top right corner) 69 | * Find firenvim. 70 | * Click on the `background page` link. 71 | * If the console already contains messages, empty by pressing ``. 72 | 73 | Then, navigate to a page with a textarea. Open the content console (`` on both firefox and chrome/ium). If you're using firefox, also open and clear the Browser Console (``). Then, click on the textarea. This should result in messages being printed in the console. If it doesn't, try clicking on the Firenvim icon next to the urlbar. If no messages are logged there either, try clicking on the `Reload settings` button. 74 | 75 | ### Make sure firenvim can access your config files 76 | 77 | If your configs are not in `$HOME/.config/nvim` and the last step works with `-u NORC`, it could be that firenvim cannot access your config files. Try sourcing them (`:source [path to file]`) from inside firenvim. If this fails, move the configs into `$HOME/.config/nvim` and try sourcing them again. 78 | 79 | ## Make sure firenvim's $PATH is the same as neovim's 80 | 81 | Some operating systems (such as OSX) empty your browser's `$PATH`. This could be a problem if you want to use plugins that depend on other executables. In order to check if this is indeed happening, just run `echo $PATH` in your shell and `:!echo $PATH` in firenvim and compare the results. If they're different, this might be the culprit. 82 | 83 | In order to fix this, call firenvim#install() and give it a prologue that sets the right path for you, like this: 84 | ```sh 85 | nvim --headless -c "call firenvim#install(0, 'export PATH=\"$PATH\"')" -c quit 86 | ``` 87 | 88 | Note that this sets your `$PATH` in stone and that in order to update it you'll need to run the above command again. If you want to avoid doing that, you could also try the method described [here](https://github.com/glacambre/firenvim/issues/122#issuecomment-536348171). 89 | 90 | ## Print-debugging your init.vim 91 | 92 | You can't use `echo` or `echom` in your init.vim before Firenvim has been loaded and initialized. If you need to debug your init.vim, you could try one of these two apparoaches: 93 | - Append the messages you would normally `echom` to a list which you will only display after the `UiEnter` autocommand has been triggered. 94 | - Use `echoerr` instead and redirect Neovim's stderr to a file on your disk in the [firenvim script](#make-sure-the-firenvim-script-has-been-created) by appending `2>>/tmp/stderr | tee -a /tmp/stdout` at the end of the `exec` line. 95 | -------------------------------------------------------------------------------- /autoload/firenvimft.vim: -------------------------------------------------------------------------------- 1 | 2 | let s:patterns_to_ft = { 3 | \ '/github.com_.*\.txt$': 'markdown', 4 | \ '/\(\w\+\.\)*reddit\.com_.*\.txt$': 'markdown', 5 | \ '/stackoverflow.com_.*\.txt$': 'markdown', 6 | \ '/stackexchange.com_.*\.txt$': 'markdown', 7 | \ '/slack.com_.*\.txt$': 'markdown', 8 | \ '/gitter.com_.*\.txt$': 'markdown', 9 | \ '/riot.im_.*\.txt$': 'markdown', 10 | \ '/lobste.rs_.*\.txt$': 'markdown', 11 | \ '/cocalc.com_.*\.txt$': 'python', 12 | \ '/kaggleusercontent.com_.*\.txt$': 'python', 13 | \ } 14 | 15 | function! firenvimft#detect(buf) abort 16 | let l:name = nvim_buf_get_name(a:buf) 17 | if l:name !~? '/firenvim/.*\.txt$' 18 | return 0 19 | endif 20 | let l:ft = 'text' 21 | for l:pattern in keys(s:patterns_to_ft) 22 | if l:name =~? l:pattern 23 | call nvim_buf_set_option(a:buf, 'filetype', s:patterns_to_ft[l:pattern]) 24 | return 25 | endif 26 | endfor 27 | endfunction 28 | -------------------------------------------------------------------------------- /firenvim.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glacambre/firenvim/c4ab7d2aeb145cd93db8660cb134f771722f2b5e/firenvim.gif -------------------------------------------------------------------------------- /lua/firenvim-utils.lua: -------------------------------------------------------------------------------- 1 | if bit == nil then 2 | bit = require("bit") 3 | end 4 | 5 | -- base64 algorithm implemented from https://en.wikipedia.org/wiki/Base64 6 | -- It's really simple: for each group of three bytes, concat the bits and then 7 | -- split them into four values of 6 bits each, then look up said values in the 8 | -- base64 table 9 | local function base64(val) 10 | local b64 = { "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", 11 | "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", 12 | "X", "Y", "Z", "a", "b", "c", "d", "e", "f", "g", "h", "i", 13 | "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", 14 | "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6", 15 | "7", "8", "9", "+", "/" } 16 | 17 | local function char1(byte1) 18 | -- 252: 0b11111100 19 | return b64[bit.rshift(bit.band(string.byte(byte1), 252), 2) + 1] 20 | end 21 | 22 | local function char2(byte1, byte2) 23 | -- 3: 0b00000011 24 | return b64[bit.lshift(bit.band(string.byte(byte1), 3), 4) + 25 | -- 240: 0b11110000 26 | bit.rshift(bit.band(string.byte(byte2), 240), 4) + 1] 27 | end 28 | 29 | local function char3(byte2, byte3) 30 | -- 15: 0b00001111 31 | return b64[bit.lshift(bit.band(string.byte(byte2), 15), 2) + 32 | -- 192: 0b11000000 33 | bit.rshift(bit.band(string.byte(byte3), 192), 6) + 1] 34 | end 35 | 36 | local function char4(byte3) 37 | -- 63: 0b00111111 38 | return b64[bit.band(string.byte(byte3), 63) + 1] 39 | end 40 | 41 | local result = "" 42 | for byte1, byte2, byte3 in string.gmatch(val, "(.)(.)(.)") do 43 | result = result .. 44 | char1(byte1) .. 45 | char2(byte1, byte2) .. 46 | char3(byte2, byte3) .. 47 | char4(byte3) 48 | end 49 | -- The last bytes might not fit in a triplet so we need to pad them 50 | -- with zeroes 51 | if (string.len(val) % 3) == 1 then 52 | result = result .. 53 | char1(string.sub(val, -1)) .. 54 | char2(string.sub(val, -1), "\0") .. 55 | "==" 56 | elseif (string.len(val) % 3) == 2 then 57 | result = result .. 58 | char1(string.sub(val, -2, -2)) .. 59 | char2(string.sub(val, -2, -2), string.sub(val, -1)) .. 60 | char3(string.sub(val, -1), "\0") .. 61 | "=" 62 | end 63 | 64 | return result 65 | end 66 | 67 | -- Returns a 2-characters string the bits of which represent the argument 68 | local function to_16_bits_str(number) 69 | return string.char(bit.band(bit.rshift(number, 8), 255)) .. 70 | string.char(bit.band(number, 255)) 71 | end 72 | 73 | -- Returns a number representing the 2 first characters of the argument string 74 | local function to_16_bits_number(str) 75 | return bit.lshift(string.byte(str, 1), 8) + 76 | string.byte(str, 2) 77 | end 78 | 79 | -- Returns a 4-characters string the bits of which represent the argument 80 | local function to_32_bits_str(number) 81 | return string.char(bit.band(bit.rshift(number, 24), 255)) .. 82 | string.char(bit.band(bit.rshift(number, 16), 255)) .. 83 | string.char(bit.band(bit.rshift(number, 8), 255)) .. 84 | string.char(bit.band(number, 255)) 85 | end 86 | 87 | -- Returns a number representing the 4 first characters of the argument string 88 | local function to_32_bits_number(str) 89 | return bit.lshift(string.byte(str, 1), 24) + 90 | bit.lshift(string.byte(str, 2), 16) + 91 | bit.lshift(string.byte(str, 3), 8) + 92 | string.byte(str, 4) 93 | end 94 | 95 | -- Returns a 4-characters string the bits of which represent the argument 96 | -- Returns incorrect results on numbers larger than 2^32 97 | local function to_64_bits_str(number) 98 | return string.char(0) .. string.char(0) .. string.char(0) .. string.char(0) .. 99 | to_32_bits_str(number % 0xFFFFFFFF) 100 | end 101 | 102 | -- Returns a number representing the 8 first characters of the argument string 103 | -- Returns incorrect results on numbers larger than 2^48 104 | local function to_64_bits_number(str) 105 | return bit.lshift(string.byte(str, 2), 48) + 106 | bit.lshift(string.byte(str, 3), 40) + 107 | bit.lshift(string.byte(str, 4), 32) + 108 | bit.lshift(string.byte(str, 5), 24) + 109 | bit.lshift(string.byte(str, 6), 16) + 110 | bit.lshift(string.byte(str, 7), 8) + 111 | string.byte(str, 8) 112 | end 113 | 114 | -- Algorithm described in https://tools.ietf.org/html/rfc3174 115 | local function sha1(val) 116 | 117 | -- Mark message end with bit 1 and pad with bit 0, then add message length 118 | -- Append original message length in bits as a 64bit number 119 | -- Note: We don't need to bother with 64 bit lengths so we just add 4 to 120 | -- number of zeros used for padding and append a 32 bit length instead 121 | local padded_message = val .. 122 | string.char(128) .. 123 | string.rep(string.char(0), 64 - ((string.len(val) + 1 + 8) % 64) + 4) .. 124 | to_32_bits_str(string.len(val) * 8) 125 | 126 | -- Blindly implement method 1 (section 6.1) of the spec without 127 | -- understanding a single thing 128 | local H0 = 0x67452301 129 | local H1 = 0xEFCDAB89 130 | local H2 = 0x98BADCFE 131 | local H3 = 0x10325476 132 | local H4 = 0xC3D2E1F0 133 | 134 | -- For each block 135 | for M = 0, string.len(padded_message) - 1, 64 do 136 | local block = string.sub(padded_message, M + 1) 137 | local words = {} 138 | -- Initialize 16 first words 139 | local i = 0 140 | for W = 1, 64, 4 do 141 | words[i] = to_32_bits_number(string.sub( 142 | block, 143 | W 144 | )) 145 | i = i + 1 146 | end 147 | 148 | -- Initialize the rest 149 | for t = 16, 79, 1 do 150 | words[t] = bit.rol( 151 | bit.bxor( 152 | words[t - 3], 153 | words[t - 8], 154 | words[t - 14], 155 | words[t - 16] 156 | ), 157 | 1 158 | ) 159 | end 160 | 161 | local A = H0 162 | local B = H1 163 | local C = H2 164 | local D = H3 165 | local E = H4 166 | 167 | -- Compute the hash 168 | for t = 0, 79, 1 do 169 | local TEMP 170 | if t <= 19 then 171 | TEMP = bit.bor( 172 | bit.band(B, C), 173 | bit.band( 174 | bit.bnot(B), 175 | D 176 | ) 177 | ) + 178 | 0x5A827999 179 | elseif t <= 39 then 180 | TEMP = bit.bxor(B, C, D) + 0x6ED9EBA1 181 | elseif t <= 59 then 182 | TEMP = bit.bor( 183 | bit.bor( 184 | bit.band(B, C), 185 | bit.band(B, D) 186 | ), 187 | bit.band(C, D) 188 | ) + 189 | 0x8F1BBCDC 190 | elseif t <= 79 then 191 | TEMP = bit.bxor(B, C, D) + 0xCA62C1D6 192 | end 193 | TEMP = (bit.rol(A, 5) + TEMP + E + words[t]) 194 | E = D 195 | D = C 196 | C = bit.rol(B, 30) 197 | B = A 198 | A = TEMP 199 | end 200 | 201 | -- Force values to be on 32 bits 202 | H0 = (H0 + A) % 0x100000000 203 | H1 = (H1 + B) % 0x100000000 204 | H2 = (H2 + C) % 0x100000000 205 | H3 = (H3 + D) % 0x100000000 206 | H4 = (H4 + E) % 0x100000000 207 | end 208 | 209 | return to_32_bits_str(H0) .. 210 | to_32_bits_str(H1) .. 211 | to_32_bits_str(H2) .. 212 | to_32_bits_str(H3) .. 213 | to_32_bits_str(H4) 214 | end 215 | 216 | return { 217 | base64 = base64, 218 | sha1 = sha1, 219 | to_16_bits_str = to_16_bits_str, 220 | to_16_bits_number = to_16_bits_number, 221 | to_32_bits_str = to_32_bits_str, 222 | to_32_bits_number = to_32_bits_number, 223 | to_64_bits_str = to_64_bits_str, 224 | to_64_bits_number = to_64_bits_number, 225 | } 226 | -------------------------------------------------------------------------------- /lua/firenvim-websocket.lua: -------------------------------------------------------------------------------- 1 | if bit == nil then 2 | bit = require("bit") 3 | end 4 | 5 | local utils = require("firenvim-utils") 6 | 7 | local opcodes = { 8 | text = 1, 9 | binary = 2, 10 | close = 8, 11 | ping = 9, 12 | pong = 10, 13 | } 14 | 15 | -- The client's handshake is described here: https://tools.ietf.org/html/rfc6455#section-4.2.1 16 | local function parse_headers() 17 | local headerend = nil 18 | local headerstring = "" 19 | -- Accumulate header lines until we have them all 20 | while headerend == nil do 21 | headerstring = headerstring .. coroutine.yield(nil, nil, nil) 22 | headerend = string.find(headerstring, "\r?\n\r?\n") 23 | end 24 | 25 | -- request is the first line of any HTTP request: `GET /file HTTP/1.1` 26 | local request = string.sub(headerstring, 1, string.find(headerstring, "\n")) 27 | -- rest is any data that might follow the actual HTTP request 28 | -- (GET+key/values). If I understand the spec correctly, it should be 29 | -- empty. 30 | local rest = string.sub(headerstring, headerend + 2) 31 | 32 | local keyvalues = string.sub(headerstring, string.len(request)) 33 | local headerobj = {} 34 | for key, value in string.gmatch(keyvalues, "([^:]+) *: *([^\r\n]+)\r?\n") do 35 | headerobj[key] = value 36 | end 37 | return request, headerobj, rest 38 | end 39 | 40 | local function compute_key(key) 41 | return utils.base64(utils.sha1(key .. "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")) 42 | end 43 | 44 | -- The server's opening handshake is described here: https://tools.ietf.org/html/rfc6455#section-4.2.2 45 | local function accept_connection(headers) 46 | return "HTTP/1.1 101 Switching Protocols\n" .. 47 | "Connection: Upgrade\r\n" .. 48 | "Sec-WebSocket-Accept: " .. compute_key(headers["Sec-WebSocket-Key"]) .. "\r\n" .. 49 | "Upgrade: websocket\r\n" .. 50 | "\r\n" 51 | end 52 | 53 | -- Frames are described here: https://tools.ietf.org/html/rfc6455#section-5.2 54 | local function decode_frame() 55 | local frame = "" 56 | local result = {} 57 | while true do 58 | local current_byte = 1 59 | -- We need at least the first two bytes of header in order to 60 | -- start doing any kind of useful work: 61 | -- - One for the fin/rsv/opcode fields 62 | -- - One for the mask + payload length 63 | while (string.len(frame) < 2) do 64 | frame = frame .. coroutine.yield(nil) 65 | end 66 | 67 | result.fin = bit.band(bit.rshift(string.byte(frame, current_byte), 7), 1) == 1 68 | result.rsv1 = bit.band(bit.rshift(string.byte(frame, current_byte), 6), 1) == 1 69 | result.rsv2 = bit.band(bit.rshift(string.byte(frame, current_byte), 5), 1) == 1 70 | result.rsv3 = bit.band(bit.rshift(string.byte(frame, current_byte), 4), 1) == 1 71 | result.opcode = bit.band(string.byte(frame, current_byte), 15) 72 | current_byte = current_byte + 1 73 | 74 | result.mask = bit.rshift(string.byte(frame, current_byte), 7) == 1 75 | result.payload_length = bit.band(string.byte(frame, current_byte), 127) 76 | current_byte = current_byte + 1 77 | 78 | if result.payload_length == 126 then 79 | -- Payload length is on the next two bytes, make sure 80 | -- they're present 81 | while (string.len(frame) < current_byte + 2) do 82 | frame = frame .. coroutine.yield(nil) 83 | end 84 | 85 | result.payload_length = utils.to_16_bits_number(string.sub(frame, current_byte)) 86 | current_byte = current_byte + 2 87 | elseif result.payload_length == 127 then 88 | -- Payload length is on the next eight bytes, make sure 89 | -- they're present 90 | while (string.len(frame) < current_byte + 8) do 91 | frame = frame .. coroutine.yield(nil) 92 | end 93 | result.payload_length = utils.to_64_bits_number(string.sub(frame, current_byte)) 94 | print("Warning: payload length on 64 bits. Estimated:" .. result.payload_length) 95 | current_byte = current_byte + 8 96 | end 97 | 98 | while string.len(frame) < current_byte + result.payload_length do 99 | frame = frame .. coroutine.yield(nil) 100 | end 101 | 102 | result.masking_key = string.sub(frame, current_byte, current_byte + 4) 103 | current_byte = current_byte + 4 104 | 105 | result.payload = "" 106 | local payload_end = current_byte + result.payload_length - 1 107 | local j = 1 108 | for i = current_byte, payload_end do 109 | result.payload = result.payload .. string.char(bit.bxor( 110 | string.byte(frame, i), 111 | string.byte(result.masking_key, j) 112 | )) 113 | j = (j % 4) + 1 114 | end 115 | current_byte = payload_end + 1 116 | frame = string.sub(frame, current_byte) .. coroutine.yield(result) 117 | end 118 | end 119 | 120 | -- The format is the same as the client's ( 121 | -- https://tools.ietf.org/html/rfc6455#section-5.2 ), except we don't need to 122 | -- mask the data. 123 | local function encode_frame(data) 124 | -- 130: 10000010 125 | -- Fin: 1 126 | -- RSV{1,2,3}: 0 127 | -- Opcode: 2 (binary frame) 128 | local header = string.char(130) 129 | local len 130 | if string.len(data) < 126 then 131 | len = string.char(string.len(data)) 132 | elseif string.len(data) < 65536 then 133 | len = string.char(126) .. utils.to_16_bits_str(string.len(data)) 134 | else 135 | len = string.char(127) .. utils.to_64_bits_str(string.len(data)) 136 | end 137 | return header .. len .. data 138 | end 139 | 140 | local function pong_frame(decoded_frame) 141 | -- 137: 10001010 142 | -- Fin: 1 143 | -- RSV{1,2,3}: 0 144 | -- Opcode: 0xA (pong) 145 | return string.char(138) .. decoded_frame.payload_length .. decoded_frame.payload_data 146 | end 147 | 148 | local function close_frame() 149 | local frame = encode_frame("") 150 | return string.char(136) .. string.sub(frame, 2) 151 | end 152 | 153 | return { 154 | accept_connection = accept_connection, 155 | close_frame = close_frame, 156 | decode_frame = decode_frame, 157 | encode_frame = encode_frame, 158 | opcodes = opcodes, 159 | parse_headers = parse_headers, 160 | pong_frame = pong_frame, 161 | } 162 | -------------------------------------------------------------------------------- /lua/firenvim.lua: -------------------------------------------------------------------------------- 1 | local websocket = require("firenvim-websocket") 2 | 3 | local function close_server(server) 4 | vim.loop.close(server) 5 | -- Work around https://github.com/glacambre/firenvim/issues/49 Note: 6 | -- important to do this before nvim_command("qall") because it breaks 7 | vim.loop.new_timer():start(1000, 100, (function() os.exit() end)) 8 | vim.schedule(function() 9 | vim.api.nvim_command("qall!") 10 | end) 11 | end 12 | 13 | local function connection_handler(server, sock, token) 14 | local pipe = vim.loop.new_pipe(false) 15 | local self_addr = vim.v.servername 16 | if self_addr == nil then 17 | self_addr = os.getenv("NVIM_LISTEN_ADDRESS") 18 | end 19 | vim.loop.pipe_connect(pipe, self_addr, function(err) 20 | assert(not err, err) 21 | end) 22 | 23 | local header_parser = coroutine.create(websocket.parse_headers) 24 | coroutine.resume(header_parser, "") 25 | local request, headers = nil, nil 26 | 27 | local frame_decoder = coroutine.create(websocket.decode_frame) 28 | coroutine.resume(frame_decoder, nil) 29 | local decoded_frame = nil 30 | local current_payload = "" 31 | 32 | return function(err, chunk) 33 | assert(not err, err) 34 | if not chunk then 35 | return close_server() 36 | end 37 | local _ 38 | if not headers then 39 | _ , request, headers = coroutine.resume(header_parser, chunk) 40 | if not request then 41 | -- Coroutine hasn't parsed the request 42 | -- because it isn't complete yet 43 | return 44 | end 45 | if not (string.match(request, "^GET /" .. token .. " HTTP/1.1\r\n$") 46 | and string.match(headers["Connection"] or "", "Upgrade") 47 | and string.match(headers["Upgrade"] or "", "websocket")) 48 | then 49 | -- Connection didn't give us the right 50 | -- token, isn't a websocket request or 51 | -- hasn't been made from a webextension 52 | -- context: abort. 53 | sock:close() 54 | close_server(server) 55 | return 56 | end 57 | sock:write(websocket.accept_connection(headers)) 58 | pipe:read_start(function(error, v) 59 | assert(not error, error) 60 | if v then 61 | sock:write(websocket.encode_frame(v)) 62 | end 63 | end) 64 | return 65 | end 66 | _, decoded_frame = coroutine.resume(frame_decoder, chunk) 67 | while decoded_frame ~= nil do 68 | if decoded_frame.opcode == websocket.opcodes.binary then 69 | current_payload = current_payload .. decoded_frame.payload 70 | if decoded_frame.fin then 71 | pipe:write(current_payload) 72 | current_payload = "" 73 | end 74 | elseif decoded_frame.opcode == websocket.opcodes.ping then 75 | sock:write(websocket.pong_frame(decoded_frame)) 76 | return 77 | elseif decoded_frame.opcode == websocket.opcodes.close then 78 | sock:write(websocket.close_frame(decoded_frame)) 79 | sock:close() 80 | pipe:close() 81 | close_server(server) 82 | return 83 | end 84 | _, decoded_frame = coroutine.resume(frame_decoder, "") 85 | end 86 | end 87 | end 88 | 89 | local function firenvim_start_server(token) 90 | local server = vim.loop.new_tcp() 91 | server:nodelay(true) 92 | server:bind('127.0.0.1', 0) 93 | server:listen(128, function(err) 94 | assert(not err, err) 95 | local sock = vim.loop.new_tcp() 96 | server:accept(sock) 97 | sock:read_start(connection_handler(server, sock, token)) 98 | end) 99 | return server:getsockname().port 100 | end 101 | 102 | return { 103 | start_server = firenvim_start_server, 104 | } 105 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "glacambre", 3 | "dependencies": { 4 | "@types/jest": "27.0.2", 5 | "@types/msgpack-lite": "0.1.8", 6 | "@types/node": "16.10.2", 7 | "@types/selenium-webdriver": "4.1.8", 8 | "@types/ws": "^8.2.0", 9 | "ace-builds": "^1.4.13", 10 | "addons-linter": "^6.20.0", 11 | "codemirror": "^5.63.1", 12 | "copy-webpack-plugin": "9.0.1", 13 | "editor-adapter": "^0.0.5", 14 | "imports-loader": "^3.1.1", 15 | "istanbul-lib-coverage": "^3.2.0", 16 | "jest": "27.3.1", 17 | "monaco-editor": "^0.28.1", 18 | "msgpack-lite": "0.1.26", 19 | "nyc": "^15.1.0", 20 | "selenium-webdriver": "4.6.0", 21 | "sharp": "^0.33.1", 22 | "ts-jest": "27.0.7", 23 | "ts-loader": "^9.2.6", 24 | "typescript": "4.8.4", 25 | "web-ext": "^8.3.0", 26 | "web-ext-types": "3.2.1", 27 | "webextension-polyfill": "0.10.0", 28 | "webpack": "^5.89.0", 29 | "webpack-cli": "4.10.0", 30 | "ws": "^7.5.2" 31 | }, 32 | "description": "Turn your browser into a Neovim GUI.", 33 | "keywords": [ 34 | "chrome", 35 | "chromium", 36 | "firefox", 37 | "nvim", 38 | "vim", 39 | "webext", 40 | "webextension" 41 | ], 42 | "license": "GPL-3.0", 43 | "name": "Firenvim", 44 | "scripts": { 45 | "build": "webpack && web-ext build --source-dir target/firefox --artifacts-dir target/xpi --overwrite-dest -n firefox-latest.xpi", 46 | "clean": "rm -rf target", 47 | "install_manifests": "nvim --headless -u NORC -i NONE -n -c \":set rtp+=.\" -c \"call firenvim#install(1)\" -c \"quit\"", 48 | "jest": "jest", 49 | "nyc": "nyc", 50 | "lint": "addons-linter target/xpi/firefox-latest.xpi", 51 | "pack": "web-ext build --source-dir target/firefox --artifacts-dir target/xpi --overwrite-dest", 52 | "tests": "npm run test-firefox && npm run test-chrome", 53 | "test-firefox": "webpack --env=firefox-testing && nyc instrument --in-place target/firefox && web-ext build --source-dir target/firefox --artifacts-dir target/xpi --overwrite-dest && jest firefox", 54 | "test-chrome": "webpack --env=chrome-testing && nyc instrument --in-place target/chrome && jest chrome", 55 | "webpack": "webpack" 56 | }, 57 | "jest": { 58 | "bail": 1, 59 | "testRegex": "/tests/[^_].*\\.(jsx?|tsx?)$", 60 | "transform": { 61 | "^.+\\.tsx?$": "ts-jest" 62 | }, 63 | "moduleFileExtensions": [ 64 | "json", 65 | "js", 66 | "ts" 67 | ] 68 | }, 69 | "version": "0.2.16", 70 | "devDependencies": { 71 | "@types/firefox-webext-browser": "^120.0.3" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /plugin/firenvim.vim: -------------------------------------------------------------------------------- 1 | if exists('g:firenvim_loaded') 2 | finish 3 | endif 4 | let g:firenvim_loaded = 1 5 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | if ! [ -e ./package.json ] ; then 4 | echo "Not in firenvim repository. Aborting." 5 | exit 1 6 | fi 7 | 8 | if [ "$1" = "" ] ; then 9 | echo "No new version specified. Aborting." 10 | exit 1 11 | fi 12 | 13 | if [ "$(git rev-parse --abbrev-ref HEAD)" != "master" ] ; then 14 | echo "Not on master. Aborting." 15 | exit 1 16 | fi 17 | 18 | if ! git diff --quiet --exit-code ; then 19 | echo "Git working directory unclean. Aborting." 20 | exit 1 21 | fi 22 | 23 | if ! git diff --cached --quiet --exit-code ; then 24 | echo "Git staged area unclean. Aborting." 25 | exit 1 26 | fi 27 | 28 | git fetch origin master 29 | if ! git diff --quiet --exit-code origin/master ; then 30 | echo "Local master is different from origin master. Aborting" 31 | exit 1 32 | fi 33 | 34 | newMajor="$(echo "$1" | cut -d. -f1)" 35 | newMinor="$(echo "$1" | cut -d. -f2)" 36 | newPatch="$(echo "$1" | cut -d. -f3)" 37 | 38 | oldVersion="$(grep '"version": "\(.\+\)"' package.json | grep -o '[0-9.]\+')" 39 | oldMajor="$(echo "$oldVersion" | cut -d. -f1)" 40 | oldMinor="$(echo "$oldVersion" | cut -d. -f2)" 41 | oldPatch="$(echo "$oldVersion" | cut -d. -f3)" 42 | 43 | if [ "$oldMajor" = "$newMajor" ] ; then 44 | if [ "$oldMinor" = "$newMinor" ] ; then 45 | if [ "$((oldPatch + 1))" != "$newPatch" ] ; then 46 | echo "New version has same minor and major but patch doesn't follow." 47 | exit 1 48 | fi 49 | elif [ "$((oldMinor + 1))" -eq "$newMinor" ] ; then 50 | if [ "$newPatch" != 0 ] ; then 51 | echo "New version has new minor but patch isn't 0." 52 | exit 1 53 | fi 54 | else 55 | echo "New version has same major but minor doesn't follow." 56 | exit 1 57 | fi 58 | elif [ "$((oldMajor + 1))" -eq "$newMajor" ] ; then 59 | if [ "$newMinor" != 0 ] ; then 60 | echo "New version has new major but minor isn't 0." 61 | exit 1 62 | fi 63 | if [ "$newPatch" != 0 ] ; then 64 | echo "New version has new major but patch isn't 0." 65 | exit 1 66 | fi 67 | else 68 | echo "New version doesn't follow previous one." 69 | exit 1 70 | fi 71 | 72 | oldVersion="$oldMajor.$oldMinor.$oldPatch" 73 | newVersion="$newMajor.$newMinor.$newPatch" 74 | 75 | echo "Updating firenvim from v$oldVersion to v$newVersion." 76 | # First, edit package info 77 | sed -i "s/\"version\": \"$oldVersion\"/\"version\": \"$newVersion\"/" package.json 78 | 79 | # Then, do manual update/editing 80 | npm ci 81 | 82 | # Make sure none of the files have changed, except for package-lock.json 83 | if [ "$(git diff --name-only | grep -v "package\(-lock\)\?.json")" != "" ] ; then 84 | echo "Some files have been modified. Aborting." 85 | exit 1 86 | fi 87 | 88 | # npm run test takes care of building the extension in test mode 89 | npm run test-firefox 90 | npm run test-chrome 91 | 92 | # now we need a release build 93 | npm run build 94 | 95 | # lint firefox add-on to make sure we'll be able to publish it 96 | npm run lint 97 | 98 | # Prepare commit message 99 | COMMIT_TEMPLATE="/tmp/firenvim_release_message" 100 | echo "package.json: bump version $oldVersion -> $newVersion" > "$COMMIT_TEMPLATE" 101 | echo "" >> "$COMMIT_TEMPLATE" 102 | git log --pretty=oneline --abbrev-commit --invert-grep --grep='dependabot' "v$oldVersion..HEAD" >> "$COMMIT_TEMPLATE" 103 | 104 | # Everything went fine, we can commit our changes 105 | git add package.json package-lock.json 106 | git commit -t "$COMMIT_TEMPLATE" 107 | git tag --delete "v$newVersion" 2>/dev/null || true 108 | git tag "v$newVersion" 109 | 110 | # Add finishing touches to chrome manifest 111 | sed 's/"key":\s*"[^"]*",//' -i target/chrome/manifest.json 112 | 113 | # Generate bundles that need to be uploaded to chrome/firefox stores 114 | rm -f target/chrome.zip 115 | zip --junk-paths target/chrome.zip target/chrome/* 116 | git archive "v$newVersion" > target/firenvim-firefox-sources.tar 117 | gzip target/firenvim-firefox-sources.tar 118 | 119 | # Everything went fine, we can push 120 | git push 121 | git push --tags 122 | gh release create "$newVersion" target/chrome.zip target/xpi/firefox-latest.xpi 123 | 124 | firefox --private-window 'https://chrome.google.com/webstore/devconsole/g06704558984641971849/egpjdkipkomnmjhjmdamaniclmdlobbo/edit?hl=en' 125 | sleep 1 126 | firefox --private-window 'https://addons.mozilla.org/en-US/developers/addon/firenvim/versions/submit/' 127 | -------------------------------------------------------------------------------- /src/EventEmitter.ts: -------------------------------------------------------------------------------- 1 | 2 | export class EventEmitter void> { 3 | private listeners = new Map(); 4 | 5 | on(event: T, handler: U) { 6 | let handlers = this.listeners.get(event); 7 | if (handlers === undefined) { 8 | handlers = []; 9 | this.listeners.set(event, handlers); 10 | } 11 | handlers.push(handler); 12 | } 13 | 14 | emit(event: T, ...data: any) { 15 | const handlers = this.listeners.get(event); 16 | if (handlers !== undefined) { 17 | const errors : Error[] = []; 18 | handlers.forEach((handler) => { 19 | try { 20 | handler(...data); 21 | } catch (e) { 22 | /* istanbul ignore next */ 23 | errors.push(e); 24 | } 25 | }); 26 | /* Error conditions here are impossible to test for from selenium 27 | * because it would arise from the wrong use of the API, which we 28 | * can't ship in the extension, so don't try to instrument. */ 29 | /* istanbul ignore next */ 30 | if (errors.length > 0) { 31 | throw new Error(JSON.stringify(errors)); 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/KeyHandler.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "./EventEmitter"; 2 | import { GlobalSettings, NvimMode } from "./utils/configuration"; 3 | import { nonLiteralKeys, addModifier, translateKey } from "./utils/keys"; 4 | import { isChrome } from "./utils/utils"; 5 | 6 | export class KeyHandler extends EventEmitter<"input", (s: string) => void> { 7 | private currentMode : NvimMode; 8 | constructor(private elem: HTMLElement, settings: GlobalSettings) { 9 | super(); 10 | const ignoreKeys = settings.ignoreKeys; 11 | this.elem.addEventListener("keydown", (evt) => { 12 | // This is a workaround for osx where pressing non-alphanumeric 13 | // characters like "@" requires pressing , which results 14 | // in the browser sending an event, which we want to 15 | // treat as a regular @. 16 | // So if we're seeing an alt on a non-alphanumeric character, 17 | // we just ignore it and let the input event handler do its 18 | // magic. This can only be tested on OSX, as generating an 19 | // keydown event with selenium won't result in an input 20 | // event. 21 | // Since coverage reports are only retrieved on linux, we don't 22 | // instrument this condition. 23 | /* istanbul ignore next */ 24 | if (evt.altKey && settings.alt === "alphanum" && !/[a-zA-Z0-9]/.test(evt.key)) { 25 | return; 26 | } 27 | // Note: order of this array is important, we need to check OS before checking meta 28 | const specialKeys = [["Alt", "A"], ["Control", "C"], ["OS", "D"], ["Meta", "D"]]; 29 | // The event has to be trusted and either have a modifier or a non-literal representation 30 | if (evt.isTrusted 31 | && (nonLiteralKeys[evt.key] !== undefined 32 | || specialKeys.find(([mod, _]: [string, string]) => 33 | evt.key !== mod && (evt as any).getModifierState(mod)))) { 34 | const text = specialKeys.concat([["Shift", "S"]]) 35 | .reduce((key: string, [attr, mod]: [string, string]) => { 36 | if ((evt as any).getModifierState(attr)) { 37 | return addModifier(mod, key); 38 | } 39 | return key; 40 | }, translateKey(evt.key)); 41 | 42 | let keys : string[] = []; 43 | if (ignoreKeys[this.currentMode] !== undefined) { 44 | keys = ignoreKeys[this.currentMode].slice(); 45 | } 46 | if (ignoreKeys.all !== undefined) { 47 | keys.push.apply(keys, ignoreKeys.all); 48 | } 49 | if (!keys.includes(text)) { 50 | this.emit("input", text); 51 | evt.preventDefault(); 52 | evt.stopImmediatePropagation(); 53 | } 54 | } 55 | }) 56 | 57 | const acceptInput = ((evt: any) => { 58 | this.emit("input", evt.target.value); 59 | evt.preventDefault(); 60 | evt.stopImmediatePropagation(); 61 | evt.target.innerText = ""; 62 | evt.target.value = ""; 63 | }).bind(this); 64 | 65 | this.elem.addEventListener("input", (evt: any) => { 66 | if (evt.isTrusted && !evt.isComposing) { 67 | acceptInput(evt); 68 | } 69 | }); 70 | 71 | // On Firefox, Pinyin input method for a single chinese character will 72 | // result in the following sequence of events: 73 | // - compositionstart 74 | // - input (character) 75 | // - compositionend 76 | // - input (result) 77 | // But on Chrome, we'll get this order: 78 | // - compositionstart 79 | // - input (character) 80 | // - input (result) 81 | // - compositionend 82 | // So Chrome's input event will still have its isComposing flag set to 83 | // true! This means that we need to add a chrome-specific event 84 | // listener on compositionend to do what happens on input events for 85 | // Firefox. 86 | // Don't instrument this branch as coverage is only generated on 87 | // Firefox. 88 | /* istanbul ignore next */ 89 | if (isChrome()) { 90 | this.elem.addEventListener("compositionend", (e: CompositionEvent) => { 91 | acceptInput(e); 92 | }); 93 | } 94 | } 95 | 96 | focus() { 97 | this.elem.focus(); 98 | } 99 | 100 | moveTo(x: number, y: number) { 101 | this.elem.style.left = `${x}px`; 102 | this.elem.style.top = `${y}px`; 103 | } 104 | 105 | setMode(s: NvimMode) { 106 | this.currentMode = s; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Neovim.ts: -------------------------------------------------------------------------------- 1 | import { PageType } from "./page" 2 | import * as CanvasRenderer from "./renderer"; 3 | import { Stdin } from "./Stdin"; 4 | import { Stdout } from "./Stdout"; 5 | import { ISiteConfig } from "./utils/configuration"; 6 | 7 | export async function neovim( 8 | page: PageType, 9 | settings: ISiteConfig, 10 | canvas: HTMLCanvasElement, 11 | { port, password }: { port: number, password: string }, 12 | ) { 13 | const functions: any = {}; 14 | const requests = new Map(); 15 | 16 | CanvasRenderer.setSettings(settings); 17 | CanvasRenderer.setCanvas(canvas); 18 | CanvasRenderer.events.on("resize", ({grid, width, height}: any) => { 19 | (functions as any).nvim_ui_try_resize_grid(grid, width, height); 20 | }); 21 | CanvasRenderer.events.on("frameResize", ({width, height}: any) => { 22 | page.resizeEditor(width, height); 23 | }); 24 | CanvasRenderer.events.on("colorChange", (({background, foreground}: any) => { 25 | const e = canvas.ownerDocument.documentElement; 26 | e.style.backgroundColor = background; 27 | e.style.color = foreground; 28 | })); 29 | 30 | let prevNotificationPromise = Promise.resolve(); 31 | const socket = new WebSocket(`ws://127.0.0.1:${port}/${password}`); 32 | socket.binaryType = "arraybuffer"; 33 | socket.addEventListener("close", ((_: any) => { 34 | prevNotificationPromise = prevNotificationPromise.finally(() => page.killEditor()); 35 | })); 36 | await (new Promise(resolve => socket.addEventListener("open", () => { 37 | resolve(undefined); 38 | }))); 39 | const stdin = new Stdin(socket); 40 | const stdout = new Stdout(socket); 41 | 42 | let reqId = 0; 43 | const request = (api: string, args: any[]) => { 44 | return new Promise((resolve, reject) => { 45 | reqId += 1; 46 | requests.set(reqId, {resolve, reject}); 47 | stdin.write(reqId, api, args); 48 | }); 49 | }; 50 | stdout.on("request", (id: number, name: any, args: any) => { 51 | console.warn("firenvim: unhandled request from neovim", id, name, args); 52 | }); 53 | stdout.on("response", (id: any, error: any, result: any) => { 54 | const r = requests.get(id); 55 | if (!r) { 56 | // This can't happen and yet it sometimes does, possibly due to a firefox bug 57 | console.error(`Received answer to ${id} but no handler found!`); 58 | } else { 59 | requests.delete(id); 60 | if (error) { 61 | r.reject(error); 62 | } else { 63 | r.resolve(result); 64 | } 65 | } 66 | }); 67 | 68 | let lastLostFocus = performance.now(); 69 | stdout.on("notification", async (name: string, args: any[]) => { 70 | if (name === "redraw" && args) { 71 | CanvasRenderer.onRedraw(args); 72 | return; 73 | } 74 | prevNotificationPromise = prevNotificationPromise.finally(() => { 75 | // A very tricky sequence of events could happen here: 76 | // - firenvim_bufwrite is received page.setElementContent is called 77 | // asynchronously 78 | // - firenvim_focus_page is called, page.focusPage() is called 79 | // asynchronously, lastLostFocus is set to now 80 | // - page.setElementContent completes, lastLostFocus is checked to see 81 | // if focus should be grabbed or not 82 | // That's why we have to check for lastLostFocus after 83 | // page.setElementContent/Cursor! Same thing for firenvim_press_keys 84 | const hadFocus = document.hasFocus(); 85 | switch (name) { 86 | case "firenvim_bufwrite": 87 | { 88 | const data = args[0] as { text: string[], cursor: [number, number] }; 89 | return page.setElementContent(data.text.join("\n")) 90 | .then(() => page.setElementCursor(...(data.cursor))) 91 | .then(() => { 92 | if (hadFocus 93 | && !document.hasFocus() 94 | && (performance.now() - lastLostFocus > 3000)) { 95 | window.focus(); 96 | } 97 | }); 98 | } 99 | case "firenvim_eval_js": 100 | return page.evalInPage(args[0]).catch(_ => _).then(result => { 101 | if (args[1]) { 102 | request("nvim_call_function", [args[1], [JSON.stringify(result)]]); 103 | } 104 | }); 105 | case "firenvim_focus_page": 106 | lastLostFocus = performance.now(); 107 | return page.focusPage(); 108 | case "firenvim_focus_input": 109 | lastLostFocus = performance.now(); 110 | return page.focusInput(); 111 | case "firenvim_focus_next": 112 | lastLostFocus = performance.now(); 113 | return page.focusNext(); 114 | case "firenvim_focus_prev": 115 | lastLostFocus = performance.now(); 116 | return page.focusPrev(); 117 | case "firenvim_hide_frame": 118 | lastLostFocus = performance.now(); 119 | return page.hideEditor(); 120 | case "firenvim_press_keys": 121 | return page.pressKeys(args[0]); 122 | case "firenvim_vimleave": 123 | lastLostFocus = performance.now(); 124 | return page.killEditor(); 125 | } 126 | }); 127 | }); 128 | 129 | const { 0: channel, 1: apiInfo } = (await request("nvim_get_api_info", [])) as INvimApiInfo; 130 | 131 | stdout.setTypes(apiInfo.types); 132 | 133 | Object.assign(functions, apiInfo.functions 134 | .reduce((acc, cur) => { 135 | acc[cur.name] = (...args: any[]) => request(cur.name, args); 136 | return acc; 137 | }, {} as {[k: string]: (...args: any[]) => any})); 138 | functions.get_current_channel = () => channel; 139 | return functions; 140 | } 141 | -------------------------------------------------------------------------------- /src/Stdin.ts: -------------------------------------------------------------------------------- 1 | import * as msgpack from "msgpack-lite"; 2 | 3 | export class Stdin { 4 | 5 | constructor(private socket: WebSocket) {} 6 | 7 | public write(reqId: number, method: string, args: any[]) { 8 | const req = [0, reqId, method, args]; 9 | const encoded = msgpack.encode(req); 10 | this.socket.send(encoded); 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/Stdout.ts: -------------------------------------------------------------------------------- 1 | import * as msgpack from "msgpack-lite"; 2 | import { EventEmitter } from "./EventEmitter"; 3 | 4 | type MessageKind = "request" | "response" | "notification"; 5 | type RequestHandler = (id: number, name: string, args: any[]) => void; 6 | type ResponseHandler = (id: number, error: any, result: any) => void; 7 | type NotificationHandler = (name: string, args: any[]) => void; 8 | type MessageHandler = RequestHandler | ResponseHandler | NotificationHandler; 9 | export class Stdout extends EventEmitter{ 10 | private messageNames = new Map([[0, "request"], [1, "response"], [2, "notification"]]); 11 | private msgpackConfig: msgpack.DecoderOptions = { 12 | // Create the codec object early so the Decoder is initialized with it. 13 | // If that was created in `setTypes`, the `decoder` would already be 14 | // initialized with the default codec. 15 | // https://github.com/kawanet/msgpack-lite/blob/5b71d82cad4b96289a466a6403d2faaa3e254167/lib/decode-buffer.js#L17 16 | codec: msgpack.createCodec({ preset: true }), 17 | }; 18 | private decoder = msgpack.Decoder(this.msgpackConfig); 19 | 20 | constructor(private socket: WebSocket) { 21 | super(); 22 | this.socket.addEventListener("message", this.onMessage.bind(this)); 23 | this.decoder.on("data", this.onDecodedChunk.bind(this)); 24 | } 25 | 26 | public setTypes(types: {[key: string]: { id: number }}) { 27 | Object 28 | .entries(types) 29 | .forEach(([_, { id }]) => 30 | this 31 | .msgpackConfig 32 | .codec 33 | .addExtUnpacker(id, (data: any) => data)); 34 | } 35 | 36 | private onMessage(msg: any) { 37 | const msgData = new Uint8Array(msg.data); 38 | try { 39 | this.decoder.decode(msgData); 40 | } catch (error) { 41 | // NOTE: this branch was not hit during testing, but theoretically could happen 42 | // due to 43 | // https://github.com/kawanet/msgpack-lite/blob/5b71d82cad4b96289a466a6403d2faaa3e254167/lib/flex-buffer.js#L52 44 | console.log("msgpack decode failed", error); 45 | } 46 | } 47 | 48 | private onDecodedChunk(decoded: [number, unknown, unknown, unknown]) { 49 | const [kind, reqId, data1, data2] = decoded; 50 | const name = this.messageNames.get(kind); 51 | /* istanbul ignore else */ 52 | if (name) { 53 | this.emit(name, reqId, data1, data2); 54 | } else { 55 | // Can't be tested because this would mean messages that break 56 | // the msgpack-rpc spec, so coverage impossible to get. 57 | console.error(`Unhandled message kind ${name}`); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/autofill.ts: -------------------------------------------------------------------------------- 1 | 2 | export async function autofill() { 3 | const textarea = document.getElementById("issue_body") as any; 4 | if (!textarea) { 5 | return; 6 | } 7 | const platInfoPromise = browser.runtime.sendMessage({ 8 | args: { 9 | args: [], 10 | funcName: ["browser", "runtime", "getPlatformInfo"], 11 | }, 12 | funcName: ["exec"], 13 | }); 14 | const manifestPromise = browser.runtime.sendMessage({ 15 | args: { 16 | args: [], 17 | funcName: ["browser", "runtime", "getManifest"], 18 | }, 19 | funcName: ["exec"], 20 | }); 21 | const nvimPluginPromise = browser.runtime.sendMessage({ 22 | args: {}, 23 | funcName: ["getNvimPluginVersion"], 24 | }); 25 | const issueTemplatePromise = fetch(browser.runtime.getURL("ISSUE_TEMPLATE.md")).then(p => p.text()); 26 | const browserString = navigator.userAgent.match(/(firefox|chrom)[^ ]+/gi); 27 | let name; 28 | let version; 29 | // Can't be tested, as coverage is only recorded on firefox 30 | /* istanbul ignore else */ 31 | if (browserString) { 32 | [ name, version ] = browserString[0].split("/"); 33 | } else { 34 | name = "unknown"; 35 | version = "unknown"; 36 | } 37 | const vendor = navigator.vendor || ""; 38 | const [ 39 | platInfo, 40 | manifest, 41 | nvimPluginVersion, 42 | issueTemplate, 43 | ] = await Promise.all([platInfoPromise, manifestPromise, nvimPluginPromise, issueTemplatePromise]); 44 | // Can't happen, but doesn't cost much to handle! 45 | /* istanbul ignore next */ 46 | if (textarea.value.replace(/\r/g, "") !== issueTemplate.replace(/\r/g, "")) { 47 | return; 48 | } 49 | textarea.value = issueTemplate 50 | .replace("OS Version:", `OS Version: ${platInfo.os} ${platInfo.arch}`) 51 | .replace("Browser Version:", `Browser Version: ${vendor} ${name} ${version}`) 52 | .replace("Browser Addon Version:", `Browser Addon Version: ${manifest.version}`) 53 | .replace("Neovim Plugin Version:", `Neovim Plugin Version: ${nvimPluginVersion}`); 54 | } 55 | -------------------------------------------------------------------------------- /src/browserAction.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Firenvim Popup 6 | 7 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /src/browserAction.ts: -------------------------------------------------------------------------------- 1 | 2 | function displayMessages(func: "getError" | "getWarning", id: "errors" | "warnings") { 3 | function insertMessage(msg: any) { 4 | document.getElementById(id).innerText = msg; 5 | } 6 | return browser.runtime.sendMessage({ funcName: [func] }) 7 | .then(insertMessage) 8 | .catch(insertMessage); 9 | } 10 | 11 | function displayErrorsAndWarnings() { 12 | return Promise.all([displayMessages("getWarning", "warnings"), displayMessages("getError", "errors")]); 13 | } 14 | 15 | async function updateDisableButton() { 16 | const tabId = (await browser.runtime.sendMessage({ 17 | args: { 18 | args: [{ active: true, currentWindow: true }], 19 | funcName: [ "browser", "tabs", "query" ], 20 | }, 21 | funcName: ["exec"], 22 | }))[0].id; 23 | const disabled = (await browser.runtime.sendMessage({ 24 | args: [tabId, "disabled"], 25 | funcName: ["getTabValueFor"], 26 | })); 27 | const button = document.getElementById("disableFirenvim"); 28 | if (disabled === true) { 29 | button.innerText = "Enable in this tab"; 30 | } else { 31 | button.innerText = "Disable in this tab"; 32 | } 33 | } 34 | 35 | addEventListener("DOMContentLoaded", () => { 36 | document.getElementById("reloadSettings").addEventListener("click", () => { 37 | browser.runtime.sendMessage( { funcName: ["updateSettings"] }) 38 | .then(displayErrorsAndWarnings) 39 | .catch(displayErrorsAndWarnings); 40 | }); 41 | document.getElementById("disableFirenvim").addEventListener("click", () => { 42 | browser.runtime.sendMessage( { funcName: ["toggleDisabled"] }) 43 | .then(updateDisableButton); 44 | }); 45 | document.getElementById("troubleshooting").addEventListener("click", () => { 46 | browser.runtime.sendMessage( { funcName: ["openTroubleshootingGuide"] }); 47 | }) 48 | displayErrorsAndWarnings(); 49 | updateDisableButton(); 50 | }); 51 | -------------------------------------------------------------------------------- /src/content.ts: -------------------------------------------------------------------------------- 1 | import { FirenvimElement } from "./FirenvimElement"; 2 | import { autofill } from "./autofill"; 3 | import { confReady, getConf } from "./utils/configuration"; 4 | import { getNeovimFrameFunctions, getActiveContentFunctions, getTabFunctions } from "./page"; 5 | 6 | if (document.location.href.startsWith("https://github.com/") 7 | || document.location.protocol === "file:" && document.location.href.endsWith("github.html")) { 8 | addEventListener("load", autofill); 9 | let lastUrl = location.href; 10 | // We have to use a MutationObserver to trigger autofill because Github 11 | // uses "progressive enhancement" and thus doesn't always trigger a load 12 | // event. But we can't always rely on the MutationObserver without the load 13 | // event because the MutationObserver won't be triggered on hard page 14 | // reloads! 15 | new MutationObserver(() => { 16 | const url = location.href; 17 | if (url !== lastUrl) { 18 | lastUrl = url; 19 | if (lastUrl === "https://github.com/glacambre/firenvim/issues/new") { 20 | autofill(); 21 | } 22 | } 23 | }).observe(document, {subtree: true, childList: true}); 24 | } 25 | 26 | // Promise used to implement a locking mechanism preventing concurrent creation 27 | // of neovim frames 28 | let frameIdLock = Promise.resolve(); 29 | 30 | export const firenvimGlobal = { 31 | // Whether Firenvim is disabled in this tab 32 | disabled: browser.runtime.sendMessage({ 33 | args: ["disabled"], 34 | funcName: ["getTabValue"], 35 | }) 36 | // Note: this relies on setDisabled existing in the object returned by 37 | // getFunctions and attached to the window object 38 | .then((disabled: boolean) => (window as any).setDisabled(disabled)), 39 | // Promise-resolution function called when a frameId is received from the 40 | // background script 41 | frameIdResolve: (_: number): void => undefined, 42 | // lastFocusedContentScript keeps track of the last content frame that has 43 | // been focused. This is necessary in pages that contain multiple frames 44 | // (and thus multiple content scripts): for example, if users press the 45 | // global keyboard shortcut , the background script sends a "global" 46 | // message to all of the active tab's content scripts. For a content script 47 | // to know if it should react to a global message, it just needs to check 48 | // if it is the last active content script. 49 | lastFocusedContentScript: 0, 50 | // nvimify: triggered when an element is focused, takes care of creating 51 | // the editor iframe, appending it to the page and focusing it. 52 | nvimify: async (evt: { target: EventTarget }) => { 53 | if (firenvimGlobal.disabled instanceof Promise) { 54 | await firenvimGlobal.disabled; 55 | } 56 | 57 | // When creating new frames, we need to know their frameId in order to 58 | // communicate with them. This can't be retrieved through a 59 | // synchronous, in-page call so the new frame has to tell the 60 | // background script to send its frame id to the page. Problem is, if 61 | // multiple frames are created in a very short amount of time, we 62 | // aren't guaranteed to receive these frameIds in the order in which 63 | // the frames were created. So we have to implement a locking mechanism 64 | // to make sure that we don't create new frames until we received the 65 | // frameId of the previously created frame. 66 | let lock; 67 | while (lock !== frameIdLock) { 68 | lock = frameIdLock; 69 | await frameIdLock; 70 | } 71 | 72 | frameIdLock = new Promise(async (unlock: any) => { 73 | // auto is true when nvimify() is called as an event listener, false 74 | // when called from forceNvimify() 75 | const auto = (evt instanceof FocusEvent); 76 | 77 | const takeover = getConf().takeover; 78 | if (firenvimGlobal.disabled || (auto && takeover === "never")) { 79 | unlock(); 80 | return; 81 | } 82 | 83 | const firenvim = new FirenvimElement( 84 | evt.target as HTMLElement, 85 | firenvimGlobal.nvimify, 86 | (id: number) => firenvimGlobal.firenvimElems.delete(id) 87 | ); 88 | const editor = firenvim.getEditor(); 89 | 90 | // If this element already has a neovim frame, stop 91 | const alreadyRunning = Array.from(firenvimGlobal.firenvimElems.values()) 92 | .find((instance) => instance.getElement() === editor.getElement()); 93 | if (alreadyRunning !== undefined) { 94 | // The span might have been removed from the page by the page 95 | // (this happens on Jira/Confluence for example) so we check 96 | // for that. 97 | const span = alreadyRunning.getSpan(); 98 | if (span.ownerDocument.contains(span)) { 99 | alreadyRunning.show(); 100 | alreadyRunning.focus(); 101 | unlock(); 102 | return; 103 | } else { 104 | // If the span has been removed from the page, the editor 105 | // is dead because removing an iframe from the page kills 106 | // the websocket connection inside of it. 107 | // We just tell the editor to clean itself up and go on as 108 | // if it didn't exist. 109 | alreadyRunning.detachFromPage(); 110 | } 111 | } 112 | 113 | if (auto && (takeover === "empty" || takeover === "nonempty")) { 114 | const content = (await editor.getContent()).trim(); 115 | if ((content !== "" && takeover === "empty") 116 | || (content === "" && takeover === "nonempty")) { 117 | unlock(); 118 | return; 119 | } 120 | } 121 | 122 | firenvim.prepareBufferInfo(); 123 | const frameIdPromise = new Promise((resolve: (_: number) => void, reject) => { 124 | firenvimGlobal.frameIdResolve = resolve; 125 | // TODO: make this timeout the same as the one in background.ts 126 | setTimeout(reject, 10000); 127 | }); 128 | frameIdPromise.then((frameId: number) => { 129 | firenvimGlobal.firenvimElems.set(frameId, firenvim); 130 | firenvimGlobal.frameIdResolve = () => undefined; 131 | unlock(); 132 | }); 133 | frameIdPromise.catch(unlock); 134 | firenvim.attachToPage(frameIdPromise); 135 | }); 136 | }, 137 | 138 | // fienvimElems maps frame ids to firenvim elements. 139 | firenvimElems: new Map(), 140 | }; 141 | 142 | const ownFrameId = browser.runtime.sendMessage({ args: [], funcName: ["getOwnFrameId"] }); 143 | async function announceFocus () { 144 | const frameId = await ownFrameId; 145 | firenvimGlobal.lastFocusedContentScript = frameId; 146 | browser.runtime.sendMessage({ 147 | args: { 148 | args: [ frameId ], 149 | funcName: ["setLastFocusedContentScript"] 150 | }, 151 | funcName: ["messagePage"] 152 | }); 153 | } 154 | // When the frame is created, we might receive focus, check for that 155 | ownFrameId.then(_ => { 156 | if (document.hasFocus()) { 157 | announceFocus(); 158 | } 159 | }); 160 | async function addFocusListener () { 161 | window.removeEventListener("focus", announceFocus); 162 | window.addEventListener("focus", announceFocus); 163 | } 164 | addFocusListener(); 165 | // We need to use setInterval to periodically re-add the focus listeners as in 166 | // frames the document could get deleted and re-created without our knowledge. 167 | const intervalId = setInterval(addFocusListener, 100); 168 | // But we don't want to syphon the user's battery so we stop checking after a second 169 | setTimeout(() => clearInterval(intervalId), 1000); 170 | 171 | export const frameFunctions = getNeovimFrameFunctions(firenvimGlobal); 172 | export const activeFunctions = getActiveContentFunctions(firenvimGlobal); 173 | export const tabFunctions = getTabFunctions(firenvimGlobal); 174 | Object.assign(window, frameFunctions, activeFunctions, tabFunctions); 175 | browser.runtime.onMessage.addListener(async (request: { funcName: string[], args: any[] }) => { 176 | // All content scripts must react to tab functions 177 | let fn = request.funcName.reduce((acc: any, cur: string) => acc[cur], tabFunctions); 178 | if (fn !== undefined) { 179 | return fn(...request.args); 180 | } 181 | 182 | // The only content script that should react to activeFunctions is the active one 183 | fn = request.funcName.reduce((acc: any, cur: string) => acc[cur], activeFunctions); 184 | if (fn !== undefined) { 185 | if (firenvimGlobal.lastFocusedContentScript === await ownFrameId) { 186 | return fn(...request.args); 187 | } 188 | return new Promise(() => undefined); 189 | } 190 | 191 | // The only content script that should react to frameFunctions is the one 192 | // that owns the frame that sent the request 193 | fn = request.funcName.reduce((acc: any, cur: string) => acc[cur], frameFunctions); 194 | if (fn !== undefined) { 195 | if (firenvimGlobal.firenvimElems.get(request.args[0]) !== undefined) { 196 | return fn(...request.args); 197 | } 198 | return new Promise(() => undefined); 199 | } 200 | 201 | throw new Error(`Error: unhandled content request: ${JSON.stringify(request)}.`); 202 | }); 203 | 204 | function onScroll(cont: boolean) { 205 | window.requestAnimationFrame(() => { 206 | const posChanged = Array.from(firenvimGlobal.firenvimElems.entries()) 207 | .map(([_, elem]) => elem.putEditorCloseToInputOrigin()) 208 | .find(changed => changed.posChanged); 209 | if (posChanged) { 210 | // As long as one editor changes position, try to resize 211 | onScroll(true); 212 | } else if (cont) { 213 | // No editor has moved, but this might be because the website 214 | // implements some kind of smooth scrolling that doesn't make 215 | // the textarea move immediately. In order to deal with these 216 | // cases, schedule a last redraw in a few milliseconds 217 | setTimeout(() => onScroll(false), 100); 218 | } 219 | }); 220 | } 221 | 222 | function doScroll() { 223 | return onScroll(true); 224 | } 225 | 226 | function addNvimListener(elem: Element) { 227 | elem.removeEventListener("focus", firenvimGlobal.nvimify); 228 | elem.addEventListener("focus", firenvimGlobal.nvimify); 229 | let parent = elem.parentElement; 230 | while (parent) { 231 | parent.removeEventListener("scroll", doScroll); 232 | parent.addEventListener("scroll", doScroll); 233 | parent = parent.parentElement; 234 | } 235 | } 236 | 237 | function setupListeners(selector: string) { 238 | window.addEventListener("scroll", doScroll); 239 | window.addEventListener("wheel", doScroll); 240 | (new ((window as any).ResizeObserver)((_: any[]) => { 241 | onScroll(true); 242 | })).observe(document.documentElement); 243 | 244 | (new MutationObserver((changes, _) => { 245 | if (changes.filter(change => change.addedNodes.length > 0).length <= 0) { 246 | return; 247 | } 248 | // This mutation observer is triggered every time an element is 249 | // added/removed from the page. When this happens, try to apply 250 | // listeners again, in case a new textarea/input field has been added. 251 | const toPossiblyNvimify = Array.from(document.querySelectorAll(selector)); 252 | toPossiblyNvimify.forEach(elem => addNvimListener(elem)); 253 | 254 | const takeover = getConf().takeover; 255 | function shouldNvimify(node: any) { 256 | // Ideally, the takeover !== "never" check shouldn't be performed 257 | // here: it should live in nvimify(). However, nvimify() only 258 | // checks for takeover === "never" if it is called from an event 259 | // handler (this is necessary in order to allow manually nvimifying 260 | // elements). Thus, we need to check if takeover !== "never" here 261 | // too. 262 | return takeover !== "never" 263 | && document.activeElement === node 264 | && toPossiblyNvimify.includes(node); 265 | } 266 | 267 | // We also need to check if the currently focused element is among the 268 | // newly created elements and if it is, nvimify it. 269 | // Note that we can't do this unconditionally: we would turn the active 270 | // element into a neovim frame even for unrelated dom changes. 271 | for (const mr of changes) { 272 | for (const node of mr.addedNodes) { 273 | if (shouldNvimify(node)) { 274 | activeFunctions.forceNvimify(); 275 | return; 276 | } 277 | const walker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT); 278 | while (walker.nextNode()) { 279 | if (shouldNvimify(walker.currentNode)) { 280 | activeFunctions.forceNvimify(); 281 | return; 282 | } 283 | } 284 | } 285 | } 286 | })).observe(window.document, { subtree: true, childList: true }); 287 | 288 | let elements: HTMLElement[]; 289 | try { 290 | elements = Array.from(document.querySelectorAll(selector)); 291 | } catch { 292 | alert(`Firenvim error: invalid CSS selector (${selector}) in your g:firenvim_config.`); 293 | elements = []; 294 | } 295 | elements.forEach(elem => addNvimListener(elem)); 296 | } 297 | 298 | export const listenersSetup = new Promise(resolve => { 299 | confReady.then(() => { 300 | const conf = getConf(); 301 | if (conf.selector !== undefined && conf.selector !== "") { 302 | setupListeners(conf.selector); 303 | } 304 | resolve(undefined); 305 | }); 306 | }); 307 | -------------------------------------------------------------------------------- /src/firenvim.d.ts: -------------------------------------------------------------------------------- 1 | 2 | type NvimParameters = Array<[string, string]>; 3 | 4 | interface INvimApiInfo { 5 | 0: number; 6 | 1: { 7 | error_types: {[key: string]: { id: number }}, 8 | functions: Array<{ 9 | deprecated_since?: number, 10 | method: boolean, 11 | name: string, 12 | parameters: NvimParameters, 13 | return_type: string, 14 | since: number, 15 | }>, 16 | types: { 17 | [key: string]: { id: number, prefix: string }, 18 | }, 19 | ui_events: Array<{ 20 | name: string, 21 | parameters: NvimParameters, 22 | since: number, 23 | }>, 24 | ui_options: string[], 25 | version: { 26 | api_compatible: number, 27 | api_level: number, 28 | api_prerelease: boolean, 29 | major: number, 30 | minor: number, 31 | patch: number, 32 | }, 33 | }; 34 | } 35 | 36 | type ResizeUpdate = [number, number, number]; 37 | type GotoUpdate = [number, number, number]; 38 | type LineUpdate = [number, number, number, Array<[string, number, number?]>]; 39 | type HighlightUpdateElement = { 40 | background?: number, 41 | blend?: number, 42 | bold?: boolean, 43 | foreground?: number, 44 | italic?: boolean, 45 | reverse?: boolean, 46 | special?: number, 47 | strikethrough?: boolean, 48 | undercurl?: boolean, 49 | underline?: boolean, 50 | } 51 | type HighlightUpdate = [number, HighlightUpdateElement]; 52 | type HighlightElement = { 53 | background?: string, 54 | bold?: boolean, 55 | foreground?: string, 56 | italic?: boolean, 57 | special?: number, 58 | strikethrough?: boolean, 59 | undercurl?: boolean, 60 | underline?: boolean, 61 | } 62 | type HighlightArray = Array; 63 | -------------------------------------------------------------------------------- /src/frame.ts: -------------------------------------------------------------------------------- 1 | import { KeyHandler } from "./KeyHandler"; 2 | import { getGlobalConf, confReady, getConfForUrl, NvimMode } from "./utils/configuration"; 3 | import { getGridId, getLogicalSize, computeGridDimensionsFor, getGridCoordinates, events as rendererEvents } from "./renderer"; 4 | import { getPageProxy } from "./page"; 5 | import { neovim } from "./Neovim"; 6 | import { toFileName } from "./utils/utils"; 7 | 8 | const pageLoaded = new Promise((resolve, reject) => { 9 | window.addEventListener("load", resolve); 10 | setTimeout(reject, 10000) 11 | }); 12 | const connectionPromise = browser.runtime.sendMessage({ funcName: ["getNeovimInstance"] }); 13 | 14 | export const isReady = browser 15 | .runtime 16 | .sendMessage({ funcName: ["publishFrameId"] }) 17 | .then(async (frameId: number) => { 18 | await confReady; 19 | await pageLoaded; 20 | const page = getPageProxy(frameId); 21 | const keyHandler = new KeyHandler(document.getElementById("keyhandler"), getGlobalConf()); 22 | try { 23 | const [[url, selector, cursor, language], connectionData] = 24 | await Promise.all([page.getEditorInfo(), connectionPromise]); 25 | await confReady; 26 | const urlSettings = getConfForUrl(url); 27 | const canvas = document.getElementById("canvas") as HTMLCanvasElement; 28 | const nvimPromise = neovim( 29 | page, 30 | urlSettings, 31 | canvas, 32 | connectionData); 33 | const contentPromise = page.getElementContent(); 34 | 35 | const [cols, rows] = getLogicalSize(); 36 | 37 | const nvim = await nvimPromise; 38 | 39 | keyHandler.on("input", (s: string) => nvim.nvim_input(s)); 40 | rendererEvents.on("modeChange", (s: NvimMode) => keyHandler.setMode(s)); 41 | 42 | // We need to set client info before running ui_attach because we want this 43 | // info to be available when UIEnter is triggered 44 | const extInfo = browser.runtime.getManifest(); 45 | const [major, minor, patch] = extInfo.version.split("."); 46 | nvim.nvim_set_client_info(extInfo.name, 47 | { major, minor, patch }, 48 | "ui", 49 | {}, 50 | {}, 51 | ); 52 | 53 | nvim.nvim_ui_attach( 54 | cols < 1 ? 1 : cols, 55 | rows < 1 ? 1 : rows, 56 | { 57 | ext_linegrid: true, 58 | ext_messages: urlSettings.cmdline !== "neovim", 59 | rgb: true, 60 | }).catch(console.log); 61 | 62 | let resizeReqId = 0; 63 | page.on("resize", ([id, width, height]: [number, number, number]) => { 64 | if (id > resizeReqId) { 65 | resizeReqId = id; 66 | // We need to put the keyHandler at the origin in order to avoid 67 | // issues when it slips out of the viewport 68 | keyHandler.moveTo(0, 0); 69 | // It's tempting to try to optimize this by only calling 70 | // ui_try_resize when nCols is different from cols and nRows is 71 | // different from rows but we can't because redraw notifications 72 | // might happen without us actually calling ui_try_resize and then 73 | // the sizes wouldn't be in sync anymore 74 | const [nCols, nRows] = computeGridDimensionsFor( 75 | width * window.devicePixelRatio, 76 | height * window.devicePixelRatio 77 | ); 78 | nvim.nvim_ui_try_resize_grid(getGridId(), nCols, nRows); 79 | page.resizeEditor(Math.floor(width / nCols) * nCols, Math.floor(height / nRows) * nRows); 80 | } 81 | }); 82 | page.on("frame_sendKey", (args) => nvim.nvim_input(args.join(""))); 83 | page.on("get_buf_content", (r: any) => r(nvim.nvim_buf_get_lines(0, 0, -1, 0))); 84 | 85 | // Create file, set its content to the textarea's, write it 86 | const filename = toFileName(urlSettings.filename, url, selector, language); 87 | const content = await contentPromise; 88 | const [line, col] = cursor; 89 | const writeFilePromise = nvim.nvim_call_function("writefile", [content.split("\n"), filename]) 90 | .then(() => nvim.nvim_command(`edit ${filename} ` 91 | + `| call nvim_win_set_cursor(0, [${line}, ${col}])`)); 92 | 93 | // Can't get coverage for this as browsers don't let us reliably 94 | // push data to the server on beforeunload. 95 | /* istanbul ignore next */ 96 | window.addEventListener("beforeunload", () => { 97 | nvim.nvim_ui_detach(); 98 | nvim.nvim_command("qall!"); 99 | }); 100 | 101 | // Keep track of last active instance (necessary for firenvim#focus_input() & others) 102 | const chan = nvim.get_current_channel(); 103 | function setCurrentChan() { 104 | nvim.nvim_set_var("last_focused_firenvim_channel", chan); 105 | } 106 | setCurrentChan(); 107 | window.addEventListener("focus", setCurrentChan); 108 | window.addEventListener("click", setCurrentChan); 109 | 110 | // Ask for notifications when user writes/leaves firenvim 111 | nvim.nvim_exec_lua(` 112 | local args = {...} 113 | local augroupName = args[1] 114 | local filename = args[2] 115 | local channel = args[3] 116 | local group = vim.api.nvim_create_augroup(augroupName, { clear = true }) 117 | vim.api.nvim_create_autocmd("BufWrite", { 118 | group = group, 119 | pattern = filename, 120 | callback = function(ev) 121 | vim.fn["firenvim#write"]() 122 | end 123 | }) 124 | vim.api.nvim_create_autocmd("VimLeave", { 125 | group = group, 126 | callback = function(ev) 127 | -- Cleanup means: 128 | -- - notify frontend that we're shutting down 129 | -- - delete file 130 | -- - remove own augroup 131 | vim.fn.rpcnotify(channel, 'firenvim_vimleave') 132 | vim.fn.delete(filename) 133 | vim.api.nvim_del_augroup_by_id(group) 134 | end 135 | }) 136 | `, [`FirenvimAugroupChan${chan}`, filename, chan]); 137 | 138 | let mouseEnabled = true; 139 | rendererEvents.on("mouseOn", () => { 140 | canvas.oncontextmenu = () => false; 141 | mouseEnabled = true; 142 | }); 143 | rendererEvents.on("mouseOff", () => { 144 | delete canvas.oncontextmenu; 145 | mouseEnabled = false; 146 | }); 147 | window.addEventListener("mousemove", (evt: MouseEvent) => { 148 | keyHandler.moveTo(evt.clientX, evt.clientY); 149 | }); 150 | function onMouse(evt: MouseEvent, action: string) { 151 | if (!mouseEnabled) { 152 | keyHandler.focus(); 153 | return; 154 | } 155 | let button; 156 | // Selenium can't generate wheel events yet :( 157 | /* istanbul ignore next */ 158 | if (evt instanceof WheelEvent) { 159 | button = "wheel"; 160 | } else { 161 | // Selenium can't generate mouse events with more buttons :( 162 | /* istanbul ignore next */ 163 | if (evt.button > 2) { 164 | // Neovim doesn't handle other mouse buttons for now 165 | return; 166 | } 167 | button = ["left", "middle", "right"][evt.button]; 168 | } 169 | evt.preventDefault(); 170 | evt.stopImmediatePropagation(); 171 | 172 | const modifiers = (evt.altKey ? "A" : "") + 173 | (evt.ctrlKey ? "C" : "") + 174 | (evt.metaKey ? "D" : "") + 175 | (evt.shiftKey ? "S" : ""); 176 | const [x, y] = getGridCoordinates(evt.pageX, evt.pageY); 177 | nvim.nvim_input_mouse(button, 178 | action, 179 | modifiers, 180 | getGridId(), 181 | y, 182 | x); 183 | keyHandler.focus(); 184 | } 185 | window.addEventListener("mousedown", e => { 186 | onMouse(e, "press"); 187 | }); 188 | window.addEventListener("mouseup", e => { 189 | onMouse(e, "release"); 190 | }); 191 | // Selenium doesn't let you simulate mouse wheel events :( 192 | /* istanbul ignore next */ 193 | window.addEventListener("wheel", evt => { 194 | if (Math.abs(evt.deltaY) >= Math.abs(evt.deltaX)) { 195 | onMouse(evt, evt.deltaY < 0 ? "up" : "down"); 196 | } else { 197 | onMouse(evt, evt.deltaX < 0 ? "right" : "left"); 198 | } 199 | }); 200 | // Let users know when they focus/unfocus the frame 201 | window.addEventListener("focus", () => { 202 | document.documentElement.style.opacity = "1"; 203 | keyHandler.focus(); 204 | nvim.nvim_command("doautocmd FocusGained"); 205 | }); 206 | window.addEventListener("blur", () => { 207 | document.documentElement.style.opacity = "0.7"; 208 | nvim.nvim_command("doautocmd FocusLost"); 209 | }); 210 | keyHandler.focus(); 211 | return new Promise ((resolve, reject) => setTimeout(() => { 212 | keyHandler.focus(); 213 | writeFilePromise.then(() => resolve(page)); 214 | // To hard to test (we'd need to find a way to make neovim fail 215 | // to write the file, which requires too many os-dependent side 216 | // effects), so don't instrument. 217 | /* istanbul ignore next */ 218 | writeFilePromise.catch(() => reject()); 219 | }, 10)); 220 | } catch (e) { 221 | console.error(e); 222 | page.killEditor(); 223 | throw e; 224 | } 225 | }); 226 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "background": { 3 | "scripts": [ "background.js" ] 4 | }, 5 | "browser_action": { 6 | "browser_style": true, 7 | "default_icon": "firenvim.svg", 8 | "default_popup": "browserAction.html", 9 | "default_title": "Firenvim" 10 | }, 11 | "commands": { 12 | "nvimify": { 13 | "description": "Turn the currently focused element into a neovim iframe.", 14 | "suggested_key": { 15 | "default": "Ctrl+E" 16 | } 17 | }, 18 | "send_C-n": { 19 | "description": "Send Ctrl-n to firenvim." 20 | }, 21 | "send_C-t": { 22 | "description": "Send Ctrl-t to firenvim." 23 | }, 24 | "send_C-w": { 25 | "description": "Send Ctrl-w to firenvim." 26 | }, 27 | "toggle_firenvim": { 28 | "description": "Toggle Firenvim in the current tab." 29 | } 30 | }, 31 | "content_scripts": [ { 32 | "all_frames": true, 33 | "js": [ "content.js" ], 34 | "match_about_blank": true, 35 | "matches": [ "" ], 36 | "run_at": "document_start" 37 | } ], 38 | "description": "replaced_at_compile_time", 39 | "icons": { 40 | "128": "firenvim.svg" 41 | }, 42 | "manifest_version": 2, 43 | "name": "Firenvim", 44 | "options_ui": { 45 | "page": "options.html" 46 | }, 47 | "permissions": [ "nativeMessaging", "storage" ], 48 | "version": "replaced_at_compile_time", 49 | "web_accessible_resources": [ "index.html", "ISSUE_TEMPLATE.md" ] 50 | } 51 | -------------------------------------------------------------------------------- /src/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

Firenvim is configured by creating a firenvim_config object in your init.vim/init.lua. Learn more about configuring Firenvim here!

7 | 8 | 9 | -------------------------------------------------------------------------------- /src/page.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "./EventEmitter"; 2 | import { FirenvimElement } from "./FirenvimElement"; 3 | import { executeInPage } from "./utils/utils"; 4 | import { getConf } from "./utils/configuration"; 5 | import { keysToEvents } from "./utils/keys"; 6 | 7 | // This module is loaded in both the browser's content script and the browser's 8 | // frame script. 9 | // As such, it should not have any side effects. 10 | 11 | interface IGlobalState { 12 | disabled: boolean | Promise; 13 | lastFocusedContentScript: number; 14 | firenvimElems: Map; 15 | frameIdResolve: (_: number) => void; 16 | nvimify: (evt: FocusEvent) => void; 17 | } 18 | 19 | ///////////////////////////////////////////// 20 | // Functions running in the content script // 21 | ///////////////////////////////////////////// 22 | 23 | function _focusInput(global: IGlobalState, firenvim: FirenvimElement, addListener: boolean) { 24 | if (addListener) { 25 | // Only re-add event listener if input's selector matches the ones 26 | // that should be autonvimified 27 | const conf = getConf(); 28 | if (conf.selector && conf.selector !== "") { 29 | const elems = Array.from(document.querySelectorAll(conf.selector)); 30 | addListener = elems.includes(firenvim.getElement()); 31 | } 32 | } 33 | firenvim.focusOriginalElement(addListener); 34 | } 35 | 36 | function getFocusedElement (firenvimElems: Map) { 37 | return Array 38 | .from(firenvimElems.values()) 39 | .find(instance => instance.isFocused()); 40 | } 41 | 42 | // Tab functions are functions all content scripts should react to 43 | export function getTabFunctions(global: IGlobalState) { 44 | return { 45 | getActiveInstanceCount : () => global.firenvimElems.size, 46 | registerNewFrameId: (frameId: number) => { 47 | global.frameIdResolve(frameId); 48 | }, 49 | setDisabled: (disabled: boolean) => { 50 | global.disabled = disabled; 51 | }, 52 | setLastFocusedContentScript: (frameId: number) => { 53 | global.lastFocusedContentScript = frameId; 54 | } 55 | }; 56 | } 57 | 58 | function isVisible(e: HTMLElement) { 59 | const rect = e.getBoundingClientRect(); 60 | const viewHeight = Math.max(document.documentElement.clientHeight, window.innerHeight); 61 | return !(rect.bottom < 0 || rect.top - viewHeight >= 0); 62 | } 63 | 64 | // ActiveContent functions are functions only the active content script should react to 65 | export function getActiveContentFunctions(global: IGlobalState) { 66 | return { 67 | forceNvimify: () => { 68 | let elem = document.activeElement; 69 | const isNull = elem === null || elem === undefined; 70 | const pageNotEditable = document.documentElement.contentEditable !== "true"; 71 | const bodyNotEditable = (document.body.contentEditable === "false" 72 | || (document.body.contentEditable === "inherit" 73 | && document.documentElement.contentEditable !== "true")); 74 | if (isNull 75 | || (elem === document.documentElement && pageNotEditable) 76 | || (elem === document.body && bodyNotEditable)) { 77 | elem = Array.from(document.getElementsByTagName("textarea")) 78 | .find(isVisible); 79 | if (!elem) { 80 | elem = Array.from(document.getElementsByTagName("input")) 81 | .find(e => e.type === "text" && isVisible(e)); 82 | } 83 | if (!elem) { 84 | return; 85 | } 86 | } 87 | global.nvimify({ target: elem } as any); 88 | }, 89 | sendKey: (key: string) => { 90 | const firenvim = getFocusedElement(global.firenvimElems); 91 | if (firenvim !== undefined) { 92 | firenvim.sendKey(key); 93 | } else { 94 | // It's important to throw this error as the background script 95 | // will execute a fallback 96 | throw new Error("No firenvim frame selected"); 97 | } 98 | }, 99 | }; 100 | } 101 | 102 | function focusElementBeforeOrAfter(global: IGlobalState, frameId: number, i: 1 | -1) { 103 | let firenvimElement; 104 | if (frameId === undefined) { 105 | firenvimElement = getFocusedElement(global.firenvimElems); 106 | } else { 107 | firenvimElement = global.firenvimElems.get(frameId); 108 | } 109 | const originalElement = firenvimElement.getOriginalElement(); 110 | 111 | const tabindex = (e: Element) => ((x => isNaN(x) ? 0 : x)(parseInt(e.getAttribute("tabindex")))); 112 | const focusables = Array.from(document.querySelectorAll("input, select, textarea, button, object, [tabindex], [href]")) 113 | .filter(e => e.getAttribute("tabindex") !== "-1") 114 | .sort((e1, e2) => tabindex(e1) - tabindex(e2)); 115 | 116 | let index = focusables.indexOf(originalElement); 117 | let elem: Element; 118 | if (index === -1) { 119 | // originalElement isn't in the list of focusables, so we have to 120 | // figure out what the closest element is. We do this by iterating over 121 | // all elements of the dom, accepting only originalElement and the 122 | // elements that are focusable. Once we find originalElement, we select 123 | // either the previous or next element depending on the value of i. 124 | const treeWalker = document.createTreeWalker( 125 | document.body, 126 | NodeFilter.SHOW_ELEMENT, 127 | { 128 | acceptNode: n => ((n === originalElement || focusables.indexOf((n as Element)) !== -1) 129 | ? NodeFilter.FILTER_ACCEPT 130 | : NodeFilter.FILTER_REJECT) 131 | }, 132 | ); 133 | const firstNode = treeWalker.currentNode as Element; 134 | let cur = firstNode; 135 | let prev; 136 | while (cur && cur !== originalElement) { 137 | prev = cur; 138 | cur = treeWalker.nextNode() as Element; 139 | } 140 | if (i > 0) { 141 | elem = treeWalker.nextNode() as Element; 142 | } else { 143 | elem = prev; 144 | } 145 | // Sanity check, can't be exercised 146 | /* istanbul ignore next */ 147 | if (!elem) { 148 | elem = firstNode; 149 | } 150 | } else { 151 | elem = focusables[(index + i + focusables.length) % focusables.length]; 152 | } 153 | 154 | index = focusables.indexOf(elem); 155 | // Sanity check, can't be exercised 156 | /* istanbul ignore next */ 157 | if (index === -1) { 158 | throw "Oh my, something went wrong!"; 159 | } 160 | 161 | // Now that we know we have an element that is in the focusable element 162 | // list, iterate over the list to find one that is visible. 163 | let startedAt; 164 | let style = getComputedStyle(elem); 165 | while (startedAt !== index && (style.visibility !== "visible" || style.display === "none")) { 166 | if (startedAt === undefined) { 167 | startedAt = index; 168 | } 169 | index = (index + i + focusables.length) % focusables.length; 170 | elem = focusables[index]; 171 | style = getComputedStyle(elem); 172 | } 173 | 174 | (document.activeElement as any).blur(); 175 | const sel = document.getSelection(); 176 | sel.removeAllRanges(); 177 | const range = document.createRange(); 178 | if (elem.ownerDocument.contains(elem)) { 179 | range.setStart(elem, 0); 180 | } 181 | range.collapse(true); 182 | (elem as HTMLElement).focus(); 183 | sel.addRange(range); 184 | } 185 | 186 | export function getNeovimFrameFunctions(global: IGlobalState) { 187 | return { 188 | evalInPage: (_: number, js: string) => executeInPage(js), 189 | focusInput: (frameId: number) => { 190 | let firenvimElement; 191 | if (frameId === undefined) { 192 | firenvimElement = getFocusedElement(global.firenvimElems); 193 | } else { 194 | firenvimElement = global.firenvimElems.get(frameId); 195 | } 196 | _focusInput(global, firenvimElement, true); 197 | }, 198 | focusPage: (frameId: number) => { 199 | const firenvimElement = global.firenvimElems.get(frameId); 200 | firenvimElement.clearFocusListeners(); 201 | (document.activeElement as any).blur(); 202 | document.documentElement.focus(); 203 | }, 204 | focusNext: (frameId: number) => { 205 | focusElementBeforeOrAfter(global, frameId, 1); 206 | }, 207 | focusPrev: (frameId: number) => { 208 | focusElementBeforeOrAfter(global, frameId, -1); 209 | }, 210 | getEditorInfo: (frameId: number) => global 211 | .firenvimElems 212 | .get(frameId) 213 | .getBufferInfo(), 214 | getElementContent: (frameId: number) => global 215 | .firenvimElems 216 | .get(frameId) 217 | .getPageElementContent(), 218 | hideEditor: (frameId: number) => { 219 | const firenvim = global.firenvimElems.get(frameId); 220 | firenvim.hide(); 221 | _focusInput(global, firenvim, true); 222 | }, 223 | killEditor: (frameId: number) => { 224 | const firenvim = global.firenvimElems.get(frameId); 225 | const isFocused = firenvim.isFocused(); 226 | firenvim.detachFromPage(); 227 | const conf = getConf(); 228 | if (isFocused) { 229 | _focusInput(global, firenvim, conf.takeover !== "once"); 230 | } 231 | global.firenvimElems.delete(frameId); 232 | }, 233 | pressKeys: (frameId: number, keys: string[]) => { 234 | global.firenvimElems.get(frameId).pressKeys(keysToEvents(keys)); 235 | }, 236 | resizeEditor: (frameId: number, width: number, height: number) => { 237 | const elem = global.firenvimElems.get(frameId); 238 | elem.resizeTo(width, height, true); 239 | elem.putEditorCloseToInputOriginAfterResizeFromFrame(); 240 | }, 241 | setElementContent: (frameId: number, text: string) => { 242 | return global.firenvimElems.get(frameId).setPageElementContent(text); 243 | }, 244 | setElementCursor: (frameId: number, line: number, column: number) => { 245 | return global.firenvimElems.get(frameId).setPageElementCursor(line, column); 246 | }, 247 | }; 248 | } 249 | 250 | ////////////////////////////////////////////////////////////////////////////// 251 | // Definition of a proxy type that lets the frame script transparently call // 252 | // the content script's functions // 253 | ////////////////////////////////////////////////////////////////////////////// 254 | 255 | // The proxy automatically appends the frameId to the request, so we hide that from users 256 | type ArgumentsType = T extends (x: any, ...args: infer U) => any ? U: never; 257 | type Promisify = T extends Promise ? T : Promise; 258 | 259 | type ft = ReturnType 260 | 261 | type PageEvents = "resize" | "frame_sendKey" | "get_buf_content" | "pause_keyhandler"; 262 | type PageHandlers = (args: any[]) => void; 263 | export class PageEventEmitter extends EventEmitter { 264 | constructor() { 265 | super(); 266 | browser.runtime.onMessage.addListener((request: any, _sender: any, _sendResponse: any) => { 267 | switch (request.funcName[0]) { 268 | case "pause_keyhandler": 269 | case "frame_sendKey": 270 | case "resize": 271 | this.emit(request.funcName[0], request.args); 272 | break; 273 | case "get_buf_content": 274 | return new Promise(resolve => this.emit(request.funcName[0], resolve)); 275 | case "evalInPage": 276 | case "resizeEditor": 277 | case "getElementContent": 278 | case "getEditorInfo": 279 | // handled by frame function handler 280 | break; 281 | default: 282 | console.error("Unhandled page request:", request); 283 | } 284 | }); 285 | } 286 | } 287 | 288 | export type PageType = PageEventEmitter & { 289 | [k in keyof ft]: (...args: ArgumentsType) => Promisify>; 290 | }; 291 | 292 | export function getPageProxy (frameId: number) { 293 | const page = new PageEventEmitter(); 294 | 295 | let funcName: keyof PageType; 296 | for (funcName in getNeovimFrameFunctions({} as any)) { 297 | // We need to declare func here because funcName is a global and would not 298 | // be captured in the closure otherwise 299 | const func = funcName; 300 | (page as any)[func] = ((...arr: any[]) => { 301 | return browser.runtime.sendMessage({ 302 | args: { 303 | args: [frameId].concat(arr), 304 | funcName: [func], 305 | }, 306 | funcName: ["messagePage"], 307 | }); 308 | }); 309 | } 310 | return page as PageType; 311 | } 312 | -------------------------------------------------------------------------------- /src/testing/background.ts: -------------------------------------------------------------------------------- 1 | import { makeRequestHandler } from "./rpc"; 2 | import * as background from "../background"; 3 | 4 | // This console.log is mostly to force webpack to import background here 5 | background.preloadedInstance.then(() => console.log("preloaded instance loaded!")); 6 | 7 | const socket = new WebSocket('ws://127.0.0.1:12345'); 8 | socket.addEventListener('message', makeRequestHandler(socket, 9 | "background", 10 | (window as any).__coverage__ || /* istanbul ignore next */ {})); 11 | -------------------------------------------------------------------------------- /src/testing/content.ts: -------------------------------------------------------------------------------- 1 | // This script is only loaded in firefox-testing and chrome-testing builds 2 | // (check manifest.json if you want to make sure of that) It provides a way for 3 | // the page to ask the webextension to reload the neovim instance. This is 4 | // necessary for testing reasons (we sometimes might create states that 5 | // "poison" firenvim and need to reset it). 6 | 7 | import { makeRequestHandler } from "./rpc"; 8 | import { listenersSetup } from "../content"; 9 | 10 | listenersSetup.then(() => { 11 | const socket = new WebSocket('ws://127.0.0.1:12345'); 12 | socket.addEventListener('message', 13 | makeRequestHandler(socket, 14 | "content", 15 | (new Function("return this"))().__coverage__ 16 | || /* istanbul ignore next */ {})); 17 | }); 18 | -------------------------------------------------------------------------------- /src/testing/frame.ts: -------------------------------------------------------------------------------- 1 | // This script is only loaded in firefox-testing and chrome-testing builds 2 | // (check manifest.json). It lets selenium know that Firenvim is ready to 3 | // receive events by connecting to the coverage server through a websocket. 4 | // Once connected, it can decide to push coverage information 5 | import { makeRequest, makeRequestHandler } from "./rpc"; 6 | 7 | // Of course we have to ignore the case where 8 | // coverage data doesn't exist. 9 | const coverageData = (window as any).__coverage__ || /* istanbul ignore next */ {}; 10 | 11 | let socket: WebSocket; 12 | function createSocket(): Promise { 13 | socket = new WebSocket('ws://127.0.0.1:12345'); 14 | socket.addEventListener('message', makeRequestHandler(socket, "frame", coverageData)); 15 | return new Promise(resolve => socket.addEventListener("open", () => resolve(socket))); 16 | } 17 | 18 | import { isReady } from "../frame"; 19 | import { PageType } from "../page"; 20 | 21 | isReady.then((page: PageType) => { 22 | page.killEditor = (f => async () => { 23 | if (socket === undefined) { 24 | // socket is undefined if isReady failed - this happens with the buggy 25 | // vimrc testcase. We still want coverage data when this happens so we 26 | // create the socket and push cov data immediately 27 | socket = await createSocket(); 28 | } 29 | await makeRequest(socket, "pushCoverage", [JSON.stringify(coverageData)]); 30 | // Ignoring this return because it's reached but after cov info has been 31 | // sent. 32 | /* istanbul ignore next */ 33 | return f(); 34 | })(page.killEditor); 35 | 36 | return createSocket(); 37 | }); 38 | 39 | -------------------------------------------------------------------------------- /src/testing/rpc.ts: -------------------------------------------------------------------------------- 1 | 2 | const requests = new Map(); 3 | 4 | let reqId = 0; 5 | // No reason to make requests for now. 6 | /* istanbul ignore next */ 7 | export function makeRequest(socket: any, func: string, args?: any[]): any { 8 | return new Promise(resolve => { 9 | reqId += 1; 10 | requests.set(reqId, resolve); 11 | socket.send(JSON.stringify({ reqId, funcName: [func], args })); 12 | }); 13 | } 14 | 15 | export function makeRequestHandler(s: any, context: string, coverageData: any) { 16 | return async (m: any) => { 17 | const req = JSON.parse(m.data); 18 | switch(req.funcName[0]) { 19 | // Ignoring the resolve case because the browser has no reason to 20 | // send requests to the coverage server for now. 21 | /* istanbul ignore next */ 22 | case "resolve": { 23 | const r = requests.get(req.reqId); 24 | if (r !== undefined) { 25 | r(...req.args); 26 | } else { 27 | console.error("Received answer to unsent request!", req); 28 | } 29 | } 30 | break; 31 | case "getContext": 32 | s.send(JSON.stringify({ 33 | args: [context], 34 | funcName: ["resolve"], 35 | reqId: req.reqId, 36 | })); 37 | break; 38 | case "getCoverageData": 39 | s.send(JSON.stringify({ 40 | args: [JSON.stringify(coverageData)], 41 | funcName: ["resolve"], 42 | reqId: req.reqId, 43 | })); 44 | // Ignoring this break because it's tested but cov data is sent 45 | // before. 46 | /* istanbul ignore next */ 47 | break; 48 | case "updateSettings": 49 | (window as any).updateSettings().finally(() => { 50 | s.send(JSON.stringify({ 51 | args: [], 52 | funcName: ["resolve"], 53 | reqId: req.reqId, 54 | })); 55 | }); 56 | break; 57 | case "tryUpdate": 58 | (window as any).updateIfPossible().finally(() => { 59 | s.send(JSON.stringify({ 60 | args: [], 61 | funcName: ["resolve"], 62 | reqId: req.reqId, 63 | })); 64 | }); 65 | break; 66 | case "acceptCommand": 67 | (window as any).acceptCommand(...req.args).finally(() => { 68 | s.send(JSON.stringify({ 69 | args: [], 70 | funcName: ["resolve"], 71 | reqId: req.reqId, 72 | })); 73 | }); 74 | break; 75 | case "eval": 76 | try { 77 | s.send(JSON.stringify({ 78 | args: [await eval(req.args[0])], 79 | funcName: ["resolve"], 80 | reqId: req.reqId, 81 | })); 82 | } catch (e) { 83 | s.send(JSON.stringify({ 84 | args: [{ 85 | message: e.message, 86 | cause: req.args[0], 87 | name: e.name, 88 | fileName: e.fileName, 89 | lineNumber: e.lineNumber, 90 | columnNumber: e.columnNumber, 91 | stack: e.stack, 92 | }], 93 | funcName: ["reject"], 94 | reqId: req.reqId, 95 | })); 96 | } 97 | } 98 | }; 99 | } 100 | -------------------------------------------------------------------------------- /src/utils/configuration.ts: -------------------------------------------------------------------------------- 1 | // These modes are defined in https://github.com/neovim/neovim/blob/master/src/nvim/cursor_shape.c 2 | export type NvimMode = "all" 3 | | "normal" 4 | | "visual" 5 | | "insert" 6 | | "replace" 7 | | "cmdline_normal" 8 | | "cmdline_insert" 9 | | "cmdline_replace" 10 | | "operator" 11 | | "visual_select" 12 | | "cmdline_hover" 13 | | "statusline_hover" 14 | | "statusline_drag" 15 | | "vsep_hover" 16 | | "vsep_drag" 17 | | "more" 18 | | "more_lastline" 19 | | "showmatch"; 20 | 21 | export interface ISiteConfig { 22 | cmdline: "neovim" | "firenvim" | "none"; 23 | content: "html" | "text"; 24 | priority: number; 25 | renderer: "html" | "canvas"; 26 | selector: string; 27 | takeover: "always" | "once" | "empty" | "nonempty" | "never"; 28 | filename: string; 29 | } 30 | 31 | export type GlobalSettings = { 32 | alt: "alphanum" | "all", 33 | "": "default" | "noop", 34 | "": "default" | "noop", 35 | "": "default" | "noop", 36 | "": "default" | "noop", 37 | "": "default" | "noop", 38 | "": "default" | "noop", 39 | ignoreKeys: { [key in NvimMode]: string[] }, 40 | cmdlineTimeout: number, 41 | } 42 | 43 | export interface IConfig { 44 | globalSettings: GlobalSettings; 45 | localSettings: { [key: string]: ISiteConfig }; 46 | } 47 | 48 | let conf: IConfig = undefined as IConfig; 49 | 50 | export function mergeWithDefaults(os: string, settings: any): IConfig { 51 | function makeDefaults(obj: { [key: string]: any }, name: string, value: any) { 52 | if (obj[name] === undefined) { 53 | obj[name] = value; 54 | } else if (typeof obj[name] !== typeof value 55 | || Array.isArray(obj[name]) !== Array.isArray(value)) { 56 | console.warn(`User config entry ${name} does not match expected type. Overriding.`); 57 | obj[name] = value; 58 | } 59 | } 60 | function makeDefaultLocalSetting(sett: { localSettings: { [key: string]: any } }, 61 | site: string, 62 | obj: ISiteConfig) { 63 | makeDefaults(sett.localSettings, site, {}); 64 | for (const key of (Object.keys(obj) as (keyof typeof obj)[])) { 65 | makeDefaults(sett.localSettings[site], key, obj[key]); 66 | } 67 | } 68 | if (settings === undefined) { 69 | settings = {}; 70 | } 71 | 72 | makeDefaults(settings, "globalSettings", {}); 73 | // "": "default" | "noop" 74 | // #103: When using the browser's command API to allow sending `` to 75 | // firenvim, whether the default action should be performed if no neovim 76 | // frame is focused. 77 | makeDefaults(settings.globalSettings, "", "default"); 78 | makeDefaults(settings.globalSettings, "", "default"); 79 | makeDefaults(settings.globalSettings, "", "default"); 80 | // Note: are currently disabled because of 81 | // https://github.com/neovim/neovim/issues/12037 82 | // Note: doesn't match the default behavior on firefox because this 83 | // would require the sessions API. Instead, Firefox's behavior matches 84 | // Chrome's. 85 | makeDefaults(settings.globalSettings, "", "default"); 86 | // Note: is there for completeness sake's but can't be emulated in 87 | // Chrome and Firefox because this would require the sessions API. 88 | makeDefaults(settings.globalSettings, "", "default"); 89 | makeDefaults(settings.globalSettings, "", "default"); 90 | // #717: allow passing keys to the browser 91 | makeDefaults(settings.globalSettings, "ignoreKeys", {}); 92 | // #1050: cursor sometimes covered by command line 93 | makeDefaults(settings.globalSettings, "cmdlineTimeout", 3000); 94 | 95 | // "alt": "all" | "alphanum" 96 | // #202: Only register alt key on alphanums to let swedish osx users type 97 | // special chars 98 | // Only tested on OSX, where we don't pull coverage reports, so don't 99 | // instrument function. 100 | /* istanbul ignore next */ 101 | if (os === "mac") { 102 | makeDefaults(settings.globalSettings, "alt", "alphanum"); 103 | } else { 104 | makeDefaults(settings.globalSettings, "alt", "all"); 105 | } 106 | 107 | makeDefaults(settings, "localSettings", {}); 108 | makeDefaultLocalSetting(settings, ".*", { 109 | // "cmdline": "neovim" | "firenvim" 110 | // #168: Use an external commandline to preserve space 111 | cmdline: "firenvim", 112 | content: "text", 113 | priority: 0, 114 | renderer: "canvas", 115 | selector: 'textarea:not([readonly], [aria-readonly]), div[role="textbox"]', 116 | // "takeover": "always" | "once" | "empty" | "nonempty" | "never" 117 | // #265: On "once", don't automatically bring back after :q'ing it 118 | takeover: "always", 119 | filename: "{hostname%32}_{pathname%32}_{selector%32}_{timestamp%32}.{extension}", 120 | }); 121 | return settings; 122 | } 123 | 124 | export const confReady = new Promise(resolve => { 125 | browser.storage.local.get().then((obj: any) => { 126 | conf = obj; 127 | resolve(true); 128 | }); 129 | }); 130 | 131 | browser.storage.onChanged.addListener((changes: any) => { 132 | Object 133 | .entries(changes) 134 | .forEach(([key, value]: [keyof IConfig, any]) => confReady.then(() => { 135 | conf[key] = value.newValue; 136 | })); 137 | }); 138 | 139 | export function getGlobalConf() { 140 | // Can't be tested for 141 | /* istanbul ignore next */ 142 | if (conf === undefined) { 143 | throw new Error("getGlobalConf called before config was ready"); 144 | } 145 | return conf.globalSettings; 146 | } 147 | 148 | export function getConf() { 149 | return getConfForUrl(document.location.href); 150 | } 151 | 152 | export function getConfForUrl(url: string): ISiteConfig { 153 | const localSettings = conf.localSettings; 154 | function or1(val: number) { 155 | if (val === undefined) { 156 | return 1; 157 | } 158 | return val; 159 | } 160 | // Can't be tested for 161 | /* istanbul ignore next */ 162 | if (localSettings === undefined) { 163 | throw new Error("Error: your settings are undefined. Try reloading the page. If this error persists, try the troubleshooting guide: https://github.com/glacambre/firenvim/blob/master/TROUBLESHOOTING.md"); 164 | } 165 | return Array.from(Object.entries(localSettings)) 166 | .filter(([pat, _]) => (new RegExp(pat)).test(url)) 167 | .sort((e1, e2) => (or1(e1[1].priority) - or1(e2[1].priority))) 168 | .reduce((acc, [_, cur]) => Object.assign(acc, cur), {} as ISiteConfig); 169 | } 170 | -------------------------------------------------------------------------------- /src/utils/keys.ts: -------------------------------------------------------------------------------- 1 | export const nonLiteralKeys: {[key: string]: string} = { 2 | " ": "", 3 | "<": "", 4 | "ArrowDown": "", 5 | "ArrowLeft": "", 6 | "ArrowRight": "", 7 | "ArrowUp": "", 8 | "Backspace": "", 9 | "Delete": "", 10 | "End": "", 11 | "Enter": "", 12 | "Escape": "", 13 | "F1": "", 14 | "F10": "", 15 | "F11": "", 16 | "F12": "", 17 | "F13": "", 18 | "F14": "", 19 | "F15": "", 20 | "F16": "", 21 | "F17": "", 22 | "F18": "", 23 | "F19": "", 24 | "F2": "", 25 | "F20": "", 26 | "F21": "", 27 | "F22": "", 28 | "F23": "", 29 | "F24": "", 30 | "F3": "", 31 | "F4": "", 32 | "F5": "", 33 | "F6": "", 34 | "F7": "", 35 | "F8": "", 36 | "F9": "", 37 | "Home": "", 38 | "PageDown": "", 39 | "PageUp": "", 40 | "Tab": "", 41 | "\\": "", 42 | "|": "", 43 | }; 44 | 45 | const nonLiteralVimKeys = Object.fromEntries(Object 46 | .entries(nonLiteralKeys) 47 | .map(([x, y]) => [y, x])); 48 | 49 | const nonLiteralKeyCodes: {[key: string]: number} = { 50 | "Enter": 13, 51 | "Space": 32, 52 | "Tab": 9, 53 | "Delete": 46, 54 | "End": 35, 55 | "Home": 36, 56 | "Insert": 45, 57 | "PageDown": 34, 58 | "PageUp": 33, 59 | "ArrowDown": 40, 60 | "ArrowLeft": 37, 61 | "ArrowRight": 39, 62 | "ArrowUp": 38, 63 | "Escape": 27, 64 | }; 65 | 66 | // Given a "special" key representation (e.g. or ), returns an 67 | // array of three javascript keyevents, the first one representing the 68 | // corresponding keydown, the second one a keypress and the third one a keyup 69 | // event. 70 | function modKeyToEvents(k: string) { 71 | let mods = ""; 72 | let key = nonLiteralVimKeys[k]; 73 | let ctrlKey = false; 74 | let altKey = false; 75 | let shiftKey = false; 76 | if (key === undefined) { 77 | const arr = k.slice(1, -1).split("-"); 78 | mods = arr[0]; 79 | key = arr[1]; 80 | ctrlKey = /c/i.test(mods); 81 | altKey = /a/i.test(mods); 82 | const specialChar = "<" + key + ">"; 83 | if (nonLiteralVimKeys[specialChar] !== undefined) { 84 | key = nonLiteralVimKeys[specialChar]; 85 | shiftKey = false; 86 | } else { 87 | shiftKey = key !== key.toLocaleLowerCase(); 88 | } 89 | } 90 | // Some pages rely on keyCodes to figure out what key was pressed. This is 91 | // awful because keycodes aren't guaranteed to be the same acrross 92 | // browsers/OS/keyboard layouts but try to do the right thing anyway. 93 | // https://github.com/glacambre/firenvim/issues/723 94 | let keyCode = 0; 95 | if (/^[a-zA-Z0-9]$/.test(key)) { 96 | keyCode = key.charCodeAt(0); 97 | } else if (nonLiteralKeyCodes[key] !== undefined) { 98 | keyCode = nonLiteralKeyCodes[key]; 99 | } 100 | const init = { key, keyCode, ctrlKey, altKey, shiftKey, bubbles: true }; 101 | return [ 102 | new KeyboardEvent("keydown", init), 103 | new KeyboardEvent("keypress", init), 104 | new KeyboardEvent("keyup", init), 105 | ]; 106 | } 107 | 108 | // Given a "simple" key (e.g. `a`, `1`…), returns an array of three javascript 109 | // events representing the action of pressing the key. 110 | function keyToEvents(key: string) { 111 | const shiftKey = key !== key.toLocaleLowerCase(); 112 | return [ 113 | new KeyboardEvent("keydown", { key, shiftKey, bubbles: true }), 114 | new KeyboardEvent("keypress", { key, shiftKey, bubbles: true }), 115 | new KeyboardEvent("keyup", { key, shiftKey, bubbles: true }), 116 | ]; 117 | } 118 | 119 | // Given an array of string representation of keys (e.g. ["a", "", …]), 120 | // returns an array of javascript keyboard events that simulate these keys 121 | // being pressed. 122 | export function keysToEvents(keys: string[]) { 123 | // Code to split mod keys and non-mod keys: 124 | // const keys = str.match(/([<>][^<>]+[<>])|([^<>]+)/g) 125 | // if (keys === null) { 126 | // return []; 127 | // } 128 | return keys.map((key) => { 129 | if (key[0] === "<") { 130 | return modKeyToEvents(key); 131 | } 132 | return keyToEvents(key); 133 | }).flat(); 134 | } 135 | 136 | // Turns a non-literal key (e.g. "Enter") into a vim-equivalent "" 137 | export function translateKey(key: string) { 138 | if (nonLiteralKeys[key] !== undefined) { 139 | return nonLiteralKeys[key]; 140 | } 141 | return key; 142 | } 143 | 144 | // Add modifier `mod` (`A`, `C`, `S`…) to `text` (a vim key `b`, ``, 145 | // ``…) 146 | export function addModifier(mod: string, text: string) { 147 | let match; 148 | let modifiers = ""; 149 | let key = ""; 150 | if ((match = text.match(/^<([A-Z]{1,5})-(.+)>$/))) { 151 | modifiers = match[1]; 152 | key = match[2]; 153 | } else if ((match = text.match(/^<(.+)>$/))) { 154 | key = match[1]; 155 | } else { 156 | key = text; 157 | } 158 | return "<" + mod + modifiers + "-" + key + ">"; 159 | } 160 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | let curHost : string; 2 | 3 | // Chrome doesn't have a "browser" object, instead it uses "chrome". 4 | if (window.location.protocol === "moz-extension:") { 5 | curHost = "firefox"; 6 | } else if (window.location.protocol === "chrome-extension:") { 7 | curHost = "chrome"; 8 | } else if ((window as any).InstallTrigger === undefined) { 9 | curHost = "chrome"; 10 | } else { 11 | curHost = "firefox"; 12 | } 13 | 14 | // Only usable in background script! 15 | export function isChrome() { 16 | // Can't cover error condition 17 | /* istanbul ignore next */ 18 | if (curHost === undefined) { 19 | throw Error("Used isChrome in content script!"); 20 | } 21 | return curHost === "chrome"; 22 | } 23 | 24 | // Runs CODE in the page's context by setting up a custom event listener, 25 | // embedding a script element that runs the piece of code and emits its result 26 | // as an event. 27 | export function executeInPage(code: string): Promise { 28 | // On firefox, use an API that allows circumventing some CSP restrictions 29 | // Use wrappedJSObject to detect availability of said API 30 | // DON'T use window.eval on other plateforms - it doesn't have the 31 | // semantics we need! 32 | if ((window as any).wrappedJSObject) { 33 | return new Promise((resolve, reject) => { 34 | try { 35 | resolve(window.eval(code)); 36 | } catch (e) { 37 | reject(e); 38 | } 39 | }); 40 | } 41 | return new Promise((resolve, reject) => { 42 | const script = document.createElement("script"); 43 | const eventId = (new URL(browser.runtime.getURL(""))).hostname + Math.random(); 44 | script.innerHTML = `(async (evId) => { 45 | try { 46 | let result; 47 | result = await ${code}; 48 | window.dispatchEvent(new CustomEvent(evId, { 49 | detail: { 50 | success: true, 51 | result, 52 | } 53 | })); 54 | } catch (e) { 55 | window.dispatchEvent(new CustomEvent(evId, { 56 | detail: { success: false, reason: e }, 57 | })); 58 | } 59 | })(${JSON.stringify(eventId)})`; 60 | window.addEventListener(eventId, ({ detail }: any) => { 61 | script.parentNode.removeChild(script); 62 | if (detail.success) { 63 | return resolve(detail.result); 64 | } 65 | return reject(detail.reason); 66 | }, { once: true }); 67 | document.head.appendChild(script); 68 | }); 69 | } 70 | 71 | // Various filters that are used to change the appearance of the BrowserAction 72 | // icon. 73 | const svgpath = "firenvim.svg"; 74 | const transformations = { 75 | disabled: (img: Uint8ClampedArray) => { 76 | for (let i = 0; i < img.length; i += 4) { 77 | // Skip transparent pixels 78 | if (img[i + 3] === 0) { 79 | continue; 80 | } 81 | const mean = Math.floor((img[i] + img[i + 1] + img[i + 2]) / 3); 82 | img[i] = mean; 83 | img[i + 1] = mean; 84 | img[i + 2] = mean; 85 | } 86 | }, 87 | error: (img: Uint8ClampedArray) => { 88 | for (let i = 0; i < img.length; i += 4) { 89 | // Turn transparent pixels red 90 | if (img[i + 3] === 0) { 91 | img[i] = 255; 92 | img[i + 3] = 255; 93 | } 94 | } 95 | }, 96 | normal: ((_img: Uint8ClampedArray) => (undefined as never)), 97 | notification: (img: Uint8ClampedArray) => { 98 | for (let i = 0; i < img.length; i += 4) { 99 | // Turn transparent pixels yellow 100 | if (img[i + 3] === 0) { 101 | img[i] = 255; 102 | img[i + 1] = 255; 103 | img[i + 3] = 255; 104 | } 105 | } 106 | }, 107 | }; 108 | 109 | export type IconKind = keyof typeof transformations; 110 | 111 | // Takes an icon kind and dimensions as parameter, draws that to a canvas and 112 | // returns a promise that will be resolved with the canvas' image data. 113 | export function getIconImageData(kind: IconKind, width = 32, height = 32) { 114 | const canvas = document.createElement("canvas") as HTMLCanvasElement; 115 | const ctx = canvas.getContext("2d"); 116 | const img = new Image(width, height); 117 | const result = new Promise((resolve) => img.addEventListener("load", () => { 118 | ctx.drawImage(img, 0, 0, width, height); 119 | const id = ctx.getImageData(0, 0, width, height); 120 | transformations[kind](id.data); 121 | resolve(id); 122 | })); 123 | img.src = svgpath; 124 | return result; 125 | } 126 | 127 | // Given a url and a selector, tries to compute a name that will be unique, 128 | // short and readable for the user. 129 | export function toFileName(formatString: string, url: string, id: string, language: string) { 130 | const parsedURL = new URL(url); 131 | 132 | const sanitize = (s: string) => (s.match(/[a-zA-Z0-9]+/g) || []).join("-"); 133 | 134 | const expand = (pattern: string) => { 135 | const noBrackets = pattern.slice(1, -1); 136 | const [symbol, length] = noBrackets.split("%"); 137 | let value = ""; 138 | switch (symbol) { 139 | case "hostname": value = parsedURL.hostname; break; 140 | case "pathname": value = sanitize(parsedURL.pathname); break; 141 | case "selector": value = sanitize(id.replace(/:nth-of-type/g, "")); break; 142 | case "timestamp": value = sanitize((new Date()).toISOString()); break; 143 | case "extension": value = languageToExtensions(language); break; 144 | default: console.error(`Unrecognized filename pattern: ${pattern}`); 145 | } 146 | return value.slice(-length); 147 | }; 148 | 149 | let result = formatString; 150 | const matches = formatString.match(/{[^}]*}/g); 151 | if (matches !== null) { 152 | for (const match of matches.filter(s => s !== undefined)) { 153 | result = result.replace(match, expand(match)); 154 | } 155 | } 156 | return result; 157 | } 158 | 159 | // Given a language name, returns a filename extension. Can return undefined. 160 | export function languageToExtensions(language: string) { 161 | if (language === undefined || language === null) { 162 | language = ""; 163 | } 164 | const lang = language.toLowerCase(); 165 | /* istanbul ignore next */ 166 | switch (lang) { 167 | case "apl": return "apl"; 168 | case "brainfuck": return "bf"; 169 | case "c": return "c"; 170 | case "c#": return "cs"; 171 | case "c++": return "cpp"; 172 | case "ceylon": return "ceylon"; 173 | case "clike": return "c"; 174 | case "clojure": return "clj"; 175 | case "cmake": return ".cmake"; 176 | case "cobol": return "cbl"; 177 | case "coffeescript": return "coffee"; 178 | case "commonlisp": return "lisp"; 179 | case "crystal": return "cr"; 180 | case "css": return "css"; 181 | case "cython": return "py"; 182 | case "d": return "d"; 183 | case "dart": return "dart"; 184 | case "diff": return "diff"; 185 | case "dockerfile": return "dockerfile"; 186 | case "dtd": return "dtd"; 187 | case "dylan": return "dylan"; 188 | // Eiffel was there first but elixir seems more likely 189 | // case "eiffel": return "e"; 190 | case "elixir": return "e"; 191 | case "elm": return "elm"; 192 | case "erlang": return "erl"; 193 | case "f#": return "fs"; 194 | case "factor": return "factor"; 195 | case "forth": return "fth"; 196 | case "fortran": return "f90"; 197 | case "gas": return "asm"; 198 | case "go": return "go"; 199 | // GFM: CodeMirror's github-flavored markdown 200 | case "gfm": return "md"; 201 | case "groovy": return "groovy"; 202 | case "haml": return "haml"; 203 | case "handlebars": return "hbs"; 204 | case "haskell": return "hs"; 205 | case "haxe": return "hx"; 206 | case "html": return "html"; 207 | case "htmlembedded": return "html"; 208 | case "htmlmixed": return "html"; 209 | case "ipython": return "py"; 210 | case "ipythonfm": return "md"; 211 | case "java": return "java"; 212 | case "javascript": return "js"; 213 | case "jinja2": return "jinja"; 214 | case "julia": return "jl"; 215 | case "jsx": return "jsx"; 216 | case "kotlin": return "kt"; 217 | case "latex": return "latex"; 218 | case "less": return "less"; 219 | case "lua": return "lua"; 220 | case "markdown": return "md"; 221 | case "mllike": return "ml"; 222 | case "ocaml": return "ml"; 223 | case "octave": return "m"; 224 | case "pascal": return "pas"; 225 | case "perl": return "pl"; 226 | case "php": return "php"; 227 | case "powershell": return "ps1"; 228 | case "python": return "py"; 229 | case "r": return "r"; 230 | case "rst": return "rst"; 231 | case "ruby": return "ruby"; 232 | case "rust": return "rs"; 233 | case "sas": return "sas"; 234 | case "sass": return "sass"; 235 | case "scala": return "scala"; 236 | case "scheme": return "scm"; 237 | case "scss": return "scss"; 238 | case "smalltalk": return "st"; 239 | case "shell": return "sh"; 240 | case "sql": return "sql"; 241 | case "stex": return "latex"; 242 | case "swift": return "swift"; 243 | case "tcl": return "tcl"; 244 | case "toml": return "toml"; 245 | case "twig": return "twig"; 246 | case "typescript": return "ts"; 247 | case "vb": return "vb"; 248 | case "vbscript": return "vbs"; 249 | case "verilog": return "sv"; 250 | case "vhdl": return "vhdl"; 251 | case "xml": return "xml"; 252 | case "yaml": return "yaml"; 253 | case "z80": return "z8a"; 254 | } 255 | return "txt"; 256 | } 257 | 258 | // Make tslint happy 259 | const fontFamily = "font-family"; 260 | 261 | // Can't be tested e2e :/ 262 | /* istanbul ignore next */ 263 | export function parseSingleGuifont(guifont: string, defaults: any) { 264 | const options = guifont.split(":"); 265 | const result = Object.assign({}, defaults); 266 | if (/^[a-zA-Z0-9]+$/.test(options[0])) { 267 | result[fontFamily] = options[0]; 268 | } else { 269 | result[fontFamily] = JSON.stringify(options[0]); 270 | } 271 | if (defaults[fontFamily]) { 272 | result[fontFamily] += `, ${defaults[fontFamily]}`; 273 | } 274 | return options.slice(1).reduce((acc, option) => { 275 | switch (option[0]) { 276 | case "h": 277 | acc["font-size"] = `${option.slice(1)}pt`; 278 | break; 279 | case "b": 280 | acc["font-weight"] = "bold"; 281 | break; 282 | case "i": 283 | acc["font-style"] = "italic"; 284 | break; 285 | case "u": 286 | acc["text-decoration"] = "underline"; 287 | break; 288 | case "s": 289 | acc["text-decoration"] = "line-through"; 290 | break; 291 | case "w": // Can't set font width. Would have to adjust cell width. 292 | case "c": // Can't set character set 293 | break; 294 | } 295 | return acc; 296 | }, result as any); 297 | } 298 | 299 | // Parses a guifont declaration as described in `:h E244` 300 | // defaults: default value for each of. 301 | // Can't be tested e2e :/ 302 | /* istanbul ignore next */ 303 | export function parseGuifont(guifont: string, defaults: any) { 304 | const fonts = guifont.split(",").reverse(); 305 | return fonts.reduce((acc, cur) => parseSingleGuifont(cur, acc), defaults); 306 | } 307 | 308 | // Computes a unique selector for its argument. 309 | export function computeSelector(element: HTMLElement) { 310 | function uniqueSelector(e: HTMLElement): string { 311 | // Only matching alphanumeric selectors because others chars might have special meaning in CSS 312 | if (e.id && e.id.match("^[a-zA-Z0-9_-]+$")) { 313 | const id = e.tagName + `[id="${e.id}"]`; 314 | if (document.querySelectorAll(id).length === 1) { 315 | return id; 316 | } 317 | } 318 | // If we reached the top of the document 319 | if (!e.parentElement) { return "HTML"; } 320 | // Compute the position of the element 321 | const index = 322 | Array.from(e.parentElement.children) 323 | .filter(child => child.tagName === e.tagName) 324 | .indexOf(e) + 1; 325 | return `${uniqueSelector(e.parentElement)} > ${e.tagName}:nth-of-type(${index})`; 326 | } 327 | return uniqueSelector(element); 328 | } 329 | 330 | // Turns a number into its hash+6 number hexadecimal representation. 331 | export function toHexCss(n: number) { 332 | if (n === undefined) 333 | return undefined; 334 | const str = n.toString(16); 335 | // Pad with leading zeros 336 | return "#" + (new Array(6 - str.length)).fill("0").join("") + str; 337 | } 338 | 339 | -------------------------------------------------------------------------------- /static/firenvim.svg: -------------------------------------------------------------------------------- 1 | 2 | 16 | 21 | 25 | 28 | 33 | 39 | 44 | 49 | 50 | 54 | 55 | 58 | 59 | 60 | 61 | Firenvim 62 | 64 | 67 | 71 | 75 | 76 | 84 | 89 | 94 | 95 | 103 | 107 | 111 | 112 | 120 | 125 | 130 | 131 | 140 | 144 | 148 | 152 | 156 | 160 | 164 | 165 | 173 | 177 | 181 | 185 | 189 | 193 | 197 | 201 | 205 | 206 | 214 | 218 | 222 | 226 | 230 | 234 | 238 | 242 | 246 | 250 | 251 | 260 | 261 | 266 | 270 | 271 | -------------------------------------------------------------------------------- /tests/_coverageserver.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import { Server } from "ws"; 4 | import * as istanbul from "istanbul-lib-coverage"; 5 | 6 | const requests = new Map(); 7 | 8 | function makeRequest(socket: any, func: string, args?: any[]): any { 9 | return new Promise((resolve, reject) => { 10 | let reqId = Math.random(); 11 | while (requests.get(reqId) !== undefined) { 12 | reqId = Math.random(); 13 | } 14 | requests.set(reqId, [resolve, reject]); 15 | socket.send(JSON.stringify({ reqId, funcName: [func], args })); 16 | }); 17 | } 18 | 19 | function makeRequestHandler(s: any) { 20 | return function (m: any) { 21 | const req = JSON.parse(m.toString()); 22 | switch(req.funcName[0]) { 23 | case "resolve": { 24 | const [r, _] = requests.get(req.reqId); 25 | if (r !== undefined) { 26 | r(...req.args); 27 | } else { 28 | console.error("Received answer to unsent request!", req); 29 | } 30 | } 31 | break; 32 | case "reject": { 33 | const [_, r] = requests.get(req.reqId); 34 | if (r !== undefined) { 35 | const err = req.args[0]; 36 | const e: any = new Error(); 37 | if (err.fileName) { 38 | e.message = `${err.fileName}:` 39 | + `${err.lineNumber}:` 40 | + `${err.columnNumber}: ` 41 | + err.message; 42 | } else { 43 | e.message = err.message; 44 | } 45 | e.message += ` (${err.cause})`; 46 | e.name = err.name; 47 | // e.stack = err.stack; 48 | r(e); 49 | } else { 50 | console.error("Received rejection to unsent request!", req); 51 | } 52 | }; 53 | break; 54 | case "pushCoverage": 55 | saveCoverageData(req.args[0]); 56 | s.send(JSON.stringify({ 57 | args: [], 58 | funcName: ["resolve"], 59 | reqId: req.reqId, 60 | })); 61 | break; 62 | } 63 | } 64 | } 65 | 66 | let server : Server = undefined; 67 | let backgroundSocket : Promise = undefined; 68 | let coverage_dir : string = undefined; 69 | const connectionResolves : any[] = []; 70 | export function start(port: number, path: string) { 71 | coverage_dir = path; 72 | server = new Server({ host: "127.0.0.1", port }); 73 | server.on("connection", s => { 74 | s.on("message", makeRequestHandler(s)) 75 | connectionResolves.forEach(r => r(s)); 76 | connectionResolves.length = 0; 77 | }); 78 | backgroundSocket = getNextBackgroundConnection(); 79 | return server; 80 | } 81 | 82 | 83 | // Returns a promise that resolves once a websocket is created 84 | export function getNextConnection () { 85 | return new Promise((resolve) => { 86 | connectionResolves.push(resolve); 87 | }); 88 | } 89 | 90 | // Returns a function that returns a promise that resolves once an object with 91 | // an attribute named kind and whose value matches X is returned. 92 | function getNextXConnection (X: "content" | "frame" | "background") { 93 | return function () { 94 | return new Promise(async (resolve) => { 95 | let isX: boolean; 96 | let socket : any; 97 | do { 98 | socket = await getNextConnection(); 99 | const context = await makeRequest(socket, "getContext"); 100 | isX = context === X; 101 | } while (!isX); 102 | resolve(socket); 103 | }); 104 | } 105 | } 106 | 107 | export const getNextBackgroundSocket = getNextXConnection("background"); 108 | export const getNextBackgroundConnection = () => { 109 | backgroundSocket = getNextBackgroundSocket(); 110 | return backgroundSocket; 111 | }; 112 | export const getNextFrameConnection = getNextXConnection("frame"); 113 | export const getNextContentConnection = getNextXConnection("content"); 114 | 115 | const covMap = istanbul.createCoverageMap({}); 116 | function saveCoverageData(coverageData: string) { 117 | const data = coverageData.replace(/webpack:\/\/Firenvim\/./g, process.cwd().replace("\\", "/")); 118 | covMap.merge(JSON.parse(data)); 119 | } 120 | 121 | export async function pullCoverageData (ws: any) { 122 | saveCoverageData(await makeRequest(ws, "getCoverageData")); 123 | } 124 | 125 | export function updateSettings () { 126 | return backgroundSocket.then((s : any) => makeRequest(s, "updateSettings")); 127 | }; 128 | 129 | export function toggleFirenvim () { 130 | return backgroundSocket.then((s : any) => makeRequest(s, "acceptCommand", ["toggle_firenvim"])); 131 | }; 132 | 133 | export function browserShortcut (k: string) { 134 | const command = "send_" + k.slice(1,-1); 135 | return backgroundSocket.then((s : any) => makeRequest(s, "acceptCommand", [command])); 136 | }; 137 | 138 | export function forceNvimify () { 139 | return backgroundSocket.then((s : any) => makeRequest(s, "acceptCommand", ["nvimify"])); 140 | } 141 | 142 | export function tryUpdate () { 143 | return backgroundSocket.then((s : any) => makeRequest(s, "tryUpdate")); 144 | }; 145 | 146 | export function backgroundEval (code: string) { 147 | return backgroundSocket.then((s : any) => makeRequest(s, "eval", [code])); 148 | }; 149 | 150 | export function contentEval (s: WebSocket, code: string) { 151 | return makeRequest(s, "eval", [code]); 152 | }; 153 | 154 | export function shutdown () { 155 | fs.writeFileSync(path.join(coverage_dir, "results"), 156 | JSON.stringify(covMap)); 157 | return new Promise((resolve) => server.close(resolve)); 158 | } 159 | -------------------------------------------------------------------------------- /tests/_vimrc.ts: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const os = require("os"); 3 | const path = require("path"); 4 | const process = require("process"); 5 | 6 | let init_vim: string; 7 | 8 | export function setupVimrc() { 9 | const base_dir = path.join(os.tmpdir(), "firenvim_test_run", `${Math.round(Math.random() * 100000)}`); 10 | process.env.XDG_CONFIG_HOME = path.join(base_dir, "config"); 11 | process.env.XDG_DATA_HOME = path.join(base_dir, "data"); 12 | const nvim_conf_dir = path.join(process.env.XDG_CONFIG_HOME, "nvim"); 13 | try { 14 | fs.mkdirSync(nvim_conf_dir, { recursive: true }); 15 | fs.mkdirSync(nvim_conf_dir, { recursive: true }); 16 | } catch (e) { 17 | console.error("Failed to create config/data dirs"); 18 | } 19 | init_vim = path.join(nvim_conf_dir, "init.vim"); 20 | return resetVimrc(); 21 | }; 22 | 23 | export function resetVimrc() { 24 | return writeVimrc(`set rtp+=${process.cwd()}\n`); 25 | } 26 | 27 | export function readVimrc() { 28 | if (init_vim === undefined) { 29 | throw new Error("readVimrc called without setupVimrc!"); 30 | } 31 | return fs.readFileSync(init_vim).toString(); 32 | }; 33 | 34 | export function writeVimrc(content: string) { 35 | if (init_vim === undefined) { 36 | throw new Error("writeVimrc called without setupVimrc!"); 37 | } 38 | return fs.writeFileSync(init_vim, content); 39 | }; 40 | 41 | -------------------------------------------------------------------------------- /tests/chrome.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | import * as fs from "fs"; 3 | import * as process from "process"; 4 | const env = process.env; 5 | import * as path from "path"; 6 | import * as webdriver from "selenium-webdriver"; 7 | 8 | import { 9 | loadLocalPage, 10 | extensionDir, 11 | writeFailures, 12 | killDriver, 13 | reloadNeovim, 14 | testAce, 15 | testBrowserShortcuts, 16 | testFrameBrowserShortcuts, 17 | testCodemirror, 18 | testConfigPriorities, 19 | testContentEditable, 20 | testDisappearing, 21 | testDynamicTextareas, 22 | testEvalJs, 23 | testFilenameSettings, 24 | testFocusGainedLost, 25 | testFocusNextPrev1, 26 | testFocusNextPrev2, 27 | testForceNvimify, 28 | testGithubAutofill, 29 | testGStartedByFirenvim, 30 | testGuifont, 31 | testHideEditor, 32 | testIgnoreKeys, 33 | testFocusInput, 34 | testInputFocusedAfterLeave, 35 | testInputResizes, 36 | testLargeBuffers, 37 | testModifiers, 38 | testMonaco, 39 | testMouse, 40 | testNestedDynamicTextareas, 41 | testNoLingeringNeovims, 42 | testFocusPage, 43 | testPressKeys, 44 | testResize, 45 | testSetCursor, 46 | testUnfocusedKillEditor, 47 | testUpdates, 48 | testUntrustedInput, 49 | testTakeoverEmpty, 50 | testTakeoverNonEmpty, 51 | testTakeoverOnce, 52 | testToggleFirenvim, 53 | testBrokenVimrc, 54 | testErrmsgVimrc, 55 | testWorksInFrame, 56 | } from "./_common" 57 | import { setupVimrc, resetVimrc } from "./_vimrc"; 58 | import * as coverageServer from "./_coverageserver"; 59 | 60 | describe("Chrome", () => { 61 | 62 | let driver: any = undefined; 63 | let server: any = coverageServer; 64 | let background: any = undefined; 65 | let neovimVersion: number = 0; 66 | 67 | beforeAll(async () => { 68 | neovimVersion = await new Promise(resolve => { 69 | exec("nvim --version", (_, stdout) => { 70 | resolve(parseFloat(stdout.match(/nvim v[0-9]+\.[0-9]+\.[0-9]+/gi)[0].slice(6))); 71 | }); 72 | }); 73 | 74 | const coverage_dir = path.join(process.cwd(), ".nyc_output"); 75 | try { 76 | fs.rmSync(coverage_dir, { recursive: true }); 77 | } catch (e) {} 78 | fs.mkdirSync(coverage_dir, { recursive: true }) 79 | 80 | await coverageServer.start(12345, coverage_dir); 81 | let backgroundPromise = coverageServer.getNextBackgroundConnection(); 82 | 83 | setupVimrc(); 84 | // Disabling the GPU is required on windows 85 | const options = (new (require("selenium-webdriver/chrome").Options)()) 86 | .addArguments("--disable-gpu") 87 | .addArguments("--disable-features=ChromeWhatsNewUI") 88 | .addArguments(`--load-extension=${path.join(extensionDir, "chrome")}`); 89 | 90 | // Won't work until this wontfix is fixed: 91 | // https://bugs.chromium.org/p/chromium/issues/detail?id=706008#c5 92 | if (env["HEADLESS"]) { 93 | return; 94 | // options.headless(); 95 | } 96 | 97 | // Set user data path so that the native messenger manifest can 98 | // be found. This is not required on windows because they use a 99 | // registry key to find it 100 | const home = env["HOME"] 101 | switch (require("os").platform()) { 102 | case "darwin": 103 | options.addArguments(`--user-data-dir=${path.join(home, "Library", "Application Support", "Google", "Chrome")}`) 104 | break; 105 | case "win32": 106 | break; 107 | default: 108 | options.addArguments(`--user-data-dir=${path.join(home, ".config", "google-chrome")}`) 109 | break; 110 | } 111 | 112 | driver = new webdriver.Builder() 113 | .forBrowser("chrome") 114 | .setChromeOptions(options) 115 | .build(); 116 | 117 | // Wait for extension to be loaded 118 | background = await backgroundPromise; 119 | await driver.sleep(1000); 120 | 121 | // Now we need to enable the extension in incognito mode if 122 | // it's not enabled. This is required for the browser keyboard 123 | // shortcut fallback test. 124 | await driver.get("chrome://extensions/?id=egpjdkipkomnmjhjmdamaniclmdlobbo"); 125 | let incognitoToggle = "document.querySelector('extensions-manager').shadowRoot.querySelector('#viewManager > extensions-detail-view.active').shadowRoot.querySelector('div#container.page-container > div.page-content > div#options-section extensions-toggle-row#allow-incognito').shadowRoot.querySelector('label#label input')"; 126 | const mustToggle = await driver.executeScript(`return !${incognitoToggle}.checked`); 127 | if (mustToggle) { 128 | // Extension is going to be reloaded when enabling incognito mode, so be prepared 129 | backgroundPromise = coverageServer.getNextBackgroundConnection(); 130 | await driver.sleep(1000); 131 | await driver.executeScript(`${incognitoToggle}.click()`); 132 | await driver.sleep(1000); 133 | background = await backgroundPromise; 134 | } 135 | return await loadLocalPage(server, driver, "simple.html", ""); 136 | }, 120000); 137 | 138 | beforeEach(async () => { 139 | resetVimrc(); 140 | await loadLocalPage(server, driver, "simple.html", "") 141 | await reloadNeovim(server, driver); 142 | return loadLocalPage(server, driver, "simple.html", "") 143 | }, 120000); 144 | 145 | afterEach(async () => { 146 | // This should kill existing webdriver promises (e.g. wait 147 | // until element found) and prevent one test's errors from 148 | // contaminating another's. 149 | await loadLocalPage(server, driver, "simple.html", ""); 150 | }, 120000); 151 | 152 | afterAll(async () => { 153 | await server.pullCoverageData(background); 154 | await server.shutdown(); 155 | writeFailures(); 156 | await killDriver(server, driver); 157 | }, 120000); 158 | 159 | function t(s: string, f: (s: string, s2: any, d: any) => Promise, ms?: number) { 160 | return test(s, () => f(s, server, driver), ms); 161 | } 162 | function o(s: string, f: (s: string, s2: any, d: any) => Promise, ms?: number) { 163 | return test.only(s, () => f(s, server, driver), ms); 164 | } 165 | 166 | t("Empty test always succeeds", () => new Promise(resolve => resolve(expect(true).toBe(true)))); 167 | t("Github autofill", testGithubAutofill); 168 | t("Setting filenames", testFilenameSettings); 169 | t("Force nvimify", testForceNvimify); 170 | t("Input focused after frame", testInputFocusedAfterLeave); 171 | t("FocusInput", testFocusInput); 172 | t("FocusNextPrev1", testFocusNextPrev1); 173 | t("FocusNextPrev2", testFocusNextPrev2); 174 | t("Dynamically created elements", testDynamicTextareas); 175 | t("Dynamically created nested elements", testNestedDynamicTextareas); 176 | t("Large buffers", testLargeBuffers); 177 | t("Modifiers work", testModifiers); 178 | t("Config priorities", testConfigPriorities); 179 | t("Add-on updates", testUpdates); 180 | t("CodeMirror", testCodemirror); 181 | t("Contenteditable", testContentEditable); 182 | t("Input resize", testInputResizes); 183 | t("g:started_by_firenvim", testGStartedByFirenvim); 184 | t("Works in frames", testWorksInFrame); 185 | t("FocusPage", testFocusPage); 186 | t("Ace editor", testAce); 187 | t("Unfocused killEditor", testUnfocusedKillEditor); 188 | t("Textarea.setCursor", testSetCursor); 189 | t("Hide editor", testHideEditor); 190 | t("Monaco editor", testMonaco); 191 | t("Span removed", testDisappearing); 192 | t("Ignoring keys", testIgnoreKeys); 193 | t("Browser shortcuts", testBrowserShortcuts); 194 | t("Frame browser shortcuts", (...args) => neovimVersion >= 0.5 195 | ? testFrameBrowserShortcuts(...args) 196 | : undefined 197 | , 30000); 198 | t("Takeover: nonempty", testTakeoverNonEmpty); 199 | t("Guifont", testGuifont); 200 | t("Takeover: once", testTakeoverOnce); 201 | t("PressKeys", testPressKeys); 202 | t("FocusGained/lost autocmds", testFocusGainedLost); 203 | t(":set columns lines", testResize); 204 | t("EvalJS", testEvalJs); 205 | t("Takeover: empty", testTakeoverEmpty); 206 | t("Toggling firenvim", testToggleFirenvim); 207 | t("Buggy Vimrc", testBrokenVimrc, 60000); 208 | if (neovimVersion > 0.7) { 209 | t("Vimrc emits error messages", testErrmsgVimrc); 210 | } 211 | if (process.platform !== "darwin") { 212 | // This test somehow fails on osx+chrome, so don't run it on this combination! 213 | t("Mouse", testMouse); 214 | } 215 | t("Untrusted input", testUntrustedInput); 216 | if (process.platform === "linux") { 217 | t("No lingering neovim process", testNoLingeringNeovims, 20000); 218 | } 219 | }) 220 | -------------------------------------------------------------------------------- /tests/firefox.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | import * as fs from "fs"; 3 | import * as process from "process"; 4 | const env = process.env; 5 | import * as path from "path"; 6 | import * as webdriver from "selenium-webdriver"; 7 | import { Options } from "selenium-webdriver/firefox"; 8 | 9 | import { 10 | writeFailures, 11 | loadLocalPage, 12 | extensionDir, 13 | getNewestFileIn, 14 | killDriver, 15 | reloadNeovim, 16 | testAce, 17 | // testBrowserShortcuts, 18 | testFrameBrowserShortcuts, 19 | testCodemirror, 20 | testContentEditable, 21 | testConfigPriorities, 22 | testDisappearing, 23 | testDynamicTextareas, 24 | testEvalJs, 25 | testFilenameSettings, 26 | testFocusGainedLost, 27 | testFocusNextPrev1, 28 | testFocusNextPrev2, 29 | testForceNvimify, 30 | testGithubAutofill, 31 | testGStartedByFirenvim, 32 | testGuifont, 33 | testHideEditor, 34 | testIgnoreKeys, 35 | testFocusInput, 36 | testInputFocusedAfterLeave, 37 | testInputResizes, 38 | testLargeBuffers, 39 | testModifiers, 40 | testMonaco, 41 | testMouse, 42 | testNestedDynamicTextareas, 43 | testNoLingeringNeovims, 44 | testFocusPage, 45 | testPressKeys, 46 | testResize, 47 | testSetCursor, 48 | testTakeoverEmpty, 49 | testTakeoverNonEmpty, 50 | testTakeoverOnce, 51 | testToggleFirenvim, 52 | testUnfocusedKillEditor, 53 | testUntrustedInput, 54 | testUpdates, 55 | testBrokenVimrc, 56 | testErrmsgVimrc, 57 | testWorksInFrame, 58 | } from "./_common" 59 | import { setupVimrc, resetVimrc } from "./_vimrc"; 60 | import * as coverageServer from "./_coverageserver"; 61 | 62 | 63 | describe("Firefox", () => { 64 | 65 | let driver: any = undefined; 66 | let server: any = coverageServer; 67 | let background: any = undefined; 68 | let neovimVersion: number = 0; 69 | 70 | beforeAll(async () => { 71 | neovimVersion = await new Promise(resolve => { 72 | exec("nvim --version", (_, stdout) => { 73 | resolve(parseFloat(stdout.match(/nvim v[0-9]+\.[0-9]+\.[0-9]+/gi)[0].slice(6))); 74 | }); 75 | }); 76 | 77 | const coverage_dir = path.join(process.cwd(), ".nyc_output"); 78 | try { 79 | fs.rmSync(coverage_dir, { recursive: true }); 80 | } catch (e) {} 81 | fs.mkdirSync(coverage_dir, { recursive: true }); 82 | 83 | await coverageServer.start(12345, coverage_dir); 84 | const backgroundPromise = coverageServer.getNextBackgroundConnection(); 85 | 86 | setupVimrc(); 87 | const extensionPath = await getNewestFileIn(path.join(extensionDir, "xpi")); 88 | 89 | const options = (new Options()) 90 | .setPreference("xpinstall.signatures.required", false) 91 | .addExtensions(extensionPath); 92 | 93 | if (env["HEADLESS"]) { 94 | options.headless(); 95 | } 96 | 97 | if (env["APPVEYOR"]) { 98 | options.setBinary("C:\\Program Files\\Firefox Developer Edition\\firefox.exe"); 99 | } 100 | 101 | driver = new webdriver.Builder() 102 | .forBrowser("firefox") 103 | .setFirefoxOptions(options) 104 | .build(); 105 | 106 | background = await backgroundPromise; 107 | const pageLoaded = loadLocalPage(server, driver, "simple.html", ""); 108 | return await pageLoaded; 109 | }, 120000); 110 | 111 | beforeEach(async () => { 112 | resetVimrc(); 113 | await loadLocalPage(server, driver, "simple.html", ""); 114 | await reloadNeovim(server, driver); 115 | await loadLocalPage(server, driver, "simple.html", "") 116 | }, 120000); 117 | 118 | afterEach(async () => { 119 | // This should kill existing webdriver promises (e.g. wait 120 | // until element found) and prevent one test's errors from 121 | // contaminating another's. 122 | await loadLocalPage(server, driver, "simple.html", ""); 123 | }, 120000); 124 | 125 | afterAll(async () => { 126 | await server.pullCoverageData(background); 127 | await server.shutdown(); 128 | writeFailures(); 129 | await killDriver(server, driver); 130 | }, 120000); 131 | 132 | function t(s: string, f: (s: string, s2: any, d: any) => Promise, ms?: number) { 133 | return test(s, () => f(s, server, driver), ms); 134 | } 135 | function o(s: string, f: (s: string, s2: any, d: any) => Promise, ms?: number) { 136 | return test.only(s, () => f(s, server, driver), ms); 137 | } 138 | 139 | t("Empty test always succeeds", () => new Promise(resolve => resolve(expect(true).toBe(true)))); 140 | t("Github autofill", testGithubAutofill); 141 | t("Setting filenames", testFilenameSettings); 142 | t("Force nvimify", testForceNvimify); 143 | t("Input focused after frame", testInputFocusedAfterLeave); 144 | t("FocusInput", testFocusInput); 145 | t("FocusNextPrev1", testFocusNextPrev1); 146 | t("FocusNextPrev2", testFocusNextPrev2); 147 | t("Dynamically created elements", testDynamicTextareas); 148 | t("Dynamically created nested elements", testNestedDynamicTextareas); 149 | t("Large buffers", testLargeBuffers); 150 | t("Modifiers work", testModifiers); 151 | t("Config priorities", testConfigPriorities); 152 | t("Add-on updates", testUpdates); 153 | t("CodeMirror", testCodemirror); 154 | t("Contenteditable", testContentEditable); 155 | t("Input resize", testInputResizes); 156 | t("g:started_by_firenvim", testGStartedByFirenvim); 157 | t("Works in frames", testWorksInFrame); 158 | t("FocusPage", testFocusPage); 159 | t("Ace editor", testAce); 160 | t("Unfocused killEditor", testUnfocusedKillEditor); 161 | t("Textarea.setCursor", testSetCursor); 162 | t("Hide editor", testHideEditor); 163 | t("Monaco editor", testMonaco); 164 | t("Span removed", testDisappearing); 165 | t("Ignoring keys", testIgnoreKeys); 166 | // t("Browser shortcuts", testBrowserShortcuts); // TODO: re-enable me 167 | t("Frame browser shortcuts", (...args) => neovimVersion >= 0.5 168 | ? testFrameBrowserShortcuts(...args) 169 | : undefined 170 | , 30000); 171 | t("Takeover: nonempty", testTakeoverNonEmpty); 172 | t("Guifont", testGuifont); 173 | t("Takeover: once", testTakeoverOnce); 174 | t("PressKeys", testPressKeys); 175 | t("FocusGained/lost autocmds", testFocusGainedLost); 176 | t(":set columns lines", testResize); 177 | t("EvalJS", testEvalJs); 178 | t("Takeover: empty", testTakeoverEmpty); 179 | t("Toggling firenvim", testToggleFirenvim); 180 | t("Buggy Vimrc", testBrokenVimrc, 60000); 181 | if (neovimVersion > 0.7) { 182 | t("Vimrc emits error messages", testErrmsgVimrc); 183 | } 184 | t("Mouse", testMouse); 185 | t("Untrusted input", testUntrustedInput); 186 | if (process.platform === "linux") { 187 | t("No lingering neovim process", testNoLingeringNeovims, 20000); 188 | } 189 | }) 190 | -------------------------------------------------------------------------------- /tests/pages/ace.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Ace test page 7 | 8 | 9 |
alert();
10 | 11 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /tests/pages/chat.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test Page 7 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /tests/pages/codemirror.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CodeMirror 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
24 |
25 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /tests/pages/contenteditable.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Test Page 8 | 9 | 10 |

11 | Firenvim works! 12 |

13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/pages/disappearing.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test Page 7 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /tests/pages/dynamic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test Page 7 | 8 | 9 | 10 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/pages/dynamic_nested.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test Page 7 | 8 | 9 | 10 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /tests/pages/focusnext.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | focus_next()/focus_prev() 7 | 12 | 13 | 14 | before 15 | 16 | 17 | nope 18 | after 19 | 20 | 21 | -------------------------------------------------------------------------------- /tests/pages/focusnext2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 2: focus_next()/focus_prev() 7 | 12 | 13 | 14 | before 15 | 16 | 17 | nope 18 | after 19 | 20 | 21 | -------------------------------------------------------------------------------- /tests/pages/input.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test Page 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/pages/monaco.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |

Monaco Editor Sync Loading Sample

11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /tests/pages/parentframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/pages/resize.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test Page 7 | 8 | 9 | 10 | 11 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /tests/pages/simple.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test Page 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "Node", 4 | "noImplicitAny": true, 5 | "sourceMap": true, 6 | "target": "ES2020", 7 | "typeRoots": ["node_modules/@types", "node_modules/web-ext-types/"], 8 | "alwaysStrict": true 9 | }, 10 | "include": [ 11 | "./src/**/*" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const ProvidePlugin = require("webpack").ProvidePlugin; 4 | const CopyWebPackPlugin = require("copy-webpack-plugin"); 5 | const sharp = require("sharp"); 6 | 7 | function deepCopy (obj) { 8 | if (obj instanceof Array) { 9 | return obj.slice(); 10 | } 11 | const result = {}; 12 | Object.assign(result, obj); 13 | Object.keys(result) 14 | .filter(key => (typeof result[key]) === "object") 15 | .forEach(key => result[key] = deepCopy(result[key])); 16 | return result; 17 | }; 18 | 19 | const browserFiles = [ 20 | ".github/ISSUE_TEMPLATE.md", 21 | "src/manifest.json", 22 | "src/options.html", 23 | "src/index.html", 24 | "src/browserAction.html", 25 | "static/firenvim.svg", 26 | ] 27 | 28 | const config = { 29 | mode: "development", 30 | 31 | entry: { 32 | background: "./src/background.ts", 33 | browserAction: "./src/browserAction.ts", 34 | content: "./src/content.ts", 35 | index: "./src/frame.ts", 36 | }, 37 | output: { 38 | filename: "[name].js", 39 | // Overwritten by browser-specific config 40 | // path: __dirname + "/target/extension", 41 | }, 42 | 43 | // Enable sourcemaps for debugging webpack's output. 44 | devtool: "inline-source-map", 45 | 46 | resolve: { 47 | // Add '.ts' and '.tsx' as resolvable extensions. 48 | extensions: [".ts", ".tsx", ".js", ".json"], 49 | }, 50 | 51 | module: { 52 | rules: [ 53 | // Load ts files with ts-loader 54 | { test: /\.tsx?$/, loader: "ts-loader" }, 55 | // For non-firefox browsers, we need to load a polyfill for the "browser" 56 | // object. This polyfill is loaded through webpack's Provide plugin. 57 | // Unfortunately, this plugin is pretty dumb and tries to provide an 58 | // empty object named "browser" to the webextension-polyfill library. 59 | // This results in the library not creating a browser object. The 60 | // following line makes sure `browser` is undefined when 61 | // webextension-polyfill is ran so that it can create a `browser` object. 62 | // This is why we shouldn't load webextension-polyfill for firefox - 63 | // otherwise we'd get a proxy instead of the real thing. 64 | { 65 | test: require.resolve("webextension-polyfill"), 66 | use: [{ 67 | loader: "imports-loader", 68 | options: { 69 | additionalCode: 'browser = undefined;', 70 | }, 71 | }] 72 | } 73 | ]}, 74 | 75 | // Overwritten by browser-specific config 76 | plugins: [], 77 | } 78 | 79 | const package_json = JSON.parse(require("fs").readFileSync(path.join(__dirname, "package.json"))) 80 | 81 | const chrome_target_dir = path.join(__dirname, "target", "chrome") 82 | const firefox_target_dir = path.join(__dirname, "target", "firefox") 83 | 84 | const chromeConfig = (config, env) => { 85 | const result = Object.assign(deepCopy(config), { 86 | output: { 87 | path: chrome_target_dir, 88 | }, 89 | plugins: [new CopyWebPackPlugin({ patterns: browserFiles.map(file => ({ 90 | from: file, 91 | to: chrome_target_dir, 92 | transform: (content, src) => { 93 | if (path.basename(src) === "manifest.json") { 94 | const manifest = JSON.parse(content.toString()) 95 | manifest["key"] = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk3pkgh862ElxtREZVPLxVNbiFWo9SnvZtZXZavNvs2GsUTY/mB9yHTPBGJiBMJh6J0l+F5JZivXDG7xdQsVD5t39CL3JGtt93M2svlsNkOEYIMM8tHbp69shNUKKjZOfT3t+aZyigK2OUm7PKedcPeHtMoZAY5cC4L1ytvgo6lge+VYQiypKF87YOsO/BGcs3D+MMdS454tLBuMp6LxMqICQEo/Q7nHGC3eubtL3B09s0l17fJeq/kcQphczKbUFhTVnNnIV0JX++UCWi+BP4QOpyk5FqI6+SVi+gxUosbQPOmZR4xCAbWWpg3OqMk4LqHaWpsBfkW9EUt6EMMMAfQIDAQAB"; 96 | manifest["version"] = package_json.version; 97 | manifest["description"] = package_json.description; 98 | manifest["icons"] = { 99 | "128": "firenvim128.png", 100 | "16": "firenvim16.png", 101 | "48": "firenvim48.png" 102 | } 103 | manifest.browser_action["default_icon"] = "firenvim128.png"; 104 | if (env.endsWith("testing")) { 105 | manifest.content_security_policy = "script-src 'self' 'unsafe-eval'; object-src 'self';" 106 | } 107 | content = JSON.stringify(manifest, undefined, 3); 108 | } 109 | return content; 110 | } 111 | })).concat([16, 48, 128].map(n => ({ 112 | from: "static/firenvim.svg", 113 | to: () => path.join(chrome_target_dir, `firenvim${n}.png`), 114 | transform: (content) => sharp(content).resize(n, n).toBuffer(), 115 | })))}), 116 | new ProvidePlugin({ "browser": "webextension-polyfill" }) 117 | ] 118 | }); 119 | try { 120 | fs.rmSync(result.output.path, { recursive: true }) 121 | } catch (e) { 122 | console.log(`Could not delete output dir (${e.message})`); 123 | } 124 | return result; 125 | } 126 | 127 | const firefoxConfig = (config, env) => { 128 | const result = Object.assign(deepCopy(config), { 129 | output: { 130 | path: firefox_target_dir, 131 | }, 132 | plugins: [new CopyWebPackPlugin({ 133 | patterns: browserFiles.map(file => ({ 134 | from: file, 135 | to: firefox_target_dir, 136 | transform: (content, src) => { 137 | switch(path.basename(src)) { 138 | case "manifest.json": 139 | const manifest = JSON.parse(content.toString()); 140 | manifest.browser_specific_settings = { 141 | "gecko": { 142 | "id": "firenvim@lacamb.re", 143 | "strict_min_version": "88.0" 144 | } 145 | }; 146 | manifest.version = package_json.version; 147 | manifest.description = package_json.description; 148 | if (env.endsWith("testing")) { 149 | manifest.content_security_policy = "script-src 'self' 'unsafe-eval'; object-src 'self';" 150 | } 151 | content = JSON.stringify(manifest, undefined, 3); 152 | } 153 | return content; 154 | } 155 | })) 156 | })] 157 | }); 158 | try { 159 | fs.rmSync(result.output.path, { recursive: true }) 160 | } catch (e) { 161 | console.log(`Could not delete output dir (${e.message})`); 162 | } 163 | return result; 164 | } 165 | 166 | module.exports = args => { 167 | let env = ""; 168 | if (args instanceof Object) { 169 | delete args.WEBPACK_BUNDLE; 170 | delete args.WEBPACK_BUILD; 171 | const keys = Object.keys(args); 172 | if (keys.length > 0) { 173 | env = keys[0]; 174 | } 175 | } 176 | 177 | if (env.endsWith("testing")) { 178 | config.entry.content = "./src/testing/content.ts"; 179 | config.entry.index = "./src/testing/frame.ts"; 180 | config.entry.background = "./src/testing/background.ts"; 181 | } 182 | 183 | if (env.startsWith("chrome")) { 184 | return [chromeConfig(config, env)]; 185 | } else if (env.startsWith("firefox")) { 186 | return [firefoxConfig(config, env)]; 187 | } 188 | return [chromeConfig(config, env), firefoxConfig(config, env)]; 189 | } 190 | 191 | --------------------------------------------------------------------------------