[
  {
    "path": ".dockerignore",
    "content": "Dockerfile\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\npatreon: browsh\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\n\n---\n\nJust a few points to consider before submitting a bug report:\n\n  * Do a quick search for any existing issues describing your bug\n  * Give a clear and concise description of what the bug is\n  * Include the contents of your `./debug.log` generated with `browsh --debug`\n  * Include your OS and terminal name\n"
  },
  {
    "path": ".github/workflows/lint.yml",
    "content": "name: Lint\non: [push, pull_request]\n\njobs:\n  lint:\n    runs-on: ubuntu-latest\n    env:\n      GOPATH: ${{ github.workspace }}\n      GOBIN: ${{ github.workspace }}/bin\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v3\n        with:\n          fetch-depth: 0\n      - name: Setup go\n        uses: actions/setup-go@v5\n        with:\n          go-version: 1.21.x\n      - name: Setup node\n        uses: actions/setup-node@v4\n        with:\n          node-version: 16\n\n      - run: npm ci\n        working-directory: ./webext\n      - name: Is web extension 'pretty'?\n        run: npm run lint\n        working-directory: ./webext\n\n      - name: Is Golang interfacer formatted?\n        run: ./ctl.sh golang_lint_check\n"
  },
  {
    "path": ".github/workflows/main.yml",
    "content": "name: Test-Release\non: [push, pull_request]\n\njobs:\n  tests:\n    name: \"Tests (webextension, interfacer: unit, tty, http-server)\"\n    runs-on: ubuntu-latest\n    env:\n      GOPATH: ${{ github.workspace }}\n      GOBIN: ${{ github.workspace }}/bin\n    outputs:\n      is_new_version: ${{ steps.check_versions.outputs.is_new_version }}\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n      - name: Setup go\n        uses: actions/setup-go@v5\n        with:\n          go-version-file: 'interfacer/go.mod'\n      - name: Setup node\n        uses: actions/setup-node@v4\n      - name: Install Firefox\n        uses: browser-actions/setup-firefox@latest\n        with:\n          firefox-version: \"140.0\"\n      - run: firefox --version\n\n      # Web extension tests\n      - run: npm ci\n        working-directory: ./webext\n      - name: Web extension tests\n        run: ./ctl.sh test_webextension\n\n      # Interfacer tests\n      - name: Interfacer tests setup\n        run: ./ctl.sh interfacer_test_setup\n      - name: Unit tests\n        run: ./ctl.sh test_interfacer_units\n      - name: E2E tests\n        run: ./ctl.sh test_tty\n      - name: TTY debug log\n        if: ${{ failure() }}\n        run: cat ./interfacer/test/tty/debug.log || echo \"No log file\"\n      - name: HTTP Server tests\n        uses: nick-fields/retry@v2\n        with:\n          max_attempts: 3\n          retry_on: error\n          timeout_minutes: 15\n          command: ./ctl.sh test_http_server\n      - name: HTTP Server debug log\n        if: ${{ failure() }}\n        run: cat ./interfacer/test/http-server/debug.log || echo \"No log file\"\n\n      - name: Check for new version\n        id: check_versions\n        run: ./ctl.sh github_actions_output_version_status\n\n  release:\n    name: Release\n    runs-on: ubuntu-latest\n    needs: tests\n    if: github.ref == 'refs/heads/master' && contains(needs.tests.outputs.is_new_version, 'true')\n    env:\n      GOPATH: ${{ github.workspace }}\n      GOBIN: ${{ github.workspace }}/bin\n      MDN_KEY: ${{ secrets.MDN_KEY }}\n      DOCKER_ACCESS_TOKEN: ${{ secrets.DOCKER_ACCESS_TOKEN }}\n      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n    steps:\n      - name: Setup Deploy Keys\n        uses: webfactory/ssh-agent@v0.5.4\n        with:\n          # Note that these private keys depend on having an ssh-keygen'd comment with the\n          # Git remote URL. This is because Github Actions use the *first* matching private\n          # key and fails if it doesn't match. webfactory/ssh-agent\n          ssh-private-key: |\n            ${{ secrets.HOMEBREW_DEPLOY_KEY }}\n            ${{ secrets.WWW_DEPLOY_KEY }}\n      - name: Checkout\n        uses: actions/checkout@v3\n        with:\n          fetch-depth: 0\n      - name: Setup node\n        uses: actions/setup-node@v3\n        with:\n          node-version-file: '.nvmrc'\n      - name: Setup go\n        uses: actions/setup-go@v3\n        with:\n          go-version-file: 'interfacer/go.mod'\n      - run: npm ci\n        working-directory: ./webext\n      - name: Binary Release\n        run: ./ctl.sh release\n      - name: Push new tag\n        uses: ad-m/github-push-action@master\n        with:\n          github_token: ${{ secrets.GITHUB_TOKEN }}\n          tags: true\n      - name: Login to Docker Hub\n        uses: docker/login-action@v2\n        with:\n          username: tombh\n          password: ${{ secrets.DOCKER_ACCESS_TOKEN }}\n      - name: Docker Release\n        run: ./ctl.sh docker_release\n      - name: Update Homebrew Tap\n        run: ./ctl.sh update_homebrew_tap_with_new_version\n      - name: Update Browsh Website\n        run: ./ctl.sh update_browsh_website_with_new_version\n"
  },
  {
    "path": ".gitignore",
    "content": "build/\n*.log\n*.out\nnode_modules\ninterfacer/target\ninterfacer/vendor\ninterfacer/dist\ninterfacer/interfacer\ninterfacer/browsh\nwebextension.go\nwebext/node_modules\nwebext/dist/*\ndist\n*.xpi\n\n# This is because of an odd permissions quirk on Github Actions. I can't seem to find a\n# way to delete these files in CI, so let's just ignore them. Otherwise Goreleaser complains\n# about a dirty working tree.\n/pkg\n/bin\n\n# Goreleaser needs to upload the webextension as an extra file in the release. But it doesn't\n# like Git to be in a dirty state. Also Goreleaser is run at PWD ./interfacer, so we can't\n# reference the webextension with something like ../webext/...\ninterfacer/browsh-*.xpi\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM debian:trixie-slim as build\n\nRUN apt update\nRUN apt install --yes \\\n      curl \\\n      ca-certificates \\\n      git \\\n      autoconf \\\n      automake \\\n      g++ \\\n      protobuf-compiler \\\n      zlib1g-dev \\\n      libncurses5-dev \\\n      libssl-dev \\\n      pkg-config \\\n      libprotobuf-dev \\\n      make \\\n      bzip2\n\n# Helper scripts\nWORKDIR /build\nADD .git .git\nADD .github .github\nADD scripts scripts\nADD ctl.sh .\n\n# Install Golang and Browsh\nENV GOROOT=/go\nENV GOPATH=/go-home\nENV PATH=$GOROOT/bin:$GOPATH/bin:$PATH\nENV BASE=$GOPATH/src/browsh/interfacer\nADD interfacer $BASE\nWORKDIR $BASE\nRUN /build/ctl.sh install_golang $BASE\nRUN /build/ctl.sh build_browsh_binary $BASE\n\n###########################\n# Actual final Docker image\n###########################\nFROM debian:trixie-slim\n\nENV HOME=/app\nWORKDIR $HOME\n\nCOPY --from=build /go-home/src/browsh/interfacer/browsh /app/bin/browsh\n\nRUN apt update\nRUN apt install --yes \\\n      xvfb \\\n      libgtk-3-0 \\\n      curl \\\n      ca-certificates \\\n      libdbus-glib-1-2 \\\n      procps \\\n      libasound2 \\\n      libxtst6 \\\n      firefox-esr\n\n# Block ads, etc. This includes porn just because this image is also used on the\n# public SSH demo: `ssh brow.sh`.\nRUN curl \\\n  -o /etc/hosts \\\n  https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/fakenews-gambling-porn-social/hosts\n\n# Don't use root\nRUN useradd -m user --home /app\nRUN chown user:user /app\nUSER user\n\nENV PATH=\"${HOME}/bin:${HOME}/bin/firefox:${PATH}\"\n\n# Firefox behaves quite differently to normal on its first run, so by getting\n# that over and done with here when there's no user to be dissapointed means\n# that all future runs will be consistent.\nRUN TERM=xterm script \\\n  --return \\\n  -c \"/app/bin/browsh\" \\\n  /dev/null \\\n  >/dev/null & \\\n  sleep 10\n\nENTRYPOINT [\"/app/bin/browsh\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "                  GNU LESSER GENERAL PUBLIC LICENSE\n                       Version 2.1, February 1999\n\n Copyright (C) 1991, 1999 Free Software Foundation, Inc.\n 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n[This is the first released version of the Lesser GPL.  It also counts\n as the successor of the GNU Library Public License, version 2, hence\n the version number 2.1.]\n\n                            Preamble\n\n  The licenses for most software are designed to take away your\nfreedom to share and change it.  By contrast, the GNU General Public\nLicenses are intended to guarantee your freedom to share and change\nfree software--to make sure the software is free for all its users.\n\n  This license, the Lesser General Public License, applies to some\nspecially designated software packages--typically libraries--of the\nFree Software Foundation and other authors who decide to use it.  You\ncan use it too, but we suggest you first think carefully about whether\nthis license or the ordinary General Public License is the better\nstrategy to use in any particular case, based on the explanations below.\n\n  When we speak of free software, we are referring to freedom of use,\nnot price.  Our General Public Licenses are designed to make sure that\nyou have the freedom to distribute copies of free software (and charge\nfor this service if you wish); that you receive source code or can get\nit if you want it; that you can change the software and use pieces of\nit in new free programs; and that you are informed that you can do\nthese things.\n\n  To protect your rights, we need to make restrictions that forbid\ndistributors to deny you these rights or to ask you to surrender these\nrights.  These restrictions translate to certain responsibilities for\nyou if you distribute copies of the library or if you modify it.\n\n  For example, if you distribute copies of the library, whether gratis\nor for a fee, you must give the recipients all the rights that we gave\nyou.  You must make sure that they, too, receive or can get the source\ncode.  If you link other code with the library, you must provide\ncomplete object files to the recipients, so that they can relink them\nwith the library after making changes to the library and recompiling\nit.  And you must show them these terms so they know their rights.\n\n  We protect your rights with a two-step method: (1) we copyright the\nlibrary, and (2) we offer you this license, which gives you legal\npermission to copy, distribute and/or modify the library.\n\n  To protect each distributor, we want to make it very clear that\nthere is no warranty for the free library.  Also, if the library is\nmodified by someone else and passed on, the recipients should know\nthat what they have is not the original version, so that the original\nauthor's reputation will not be affected by problems that might be\nintroduced by others.\n\n  Finally, software patents pose a constant threat to the existence of\nany free program.  We wish to make sure that a company cannot\neffectively restrict the users of a free program by obtaining a\nrestrictive license from a patent holder.  Therefore, we insist that\nany patent license obtained for a version of the library must be\nconsistent with the full freedom of use specified in this license.\n\n  Most GNU software, including some libraries, is covered by the\nordinary GNU General Public License.  This license, the GNU Lesser\nGeneral Public License, applies to certain designated libraries, and\nis quite different from the ordinary General Public License.  We use\nthis license for certain libraries in order to permit linking those\nlibraries into non-free programs.\n\n  When a program is linked with a library, whether statically or using\na shared library, the combination of the two is legally speaking a\ncombined work, a derivative of the original library.  The ordinary\nGeneral Public License therefore permits such linking only if the\nentire combination fits its criteria of freedom.  The Lesser General\nPublic License permits more lax criteria for linking other code with\nthe library.\n\n  We call this license the \"Lesser\" General Public License because it\ndoes Less to protect the user's freedom than the ordinary General\nPublic License.  It also provides other free software developers Less\nof an advantage over competing non-free programs.  These disadvantages\nare the reason we use the ordinary General Public License for many\nlibraries.  However, the Lesser license provides advantages in certain\nspecial circumstances.\n\n  For example, on rare occasions, there may be a special need to\nencourage the widest possible use of a certain library, so that it becomes\na de-facto standard.  To achieve this, non-free programs must be\nallowed to use the library.  A more frequent case is that a free\nlibrary does the same job as widely used non-free libraries.  In this\ncase, there is little to gain by limiting the free library to free\nsoftware only, so we use the Lesser General Public License.\n\n  In other cases, permission to use a particular library in non-free\nprograms enables a greater number of people to use a large body of\nfree software.  For example, permission to use the GNU C Library in\nnon-free programs enables many more people to use the whole GNU\noperating system, as well as its variant, the GNU/Linux operating\nsystem.\n\n  Although the Lesser General Public License is Less protective of the\nusers' freedom, it does ensure that the user of a program that is\nlinked with the Library has the freedom and the wherewithal to run\nthat program using a modified version of the Library.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.  Pay close attention to the difference between a\n\"work based on the library\" and a \"work that uses the library\".  The\nformer contains code derived from the library, whereas the latter must\nbe combined with the library in order to run.\n\n                  GNU LESSER GENERAL PUBLIC LICENSE\n   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION\n\n  0. This License Agreement applies to any software library or other\nprogram which contains a notice placed by the copyright holder or\nother authorized party saying it may be distributed under the terms of\nthis Lesser General Public License (also called \"this License\").\nEach licensee is addressed as \"you\".\n\n  A \"library\" means a collection of software functions and/or data\nprepared so as to be conveniently linked with application programs\n(which use some of those functions and data) to form executables.\n\n  The \"Library\", below, refers to any such software library or work\nwhich has been distributed under these terms.  A \"work based on the\nLibrary\" means either the Library or any derivative work under\ncopyright law: that is to say, a work containing the Library or a\nportion of it, either verbatim or with modifications and/or translated\nstraightforwardly into another language.  (Hereinafter, translation is\nincluded without limitation in the term \"modification\".)\n\n  \"Source code\" for a work means the preferred form of the work for\nmaking modifications to it.  For a library, complete source code means\nall the source code for all modules it contains, plus any associated\ninterface definition files, plus the scripts used to control compilation\nand installation of the library.\n\n  Activities other than copying, distribution and modification are not\ncovered by this License; they are outside its scope.  The act of\nrunning a program using the Library is not restricted, and output from\nsuch a program is covered only if its contents constitute a work based\non the Library (independent of the use of the Library in a tool for\nwriting it).  Whether that is true depends on what the Library does\nand what the program that uses the Library does.\n\n  1. You may copy and distribute verbatim copies of the Library's\ncomplete source code as you receive it, in any medium, provided that\nyou conspicuously and appropriately publish on each copy an\nappropriate copyright notice and disclaimer of warranty; keep intact\nall the notices that refer to this License and to the absence of any\nwarranty; and distribute a copy of this License along with the\nLibrary.\n\n  You may charge a fee for the physical act of transferring a copy,\nand you may at your option offer warranty protection in exchange for a\nfee.\n\n  2. You may modify your copy or copies of the Library or any portion\nof it, thus forming a work based on the Library, and copy and\ndistribute such modifications or work under the terms of Section 1\nabove, provided that you also meet all of these conditions:\n\n    a) The modified work must itself be a software library.\n\n    b) You must cause the files modified to carry prominent notices\n    stating that you changed the files and the date of any change.\n\n    c) You must cause the whole of the work to be licensed at no\n    charge to all third parties under the terms of this License.\n\n    d) If a facility in the modified Library refers to a function or a\n    table of data to be supplied by an application program that uses\n    the facility, other than as an argument passed when the facility\n    is invoked, then you must make a good faith effort to ensure that,\n    in the event an application does not supply such function or\n    table, the facility still operates, and performs whatever part of\n    its purpose remains meaningful.\n\n    (For example, a function in a library to compute square roots has\n    a purpose that is entirely well-defined independent of the\n    application.  Therefore, Subsection 2d requires that any\n    application-supplied function or table used by this function must\n    be optional: if the application does not supply it, the square\n    root function must still compute square roots.)\n\nThese requirements apply to the modified work as a whole.  If\nidentifiable sections of that work are not derived from the Library,\nand can be reasonably considered independent and separate works in\nthemselves, then this License, and its terms, do not apply to those\nsections when you distribute them as separate works.  But when you\ndistribute the same sections as part of a whole which is a work based\non the Library, the distribution of the whole must be on the terms of\nthis License, whose permissions for other licensees extend to the\nentire whole, and thus to each and every part regardless of who wrote\nit.\n\nThus, it is not the intent of this section to claim rights or contest\nyour rights to work written entirely by you; rather, the intent is to\nexercise the right to control the distribution of derivative or\ncollective works based on the Library.\n\nIn addition, mere aggregation of another work not based on the Library\nwith the Library (or with a work based on the Library) on a volume of\na storage or distribution medium does not bring the other work under\nthe scope of this License.\n\n  3. You may opt to apply the terms of the ordinary GNU General Public\nLicense instead of this License to a given copy of the Library.  To do\nthis, you must alter all the notices that refer to this License, so\nthat they refer to the ordinary GNU General Public License, version 2,\ninstead of to this License.  (If a newer version than version 2 of the\nordinary GNU General Public License has appeared, then you can specify\nthat version instead if you wish.)  Do not make any other change in\nthese notices.\n\n  Once this change is made in a given copy, it is irreversible for\nthat copy, so the ordinary GNU General Public License applies to all\nsubsequent copies and derivative works made from that copy.\n\n  This option is useful when you wish to copy part of the code of\nthe Library into a program that is not a library.\n\n  4. You may copy and distribute the Library (or a portion or\nderivative of it, under Section 2) in object code or executable form\nunder the terms of Sections 1 and 2 above provided that you accompany\nit with the complete corresponding machine-readable source code, which\nmust be distributed under the terms of Sections 1 and 2 above on a\nmedium customarily used for software interchange.\n\n  If distribution of object code is made by offering access to copy\nfrom a designated place, then offering equivalent access to copy the\nsource code from the same place satisfies the requirement to\ndistribute the source code, even though third parties are not\ncompelled to copy the source along with the object code.\n\n  5. A program that contains no derivative of any portion of the\nLibrary, but is designed to work with the Library by being compiled or\nlinked with it, is called a \"work that uses the Library\".  Such a\nwork, in isolation, is not a derivative work of the Library, and\ntherefore falls outside the scope of this License.\n\n  However, linking a \"work that uses the Library\" with the Library\ncreates an executable that is a derivative of the Library (because it\ncontains portions of the Library), rather than a \"work that uses the\nlibrary\".  The executable is therefore covered by this License.\nSection 6 states terms for distribution of such executables.\n\n  When a \"work that uses the Library\" uses material from a header file\nthat is part of the Library, the object code for the work may be a\nderivative work of the Library even though the source code is not.\nWhether this is true is especially significant if the work can be\nlinked without the Library, or if the work is itself a library.  The\nthreshold for this to be true is not precisely defined by law.\n\n  If such an object file uses only numerical parameters, data\nstructure layouts and accessors, and small macros and small inline\nfunctions (ten lines or less in length), then the use of the object\nfile is unrestricted, regardless of whether it is legally a derivative\nwork.  (Executables containing this object code plus portions of the\nLibrary will still fall under Section 6.)\n\n  Otherwise, if the work is a derivative of the Library, you may\ndistribute the object code for the work under the terms of Section 6.\nAny executables containing that work also fall under Section 6,\nwhether or not they are linked directly with the Library itself.\n\n  6. As an exception to the Sections above, you may also combine or\nlink a \"work that uses the Library\" with the Library to produce a\nwork containing portions of the Library, and distribute that work\nunder terms of your choice, provided that the terms permit\nmodification of the work for the customer's own use and reverse\nengineering for debugging such modifications.\n\n  You must give prominent notice with each copy of the work that the\nLibrary is used in it and that the Library and its use are covered by\nthis License.  You must supply a copy of this License.  If the work\nduring execution displays copyright notices, you must include the\ncopyright notice for the Library among them, as well as a reference\ndirecting the user to the copy of this License.  Also, you must do one\nof these things:\n\n    a) Accompany the work with the complete corresponding\n    machine-readable source code for the Library including whatever\n    changes were used in the work (which must be distributed under\n    Sections 1 and 2 above); and, if the work is an executable linked\n    with the Library, with the complete machine-readable \"work that\n    uses the Library\", as object code and/or source code, so that the\n    user can modify the Library and then relink to produce a modified\n    executable containing the modified Library.  (It is understood\n    that the user who changes the contents of definitions files in the\n    Library will not necessarily be able to recompile the application\n    to use the modified definitions.)\n\n    b) Use a suitable shared library mechanism for linking with the\n    Library.  A suitable mechanism is one that (1) uses at run time a\n    copy of the library already present on the user's computer system,\n    rather than copying library functions into the executable, and (2)\n    will operate properly with a modified version of the library, if\n    the user installs one, as long as the modified version is\n    interface-compatible with the version that the work was made with.\n\n    c) Accompany the work with a written offer, valid for at\n    least three years, to give the same user the materials\n    specified in Subsection 6a, above, for a charge no more\n    than the cost of performing this distribution.\n\n    d) If distribution of the work is made by offering access to copy\n    from a designated place, offer equivalent access to copy the above\n    specified materials from the same place.\n\n    e) Verify that the user has already received a copy of these\n    materials or that you have already sent this user a copy.\n\n  For an executable, the required form of the \"work that uses the\nLibrary\" must include any data and utility programs needed for\nreproducing the executable from it.  However, as a special exception,\nthe materials to be distributed need not include anything that is\nnormally distributed (in either source or binary form) with the major\ncomponents (compiler, kernel, and so on) of the operating system on\nwhich the executable runs, unless that component itself accompanies\nthe executable.\n\n  It may happen that this requirement contradicts the license\nrestrictions of other proprietary libraries that do not normally\naccompany the operating system.  Such a contradiction means you cannot\nuse both them and the Library together in an executable that you\ndistribute.\n\n  7. You may place library facilities that are a work based on the\nLibrary side-by-side in a single library together with other library\nfacilities not covered by this License, and distribute such a combined\nlibrary, provided that the separate distribution of the work based on\nthe Library and of the other library facilities is otherwise\npermitted, and provided that you do these two things:\n\n    a) Accompany the combined library with a copy of the same work\n    based on the Library, uncombined with any other library\n    facilities.  This must be distributed under the terms of the\n    Sections above.\n\n    b) Give prominent notice with the combined library of the fact\n    that part of it is a work based on the Library, and explaining\n    where to find the accompanying uncombined form of the same work.\n\n  8. You may not copy, modify, sublicense, link with, or distribute\nthe Library except as expressly provided under this License.  Any\nattempt otherwise to copy, modify, sublicense, link with, or\ndistribute the Library is void, and will automatically terminate your\nrights under this License.  However, parties who have received copies,\nor rights, from you under this License will not have their licenses\nterminated so long as such parties remain in full compliance.\n\n  9. You are not required to accept this License, since you have not\nsigned it.  However, nothing else grants you permission to modify or\ndistribute the Library or its derivative works.  These actions are\nprohibited by law if you do not accept this License.  Therefore, by\nmodifying or distributing the Library (or any work based on the\nLibrary), you indicate your acceptance of this License to do so, and\nall its terms and conditions for copying, distributing or modifying\nthe Library or works based on it.\n\n  10. Each time you redistribute the Library (or any work based on the\nLibrary), the recipient automatically receives a license from the\noriginal licensor to copy, distribute, link with or modify the Library\nsubject to these terms and conditions.  You may not impose any further\nrestrictions on the recipients' exercise of the rights granted herein.\nYou are not responsible for enforcing compliance by third parties with\nthis License.\n\n  11. If, as a consequence of a court judgment or allegation of patent\ninfringement or for any other reason (not limited to patent issues),\nconditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot\ndistribute so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you\nmay not distribute the Library at all.  For example, if a patent\nlicense would not permit royalty-free redistribution of the Library by\nall those who receive copies directly or indirectly through you, then\nthe only way you could satisfy both it and this License would be to\nrefrain entirely from distribution of the Library.\n\nIf any portion of this section is held invalid or unenforceable under any\nparticular circumstance, the balance of the section is intended to apply,\nand the section as a whole is intended to apply in other circumstances.\n\nIt is not the purpose of this section to induce you to infringe any\npatents or other property right claims or to contest validity of any\nsuch claims; this section has the sole purpose of protecting the\nintegrity of the free software distribution system which is\nimplemented by public license practices.  Many people have made\ngenerous contributions to the wide range of software distributed\nthrough that system in reliance on consistent application of that\nsystem; it is up to the author/donor to decide if he or she is willing\nto distribute software through any other system and a licensee cannot\nimpose that choice.\n\nThis section is intended to make thoroughly clear what is believed to\nbe a consequence of the rest of this License.\n\n  12. If the distribution and/or use of the Library is restricted in\ncertain countries either by patents or by copyrighted interfaces, the\noriginal copyright holder who places the Library under this License may add\nan explicit geographical distribution limitation excluding those countries,\nso that distribution is permitted only in or among countries not thus\nexcluded.  In such case, this License incorporates the limitation as if\nwritten in the body of this License.\n\n  13. The Free Software Foundation may publish revised and/or new\nversions of the Lesser General Public License from time to time.\nSuch new versions will be similar in spirit to the present version,\nbut may differ in detail to address new problems or concerns.\n\nEach version is given a distinguishing version number.  If the Library\nspecifies a version number of this License which applies to it and\n\"any later version\", you have the option of following the terms and\nconditions either of that version or of any later version published by\nthe Free Software Foundation.  If the Library does not specify a\nlicense version number, you may choose any version ever published by\nthe Free Software Foundation.\n\n  14. If you wish to incorporate parts of the Library into other free\nprograms whose distribution conditions are incompatible with these,\nwrite to the author to ask for permission.  For software which is\ncopyrighted by the Free Software Foundation, write to the Free\nSoftware Foundation; we sometimes make exceptions for this.  Our\ndecision will be guided by the two goals of preserving the free status\nof all derivatives of our free software and of promoting the sharing\nand reuse of software generally.\n\n                            NO WARRANTY\n\n  15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO\nWARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.\nEXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR\nOTHER PARTIES PROVIDE THE LIBRARY \"AS IS\" WITHOUT WARRANTY OF ANY\nKIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE\nLIBRARY IS WITH YOU.  SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME\nTHE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN\nWRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY\nAND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU\nFOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR\nCONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE\nLIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING\nRENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A\nFAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF\nSUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH\nDAMAGES.\n\n                     END OF TERMS AND CONDITIONS\n\n"
  },
  {
    "path": "README.md",
    "content": "[![Follow @brow_sh](https://img.shields.io/twitter/follow/brow_sh.svg?style=social&label=Follow)](https://twitter.com/intent/follow?screen_name=brow_sh)\n\n![Browsh Logo](https://www.brow.sh/assets/images/browsh-header.jpg)\n\n**A fully interactive, real-time, and modern text-based browser rendered to TTYs and browsers**\n\n![Browsh GIF](https://media.giphy.com/media/bbsmVkYjPdOKHhMXOO/giphy.gif)\n\n## Why use Browsh?\n\nNot all the world has good Internet.\n\nIf you only have a 3kbps internet connection tethered from a phone,\nthen it's good to SSH into a server and browse the web through, say,\n[elinks](https://github.com/browsh-org/browsh/issues/17). That way the\n_server_ downloads the web pages and uses the limited bandwidth of an\nSSH connection to display the result. However, traditional text-based browsers\nlack JS and all other modern HTML5 support. Browsh is different\nin that it's backed by a real browser, namely headless Firefox,\nto create a purely text-based version of web pages and web apps. These can be easily\nrendered in a terminal or indeed, ironically, in another browser. Do note that currently the browser client doesn't have feature parity with the terminal client.\n\nWhy not VNC? Well VNC is certainly one solution but it doesn't quite\nhave the same ability to deal with extremely bad Internet. Terminal \nBrowsh can also use MoSH to further reduce bandwidth and increase stability\nof the connection. Mosh offers features like automatic\nreconnection of dropped or roamed connections and diff-only screen updates.\nFurthermore, other than SSH or MoSH, terminal Browsh doesn't require a client\nlike VNC.\n\nOne final reason to use terminal Browsh could be to offload the battery-drain of a modern\nbrowser from your laptop or low-powered device like a Raspberry Pi. If you're a CLI-native,\nthen you could potentially get a few more hours of life if your CPU-hungry browser\nis running somewhere else on mains electricity.\n\n## Installation\n\nDownload a binary from the [releases](https://github.com/browsh-org/browsh/releases) (~11MB).\nYou will need to have [Firefox](https://www.mozilla.org/en-US/firefox/new/) already installed.\n\nOr download and run the Docker image (~230MB) with:\n    `docker run --rm -it browsh/browsh`\n\n## Usage\nMost keys and mouse gestures should work as you'd expect on a desktop\nbrowser.\n\nFor full documentation click [here](https://www.brow.sh/docs/introduction/).\n\n## Development\n\n### The Firefox Web Extension\nThis is needed to run essential JS inside web pages so that they render in a way that Browsh can consume.\n\nYou will need to install `nodejs`, usually available from your OS package manager. Though for development purposes the recommended method is with https://mise.jdx.dev. \n\nThen in the `webext` directory\n* `npm install`\n* `npx webpack --watch`\n\n### The `browsh` Golang code\nYou will need to install `go`, usually available from your OS package manager. Though for development purposes the recommended method is with https://mise.jdx.dev. \n\nThen in the `interfacer` directory\n* `go run ./cmd/browsh --debug`\n\nLogs will be available in `interfacer/debug.log`\n\n## Tests\n\nFor the webextension: in `webext/` folder, `npm test`    \nFor CLI unit tests: in `/interfacer` run `go test src/browsh/*.go`    \nFor CLI E2E tests: in `/interfacer` run `go test test/tty/*.go`    \nFor HTTP Service tests: in `/interfacer` run `go test test/http-server/*.go`    \n\n## Special Thanks\n  * [@tobimensch](https://github.com/tobimensch) For essential early feedback and user testing.\n  * [@arasatasaygin](https://github.com/arasatasaygin) For the Browsh logo.\n\n## Donating\nPlease consider donating: https://www.brow.sh/donate\n\n## License\nGNU Lesser General Public License v2.1\n"
  },
  {
    "path": "ctl.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\nfunction_to_run=$1\n\nexport PROJECT_ROOT\nexport GORELEASER_VERSION=1.10.2\n\nPROJECT_ROOT=$(cd -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" &>/dev/null && pwd)\n\nfunction _includes_path {\n\techo \"$PROJECT_ROOT\"/scripts\n}\n\nfunction _load_includes {\n\tfor file in \"$(_includes_path)\"/*.bash; do\n\t\t# shellcheck disable=1090\n\t\tsource \"$file\"\n\tdone\n}\n\n_load_includes\n\nif [[ $(type -t \"$function_to_run\") != function ]]; then\n\techo \"Subcommand: '$function_to_run' not found.\"\n\texit 1\nfi\n\nshift\n\npushd \"$PROJECT_ROOT\" || _panic\n\"$function_to_run\" \"$@\"\npopd || _panic\n"
  },
  {
    "path": "goreleaser.yml",
    "content": "# Run with `ctl.sh release` to get ENV vars\n\nproject_name: browsh\nbuilds:\n  - binary: browsh\n    env:\n      - CGO_ENABLED=0\n    main: cmd/browsh/main.go\n    goos:\n      - windows\n      - darwin\n      - linux\n      - freebsd\n      - openbsd\n    goarch:\n      - 386\n      - amd64\n      - arm\n      - arm64\n    goarm:\n      - 6\n      - 7\n    ignore:\n      - goos: darwin\n        goarch: 386\n      - goarch: arm64\n        goos: windows\n    ldflags: -s -w\n\narchives:\n  - format_overrides:\n    - goos: windows\n      format: binary\n    - goos: linux\n      format: binary\n    - goos: freebsd\n      format: binary\n    - goos: openbsd\n      format: binary\n\nnfpms:\n  - vendor: Browsh\n    homepage: https://www.brow.sh\n    maintainer: Thomas Buckley-Houston <tom@tombh.co.uk>\n    description: The modern, text-based browser\n    license: GPL v3\n    formats:\n      - deb\n      - rpm\n    dependencies:\n      - firefox\n    overrides:\n      deb:\n        dependencies:\n          - 'firefox | firefox-esr'\n\nbrews:\n  - name: browsh\n    tap:\n      name: homebrew-browsh\n    homepage: \"https://www.brow.sh\"\n    description: \"The modern, text-based browser\"\n    caveats: \"You need Firefox 57 or newer to run Browsh\"\n    # We do the upload manually because Goreleaser doesn't support Deploy Keys and Github\n    # doesn't support repo-specific Access Tokens 🙄\n    skip_upload: true\n\nrelease:\n  extra_files:\n    - glob: ./browsh-*.xpi\n"
  },
  {
    "path": "interfacer/cmd/browsh/main.go",
    "content": "package main\n\nimport \"github.com/browsh-org/browsh/interfacer/src/browsh\"\n\nfunc main() {\n\tbrowsh.MainEntry()\n}\n"
  },
  {
    "path": "interfacer/contrib/upx_compress_binary.sh",
    "content": "#!/usr/bin/env bash\nset -ex\nshopt -s extglob\n\npushd dist\nupx !(@(freebsd*|openbsd*|darwin*|linux_arm64))/*\npopd\n"
  },
  {
    "path": "interfacer/go.mod",
    "content": "module github.com/browsh-org/browsh/interfacer\n\ngo 1.24.4\n\nrequire (\n\tgithub.com/NYTimes/gziphandler v1.1.1\n\tgithub.com/gdamore/tcell v1.4.0\n\tgithub.com/go-errors/errors v1.5.1\n\tgithub.com/gorilla/websocket v1.5.1\n\tgithub.com/onsi/ginkgo v1.16.5\n\tgithub.com/onsi/gomega v1.30.0\n\tgithub.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0\n\tgithub.com/spf13/pflag v1.0.5\n\tgithub.com/spf13/viper v1.18.1\n\tgithub.com/ulule/limiter v2.2.2+incompatible\n\tgolang.org/x/sys v0.15.0\n)\n\nrequire (\n\tgithub.com/fsnotify/fsnotify v1.7.0 // indirect\n\tgithub.com/gdamore/encoding v1.0.0 // indirect\n\tgithub.com/google/go-cmp v0.6.0 // indirect\n\tgithub.com/hashicorp/hcl v1.0.0 // indirect\n\tgithub.com/lucasb-eyer/go-colorful v1.2.0 // indirect\n\tgithub.com/magiconair/properties v1.8.7 // indirect\n\tgithub.com/mattn/go-runewidth v0.0.15 // indirect\n\tgithub.com/mitchellh/mapstructure v1.5.0 // indirect\n\tgithub.com/nxadm/tail v1.4.11 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.1.0 // indirect\n\tgithub.com/pkg/errors v0.9.1 // indirect\n\tgithub.com/rivo/uniseg v0.4.4 // indirect\n\tgithub.com/sagikazarmark/locafero v0.4.0 // indirect\n\tgithub.com/sagikazarmark/slog-shim v0.1.0 // indirect\n\tgithub.com/sourcegraph/conc v0.3.0 // indirect\n\tgithub.com/spf13/afero v1.11.0 // indirect\n\tgithub.com/spf13/cast v1.6.0 // indirect\n\tgithub.com/subosito/gotenv v1.6.0 // indirect\n\tgo.uber.org/multierr v1.11.0 // indirect\n\tgolang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb // indirect\n\tgolang.org/x/net v0.19.0 // indirect\n\tgolang.org/x/text v0.14.0 // indirect\n\tgopkg.in/ini.v1 v1.67.0 // indirect\n\tgopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n"
  },
  {
    "path": "interfacer/go.sum",
    "content": "github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I=\ngithub.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=\ngithub.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=\ngithub.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=\ngithub.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=\ngithub.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=\ngithub.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=\ngithub.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=\ngithub.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=\ngithub.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=\ngithub.com/gdamore/tcell v1.4.0 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU=\ngithub.com/gdamore/tcell v1.4.0/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0=\ngithub.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=\ngithub.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=\ngithub.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=\ngithub.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=\ngithub.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=\ngithub.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=\ngithub.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=\ngithub.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=\ngithub.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=\ngithub.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=\ngithub.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=\ngithub.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=\ngithub.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=\ngithub.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=\ngithub.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=\ngithub.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE=\ngithub.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=\ngithub.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=\ngithub.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=\ngithub.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=\ngithub.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=\ngithub.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=\ngithub.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=\ngithub.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=\ngithub.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=\ngithub.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=\ngithub.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=\ngithub.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=\ngithub.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=\ngithub.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=\ngithub.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=\ngithub.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=\ngithub.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=\ngithub.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=\ngithub.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=\ngithub.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=\ngithub.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=\ngithub.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=\ngithub.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=\ngithub.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4=\ngithub.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o=\ngithub.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=\ngithub.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=\ngithub.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8=\ngithub.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=\ngithub.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=\ngithub.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=\ngithub.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=\ngithub.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=\ngithub.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=\ngithub.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=\ngithub.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=\ngithub.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=\ngithub.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=\ngithub.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0 h1:Xuk8ma/ibJ1fOy4Ee11vHhUFHQNpHhrBneOCNHVXS5w=\ngithub.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0/go.mod h1:7AwjWCpdPhkSmNAgUv5C7EJ4AbmjEB3r047r3DXWu3Y=\ngithub.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=\ngithub.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=\ngithub.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=\ngithub.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=\ngithub.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=\ngithub.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=\ngithub.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=\ngithub.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/spf13/viper v1.18.1 h1:rmuU42rScKWlhhJDyXZRKJQHXFX02chSVW1IvkPGiVM=\ngithub.com/spf13/viper v1.18.1/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=\ngithub.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=\ngithub.com/ulule/limiter v2.2.2+incompatible h1:1lk9jesmps1ziYHHb4doL7l5hFkYYYA3T8dkNyw7ffY=\ngithub.com/ulule/limiter v2.2.2+incompatible/go.mod h1:VJx/ZNGmClQDS5F6EmsGqK8j3jz1qJYZ6D9+MdAD+kw=\ngithub.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngo.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=\ngo.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8=\ngolang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=\ngolang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=\ngolang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=\ngolang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=\ngolang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=\ngolang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=\ngolang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=\ngolang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM=\ngolang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=\ngoogle.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=\ngoogle.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=\ngoogle.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=\ngoogle.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=\ngoogle.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=\ngoogle.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=\ngoogle.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=\ngopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=\ngopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=\ngopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=\ngopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=\ngopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "interfacer/src/browsh/browsh.go",
    "content": "package browsh\n\nimport (\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\n\t// TCell seems to be one of the best projects in any language for handling terminal\n\t// standards across the major OSs.\n\t\"github.com/gdamore/tcell\"\n\n\t\"github.com/go-errors/errors\"\n\t\"github.com/spf13/pflag\"\n\t\"github.com/spf13/viper\"\n)\n\nvar (\n\tlogo = `\n////  ////\n / /   / /\n //    //\n //    //    ,,,,,,,,\n ////////  ..,,,,,,,,,\n //    //  .., ,,, .,.\n ////////  .., ,,,,,..\n ////////  ..,,,,,,,,,\n ////////    ...........\n //////////\n ****///////////////////\n   ********///////////////\n     ***********************`\n\t// IsTesting is used in tests, so it needs to be exported\n\tIsTesting        = false\n\tIsHTTPServerMode = false\n\tlogfile          string\n\t_                = pflag.Bool(\"version\", false, \"Output current Browsh version\")\n)\n\nfunc setupLogging() {\n\tout := io.Discard\n\tif *isDebug {\n\t\tdir, err := os.Getwd()\n\t\tif err != nil {\n\t\t\tShutdown(err)\n\t\t}\n\t\tlogfile = fmt.Sprintf(\"%s\", filepath.Join(dir, \"debug.log\"))\n\t\tif out, err = os.OpenFile(logfile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644); err != nil {\n\t\t\tShutdown(err)\n\t\t}\n\t}\n\tslog.SetDefault(slog.New(slog.NewTextHandler(out, nil)))\n}\n\n// Initialise browsh\nfunc Initialise() {\n\tif IsTesting {\n\t\t*isDebug = true\n\t}\n\tsetupLogging()\n\tloadConfig()\n}\n\n// Shutdown tries its best to cleanly shutdown browsh and the associated browser\nfunc Shutdown(err error) {\n\tmsg := \"shutting down\"\n\tvar e *errors.Error\n\tif errors.As(err, &e) {\n\t\tslog.Error(msg, \"errorStack\", e.ErrorStack())\n\t} else {\n\t\tslog.Error(msg, \"error\", err)\n\t}\n\tif screen != nil {\n\t\tscreen.Fini()\n\t}\n\texitCode := 0\n\tif !errors.Is(err, errNormalExit) {\n\t\texitCode = 1\n\t}\n\tos.Exit(exitCode)\n}\n\nfunc Log(message string) {\n}\n\nfunc saveScreenshot(base64String string) {\n\tdec, err := base64.StdEncoding.DecodeString(base64String)\n\tif err != nil {\n\t\tShutdown(err)\n\t}\n\tfile, err := os.CreateTemp(\"\", \"browsh-screenshot\")\n\tif err != nil {\n\t\tShutdown(err)\n\t}\n\tdefer file.Close()\n\tif _, err := file.Write(dec); err != nil {\n\t\tShutdown(err)\n\t}\n\tif err := file.Sync(); err != nil {\n\t\tShutdown(err)\n\t}\n\tfullPath := file.Name() + \".jpg\"\n\tif err := os.Rename(file.Name(), fullPath); err != nil {\n\t\tShutdown(err)\n\t}\n\tmessage := \"Screenshot saved to \" + fullPath\n\tsendMessageToWebExtension(\"/status,\" + message)\n}\n\n// Shell provides nice and easy shell commands\nfunc Shell(command string) string {\n\tparts := strings.Fields(command)\n\thead := parts[0]\n\tparts = parts[1:]\n\tout, err := exec.Command(head, parts...).CombinedOutput()\n\tif err != nil {\n\t\terr := fmt.Errorf(\n\t\t\t\"Browsh tried to run `%s` but failed with: %s, err: %w\",\n\t\t\tcommand,\n\t\t\tstring(out),\n\t\t\terr,\n\t\t)\n\t\tShutdown(err)\n\t}\n\treturn strings.TrimSpace(string(out))\n}\n\n// TTYStart starts Browsh\nfunc TTYStart(injectedScreen tcell.Screen) {\n\tscreen = injectedScreen\n\tsetupTcell()\n\twriteString(1, 0, logo, tcell.StyleDefault)\n\twriteString(\n\t\t0,\n\t\t15,\n\t\t\"Starting Browsh v\"+browshVersion+\", the modern text-based web browser.\",\n\t\ttcell.StyleDefault,\n\t)\n\tStartFirefox()\n\tslog.Info(\"Starting Browsh CLI client\")\n\tgo readStdin()\n\tstartWebSocketServer()\n}\n\nfunc toInt(char string) int {\n\ti, err := strconv.ParseInt(char, 10, 16)\n\tif err != nil {\n\t\tShutdown(err)\n\t}\n\treturn int(i)\n}\n\nfunc toInt32(char string) int32 {\n\ti, err := strconv.ParseInt(char, 10, 32)\n\tif err != nil {\n\t\tShutdown(err)\n\t}\n\treturn int32(i)\n}\n\nfunc ttyEntry() {\n\t// Hack to force true colours\n\t// Follow: https://github.com/gdamore/tcell/pull/183\n\tif runtime.GOOS != \"windows\" {\n\t\t// On windows this generates a \"character set not supported\" error. The error comes\n\t\t// from tcell.\n\t\tos.Setenv(\"TERM\", \"xterm-truecolor\")\n\t}\n\trealScreen, err := tcell.NewScreen()\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"%v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\tTTYStart(realScreen)\n}\n\n// MainEntry decides between running Browsh as a CLI app or as an HTTP web server\nfunc MainEntry() {\n\tpflag.Parse()\n\t// validURL contains array of valid user inputted links.\n\tvar validURL []string\n\tif pflag.NArg() != 0 {\n\t\tfor i := 0; i < len(pflag.Args()); i++ {\n\t\t\tu, _ := url.ParseRequestURI(pflag.Args()[i])\n\t\t\tif u != nil {\n\t\t\t\tvalidURL = append(validURL, pflag.Args()[i])\n\t\t\t}\n\t\t}\n\t}\n\tviper.SetDefault(\"validURL\", validURL)\n\tInitialise()\n\n\t// Print version if asked and exit\n\tif viper.GetBool(\"version\") || viper.GetBool(\"v\") {\n\t\tprintln(browshVersion)\n\t\tos.Exit(0)\n\t}\n\n\t// Print name if asked and exit\n\tif viper.GetBool(\"name\") || viper.GetBool(\"n\") {\n\t\tprintln(\"Browsh\")\n\t\tos.Exit(0)\n\t}\n\n\t// Decide whether to run in http-server-mode or CLI app\n\tif viper.GetBool(\"http-server-mode\") {\n\t\tHTTPServerStart()\n\t} else {\n\t\tttyEntry()\n\t}\n}\n"
  },
  {
    "path": "interfacer/src/browsh/cells.go",
    "content": "package browsh\n\nimport (\n\t\"sync\"\n\n\t\"github.com/gdamore/tcell\"\n)\n\n// A cell represents an individual TTY cell. An entire representation of the browser\n// DOM is stored in a local in-memory \"frame\". The TTY can then quickly render a region\n// of this frame for fast scrolling.\ntype cell struct {\n\tcharacter []rune\n\tfgColour  tcell.Color\n\tbgColour  tcell.Color\n}\n\n// Both updating a frame and scrolling a frame can happen at the same time, so we need\n// to use mutexes.\ntype threadSafeCellsMap struct {\n\tsync.RWMutex\n\tinternal map[int]cell\n}\n\nfunc newCellsMap() *threadSafeCellsMap {\n\treturn &threadSafeCellsMap{\n\t\tinternal: make(map[int]cell),\n\t}\n}\n\nfunc (m *threadSafeCellsMap) load(key int) (value cell, ok bool) {\n\tm.RLock()\n\tresult, ok := m.internal[key]\n\tm.RUnlock()\n\treturn result, ok\n}\n\nfunc (m *threadSafeCellsMap) store(key int, value cell) {\n\tm.Lock()\n\tm.internal[key] = value\n\tm.Unlock()\n}\n"
  },
  {
    "path": "interfacer/src/browsh/comms.go",
    "content": "package browsh\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/go-errors/errors\"\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/spf13/viper\"\n)\n\nvar (\n\tupgrader = websocket.Upgrader{\n\t\tCheckOrigin:     func(r *http.Request) bool { return true },\n\t\tReadBufferSize:  1024,\n\t\tWriteBufferSize: 1024,\n\t}\n\tstdinChannel              = make(chan string)\n\tIsConnectedToWebExtension = false\n)\n\ntype incomingRawText struct {\n\tRequestID string `json:\"request_id\"`\n\tRawJSON   string `json:\"json\"`\n}\n\nfunc startWebSocketServer() {\n\tserverMux := http.NewServeMux()\n\tserverMux.HandleFunc(\"/\", webSocketServer)\n\tport := viper.GetString(\"browsh.websocket-port\")\n\tslog.Info(\"Starting websocket server...\")\n\tif netErr := http.ListenAndServe(\":\"+port, serverMux); netErr != nil {\n\t\tShutdown(fmt.Errorf(\"Error starting websocket server: %w\", netErr))\n\t}\n}\n\nfunc sendMessageToWebExtension(message string) {\n\tif !IsConnectedToWebExtension {\n\t\tslog.Info(\"Webextension not connected. Message not sent\", \"message\", message)\n\t\treturn\n\t}\n\tstdinChannel <- message\n}\n\n// Listen to all messages coming from the webextension\n// TODO: It seems this *also* receives sent to the webextention!?\nfunc webSocketReader(ws *websocket.Conn) {\n\tdefer ws.Close()\n\tfor {\n\t\t_, message, err := ws.ReadMessage()\n\t\thandleWebextensionCommand(message)\n\t\tif err != nil {\n\t\t\tif websocket.IsCloseError(err, websocket.CloseGoingAway) {\n\t\t\t\tslog.Info(\"Socket reader detected that the browser closed the websocket\")\n\t\t\t\ttriggerSocketWriterClose()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) {\n\t\t\t\tslog.Error(\"Socket reader detected that the connection unexpectedly dissapeared\")\n\t\t\t\ttriggerSocketWriterClose()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tShutdown(err)\n\t\t}\n\t}\n}\n\nfunc handleWebextensionCommand(message []byte) {\n\tparts := strings.Split(string(message), \",\")\n\tcommand := parts[0]\n\tif viper.GetBool(\"http-server-mode\") {\n\t\thandleRawFrameTextCommands(parts)\n\t\treturn\n\t}\n\tswitch command {\n\tcase \"/frame_text\":\n\t\tparseJSONFrameText(strings.Join(parts[1:], \",\"))\n\t\trenderCurrentTabWindow()\n\tcase \"/frame_pixels\":\n\t\tparseJSONFramePixels(strings.Join(parts[1:], \",\"))\n\t\trenderCurrentTabWindow()\n\tcase \"/tab_state\":\n\t\tparseJSONTabState(strings.Join(parts[1:], \",\"))\n\t\tif CurrentTab != nil {\n\t\t\trenderUI()\n\t\t}\n\tcase \"/screenshot\":\n\t\tsaveScreenshot(parts[1])\n\tdefault:\n\t\tslog.Info(\"WEBEXT\", \"message\", string(message))\n\t}\n}\n\nfunc handleRawFrameTextCommands(parts []string) {\n\tvar incoming incomingRawText\n\tcommand := parts[0]\n\tif command == \"/raw_text\" {\n\t\tjsonBytes := []byte(strings.Join(parts[1:], \",\"))\n\t\tif err := json.Unmarshal(jsonBytes, &incoming); err != nil {\n\t\t\tShutdown(err)\n\t\t}\n\t\tif incoming.RequestID != \"\" {\n\t\t\tslog.Info(\"Raw text for\", \"RequestID\", incoming.RequestID)\n\t\t\trawTextRequests.store(incoming.RequestID, incoming.RawJSON)\n\t\t} else {\n\t\t\tslog.Info(\"Raw text but no associated request ID\")\n\t\t}\n\t} else {\n\t\tslog.Info(\"WEBEXT\", \"command\", strings.Join(parts[0:], \",\"))\n\t}\n}\n\n// When the socket reader attempts to read from a closed websocket it quickly and\n// simply closes its associated Go routine. However the socket writer won't\n// automatically notice until it actually needs to send something. So we force that\n// by sending this NOOP text.\n// TODO: There's a potential race condition because new connections share the same\n//\n//\tGo channel. So we need to setup a new channel for every connection.\nfunc triggerSocketWriterClose() {\n\tstdinChannel <- \"BROWSH CLIENT FORCING CLOSE OF WEBSOCKET WRITER\"\n}\n\n// Send a message to the webextension\nfunc webSocketWriter(ws *websocket.Conn) {\n\tvar message string\n\tdefer ws.Close()\n\tfor {\n\t\tmessage = <-stdinChannel\n\t\tslog.Info(\"TTY sending\", \"message\", message)\n\t\tif err := ws.WriteMessage(websocket.TextMessage, []byte(message)); err != nil {\n\t\t\tif errors.Is(err, websocket.ErrCloseSent) {\n\t\t\t\tslog.Info(\"Socket writer detected that the browser closed the websocket\")\n\t\t\t} else {\n\t\t\t\tslog.Error(\"Socket writer detected unexpected closure of websocket\", \"error\", err)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc webSocketServer(w http.ResponseWriter, r *http.Request) {\n\tslog.Info(\"Incoming web request from browser\")\n\tws, err := upgrader.Upgrade(w, r, nil)\n\tif err != nil {\n\t\tShutdown(err)\n\t}\n\tIsConnectedToWebExtension = true\n\tgo webSocketWriter(ws)\n\tgo webSocketReader(ws)\n\tsendConfigToWebExtension()\n\tsetDefaultFirefoxPreferences()\n\tif !viper.GetBool(\"http-server-mode\") {\n\t\tsendTtySize()\n\t}\n\t// For some reason, using Firefox's CLI arg `--url https://google.com` doesn't consistently\n\t// work. So we do it here instead.\n\tvalidURL := viper.GetStringSlice(\"validURL\")\n\tif len(validURL) == 0 {\n\t\tif !IsHTTPServerMode {\n\t\t\tsendMessageToWebExtension(\"/new_tab,\" + viper.GetString(\"startup-url\"))\n\t\t}\n\t} else {\n\t\tfor i := 0; i < len(validURL); i++ {\n\t\t\tsendMessageToWebExtension(\"/new_tab,\" + validURL[i])\n\t\t}\n\t}\n}\n\nfunc sendConfigToWebExtension() {\n\tconfigJSON, _ := json.Marshal(viper.AllSettings())\n\tsendMessageToWebExtension(\"/config,\" + string(configJSON))\n}\n"
  },
  {
    "path": "interfacer/src/browsh/config.go",
    "content": "package browsh\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/shibukawa/configdir\"\n\t\"github.com/spf13/pflag\"\n\t\"github.com/spf13/viper\"\n)\n\nvar (\n\tconfigFilename = \"config.toml\"\n\n\tisDebug   = pflag.Bool(\"debug\", false, \"slog.Info to ./debug.log\")\n\ttimeLimit = pflag.Int(\"time-limit\", 0, \"Kill Browsh after the specified number of seconds\")\n\t_         = pflag.Bool(\"http-server-mode\", false, \"Run as an HTTP service\")\n\n\t_ = pflag.String(\"startup-url\", \"https://www.brow.sh\", \"URL to launch at startup\")\n\t_ = pflag.String(\"firefox.path\", \"firefox\", \"Path to Firefox executable\")\n\t_ = pflag.Bool(\"firefox.with-gui\", false, \"Don't use headless Firefox\")\n\t_ = pflag.Bool(\"firefox.use-existing\", false, \"Whether Browsh should launch Firefox or not\")\n\t_ = pflag.Bool(\"monochrome\", false, \"Start browsh in monochrome mode\")\n\t_ = pflag.Bool(\"name\", false, \"Print out the name: Browsh\")\n)\n\nfunc getConfigNamespace() string {\n\tif IsTesting {\n\t\treturn \"browsh-testing\"\n\t}\n\treturn \"browsh\"\n}\n\n// Gets a cross-platform path to a folder containing Browsh config\nfunc getConfigDir() string {\n\tmarker := \"browsh-settings\"\n\t// configdir has no other option but to have a nested folder\n\tconfigDirs := configdir.New(getConfigNamespace(), marker)\n\tfolders := configDirs.QueryFolders(configdir.Global)\n\t// Delete the previously enforced nested folder\n\tpath := strings.Trim(folders[0].Path, marker)\n\tos.MkdirAll(path, os.ModePerm)\n\tensureConfigFile(path)\n\treturn path\n}\n\n// Copy the sample config file if the user doesn't already have a config file\nfunc ensureConfigFile(path string) {\n\tfullPath := filepath.Join(path, configFilename)\n\tif _, err := os.Stat(fullPath); os.IsNotExist(err) {\n\t\tfile, err := os.Create(fullPath)\n\t\tif err != nil {\n\t\t\tShutdown(err)\n\t\t}\n\t\tdefer file.Close()\n\t\t_, err = file.WriteString(configSample)\n\t\tif err != nil {\n\t\t\tShutdown(err)\n\t\t}\n\t}\n}\n\n// Gets a cross-platform path to store a Browsh-specific Firefox profile\nfunc getFirefoxProfilePath() string {\n\tconfigDirs := configdir.New(getConfigNamespace(), \"firefox_profile\")\n\tfolders := configDirs.QueryFolders(configdir.Global)\n\tfolders[0].MkdirAll()\n\treturn folders[0].Path\n}\n\nfunc setDefaults() {\n\t// Temporary experimental configurable keybindings\n\tviper.SetDefault(\"tty.keys.next-tab\", []string{\"\\u001c\", \"28\", \"2\"})\n}\n\nfunc loadConfig() {\n\tdir := getConfigDir()\n\tfullPath := filepath.Join(dir, configFilename)\n\tslog.Info(\"Looking in \" + fullPath + \" for config.\")\n\tviper.SetConfigType(\"toml\")\n\tviper.SetConfigName(strings.Trim(configFilename, \".toml\"))\n\tviper.AddConfigPath(dir)\n\tviper.AddConfigPath(\".\")\n\tsetDefaults()\n\t// First load the sample config in case the user hasn't updated any new fields\n\tif err := viper.ReadConfig(bytes.NewBuffer([]byte(configSample))); err != nil {\n\t\tpanic(fmt.Errorf(\"Config file error: %s \\n\", err))\n\t}\n\t// Then load the users own config file, overwriting the sample config\n\tif err := viper.MergeInConfig(); err != nil {\n\t\tpanic(fmt.Errorf(\"Config file error: %s \\n\", err))\n\t}\n\tviper.BindPFlags(pflag.CommandLine)\n}\n"
  },
  {
    "path": "interfacer/src/browsh/config_sample.go",
    "content": "package browsh\n\nvar configSample = `\n# See; https://www.brow.sh/donate/\n# By showing your support you can disable the app's branding and nags to donate.\nbrowsh_supporter = \"♥\"\n\n# The page to show at startup. Browsh will fail to boot if this URL is not accessible\nstartup-url = \"http://www.brow.sh\"\n\n# The base query when a non-URL is entered into the URL bar\ndefault_search_engine_base = \"https://www.google.com/search?q=\"\n\n# The mobile user agent for forcing web pages to use their mobile layout\nmobile_user_agent = \"Mozilla/5.0 (Android 7.0; Mobile; rv:54.0) Gecko/58.0 Firefox/58.0\"\n\n[browsh] # Browsh internals\nwebsocket-port = 3334\n\n# Possibly better handling of overlapping text in web pages. If a page seems to have\n# text that shouldn't be visible, if it should be behind another element for example,\n# then this experimental feature should help. It can also be toggled in-browser with F6.\nuse_experimental_text_visibility = false\n\n# Custom CSS to apply to all loaded tabs, eg;\n#   custom_css = \"\"\"\n#   body {\n#     background-colour: black;\n#   }\n#   \"\"\"\ncustom_css = \"\"\n\n[firefox]\n# The path to your Firefox binary\npath = \"firefox\"\n# Browsh has its own profile, seperate from the normal user's. But you can change that.\nprofile = \"browsh-default\"\n# Don't let Browsh launch Firefox, but make it try to connect to an existing one. Note\n# it will need to have been launched with the '--marionette' flag.\nuse-existing = false\n# Launch Firefox in with its visible GUI window. Useful for setting up the Browsh profile.\nwith-gui = false\n\n# Config that you might usually set through Firefox's 'about:config' page\n# Note that string must be wrapped in quotes\n# preferences = [\n#   \"privacy.resistFingerprinting=true\",\n#   \"network.proxy.http='localhost'\",\n#   \"network.proxy.ssl='localhost'\",\n#   \"network.proxy.http_port=8118\",\n#   \"network.proxy.ssl_port=8118\",\n#   \"network.proxy.type=1\"\n# ]\n\n[tty]\n# The time in milliseconds between requesting a new TTY-sized pixel frame.\n# This is essentially the frame rate for graphics. Lower values make for smoother\n# animations and feedback, but also increases the CPU load.\nsmall_pixel_frame_rate = 250\n\n[http-server]\nport = 4333\nbind = \"0.0.0.0\"\n\n# The time to wait in milliseconds after the DOM is ready before\n# trying to parse and render the page's text. Too soon and text risks not being\n# parsed, too long and you wait unecessarily.\nrender_delay = 100\n\n# The length of time in seconds to wait before aborting the page load\ntimeout = 30\n\n# The dimensions of a char-based window onto a webpage.\n# The columns are ultimately the width of the final text. Whereas the rows\n# represent the height of the original web page made visible to the original\n# browser window. So the number of rows can effect things like how far down a\n# web page images are lazy-loaded.\ncolumns = 100\nrows = 30\n\n# The amount of lossy JPG compression to apply to the background image of HTML\n# pages.\njpeg_compression = 0.9\n\n# Rate limit. For syntax, see: https://github.com/ulule/limiter\nrate-limit = \"100000000-M\"\n\n# Blocking is useful if the HTTP server is made public. All values are evaluated as\n# regular expressions.\nblocked-domains = [\n]\n\nblocked-user-agents = [\n]\n\n# HTML snippets to show at top and bottom of final page.\nheader = \"\"\nfooter = \"\"\n`\n"
  },
  {
    "path": "interfacer/src/browsh/firefox.go",
    "content": "package browsh\n\nimport (\n\t\"bufio\"\n\t\"embed\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"log/slog\"\n\t\"net\"\n\t\"os\"\n\t\"os/exec\"\n\t\"os/signal\"\n\t\"path\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/gdamore/tcell\"\n\t\"github.com/go-errors/errors\"\n\t\"github.com/spf13/viper\"\n)\n\n//go:embed browsh.xpi\nvar browshXpi embed.FS\n\nvar (\n\tmarionette     net.Conn\n\tffCommandCount = 0\n\tdefaultFFPrefs = map[string]string{\n\t\t\"startup.homepage_welcome_url.additional\": \"''\",\n\t\t\"devtools.errorconsole.enabled\":           \"true\",\n\t\t\"devtools.chrome.enabled\":                 \"true\",\n\n\t\t// Send Browser Console (different from Devtools console) output to\n\t\t// STDOUT.\n\t\t\"browser.dom.window.dump.enabled\": \"true\",\n\n\t\t// From:\n\t\t// http://hg.mozilla.org/mozilla-central/file/1dd81c324ac7/build/automation.py.in//l388\n\t\t// Make url-classifier updates so rare that they won\"t affect tests.\n\t\t\"urlclassifier.updateinterval\": \"172800\",\n\t\t// Point the url-classifier to a nonexistent local URL for fast failures.\n\t\t\"browser.safebrowsing.provider.0.gethashURL\": \"'http://localhost/safebrowsing-dummy/gethash'\",\n\t\t\"browser.safebrowsing.provider.0.keyURL\":     \"'http://localhost/safebrowsing-dummy/newkey'\",\n\t\t\"browser.safebrowsing.provider.0.updateURL\":  \"'http://localhost/safebrowsing-dummy/update'\",\n\n\t\t// Disable self repair/SHIELD\n\t\t\"browser.selfsupport.url\": \"'https://localhost/selfrepair'\",\n\t\t// Disable Reader Mode UI tour\n\t\t\"browser.reader.detectedFirstArticle\": \"true\",\n\n\t\t// Set the policy firstURL to an empty string to prevent\n\t\t// the privacy info page to be opened on every \"web-ext run\".\n\t\t// (See #1114 for rationale)\n\t\t\"datareporting.policy.firstRunURL\": \"''\",\n\t}\n)\n\nfunc startHeadlessFirefox() {\n\tslog.Info(\"Starting Firefox in headless mode\")\n\tcheckIfFirefoxIsAlreadyRunning()\n\tfirefoxPath := ensureFirefoxBinary()\n\tensureFirefoxVersion(firefoxPath)\n\targs := []string{\"--marionette\"}\n\tif !viper.GetBool(\"firefox.with-gui\") {\n\t\targs = append(args, \"--headless\")\n\t}\n\tprofile := viper.GetString(\"firefox.profile\")\n\tif profile != \"browsh-default\" {\n\t\tslog.Info(\"Using Firefox profile\", \"profile\", profile)\n\t\targs = append(args, \"-P\", profile)\n\t} else {\n\t\tprofilePath := getFirefoxProfilePath()\n\t\tslog.Info(\"Using default profile\", \"path\", profilePath)\n\t\targs = append(args, \"--profile\", profilePath)\n\t}\n\tfirefoxProcess := exec.Command(firefoxPath, args...)\n\tdefer firefoxProcess.Process.Kill()\n\tstdout, err := firefoxProcess.StdoutPipe()\n\tif err != nil {\n\t\tShutdown(err)\n\t}\n\tif err := firefoxProcess.Start(); err != nil {\n\t\tShutdown(err)\n\t}\n\tin := bufio.NewScanner(stdout)\n\tfor in.Scan() {\n\t\tslog.Info(\"FF-CONSOLE\", \"stdout\", in.Text())\n\t}\n}\n\nfunc checkIfFirefoxIsAlreadyRunning() {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn\n\t}\n\tprocesses := Shell(\"ps aux\")\n\tr, _ := regexp.Compile(\"firefox.*--headless\")\n\tif r.MatchString(processes) {\n\t\tShutdown(errors.New(\"A headless Firefox is already running\"))\n\t}\n}\n\nfunc ensureFirefoxBinary() string {\n\tpath := viper.GetString(\"firefox.path\")\n\tif path == \"firefox\" {\n\t\tswitch runtime.GOOS {\n\t\tcase \"windows\":\n\t\t\tpath = getFirefoxPath()\n\t\tcase \"darwin\":\n\t\t\tpath = \"/Applications/Firefox.app/Contents/MacOS/firefox\"\n\t\tdefault:\n\t\t\tpath = getFirefoxPath()\n\t\t}\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif errors.Is(err, fs.ErrNotExist) {\n\t\t\terr = errors.New(\"Firefox binary not found: \" + path)\n\t\t}\n\t\tShutdown(err)\n\t}\n\tslog.Info(\"Using Firefox\", \"path\", path)\n\treturn path\n}\n\n// Taken from https://stackoverflow.com/a/18411978/575773\nfunc versionOrdinal(version string) string {\n\t// ISO/IEC 14651:2011\n\tconst maxByte = 1<<8 - 1\n\tvo := make([]byte, 0, len(version)+8)\n\tj := -1\n\tfor i := 0; i < len(version); i++ {\n\t\tb := version[i]\n\t\tif '0' > b || b > '9' {\n\t\t\tvo = append(vo, b)\n\t\t\tj = -1\n\t\t\tcontinue\n\t\t}\n\t\tif j == -1 {\n\t\t\tvo = append(vo, 0x00)\n\t\t\tj = len(vo) - 1\n\t\t}\n\t\tif vo[j] == 1 && vo[j+1] == '0' {\n\t\t\tvo[j+1] = b\n\t\t\tcontinue\n\t\t}\n\t\tif vo[j]+1 > maxByte {\n\t\t\tpanic(\"VersionOrdinal: invalid version\")\n\t\t}\n\t\tvo = append(vo, b)\n\t\tvo[j]++\n\t}\n\treturn string(vo)\n}\n\n// Start Firefox via the `web-ext` CLI tool. This is for development and testing,\n// because I haven't been able to recreate the way `web-ext` injects an unsigned\n// extension.\nfunc startWERFirefox() {\n\tslog.Info(\"Attempting to start headless Firefox with `web-ext`\")\n\tif IsConnectedToWebExtension {\n\t\tShutdown(errors.New(\"There appears to already be an existing Web Extension connection\"))\n\t}\n\tcheckIfFirefoxIsAlreadyRunning()\n\trootDir := Shell(\"git rev-parse --show-toplevel\")\n\targs := []string{\n\t\t\"run\",\n\t\t\"--firefox=\" + rootDir + \"/webext/contrib/firefoxheadless.sh\",\n\t\t\"--verbose\",\n\t\t\"--no-reload\",\n\t}\n\tfirefoxProcess := exec.Command(rootDir+\"/webext/node_modules/.bin/web-ext\", args...)\n\tfirefoxProcess.Dir = rootDir + \"/webext/dist/\"\n\tstdout, err := firefoxProcess.StdoutPipe()\n\tif err != nil {\n\t\tShutdown(err)\n\t}\n\tif err := firefoxProcess.Start(); err != nil {\n\t\tShutdown(err)\n\t}\n\tin := bufio.NewScanner(stdout)\n\tfor in.Scan() {\n\t\tif strings.Contains(in.Text(), \"Connected to the remote Firefox debugger\") {\n\t\t}\n\t\tif strings.Contains(in.Text(), \"JavaScript strict\") ||\n\t\t\tstrings.Contains(in.Text(), \"D-BUS\") ||\n\t\t\tstrings.Contains(in.Text(), \"dbus\") {\n\t\t\tcontinue\n\t\t}\n\t\tslog.Info(\"FF-CONSOLE\", \"stdout\", in.Text())\n\t}\n\tslog.Info(\"WER Firefox unexpectedly closed\")\n}\n\n// Connect to Firefox's Marionette service.\n// RANT: Firefox's remote control tools are so confusing. There seem to be 2\n// services that come with your Firefox binary; Marionette and the Remote\n// Debugger. The latter you would expect to follow the widely supported\n// Chrome standard, but no, it's merely on the roadmap. There is very little\n// documentation on either. I have the impression, but I'm not sure why, that\n// the Remote Debugger is better, seemingly more API methods, and as mentioned\n// is on the roadmap to follow the Chrome standard.\n// I've used Marionette here, simply because it was easier to reverse engineer\n// from the Python Marionette package.\nfunc firefoxMarionette() {\n\tvar (\n\t\terr  error\n\t\tconn net.Conn\n\t)\n\tconnected := false\n\tslog.Info(\"Attempting to connect to Firefox Marionette\")\n\tstart := time.Now()\n\tfor time.Since(start) < 30*time.Second {\n\t\tconn, err = net.Dial(\"tcp\", \"127.0.0.1:2828\")\n\t\tif err != nil {\n\t\t\tif !strings.Contains(err.Error(), \"refused\") {\n\t\t\t\tShutdown(err)\n\t\t\t} else {\n\t\t\t\ttime.Sleep(10 * time.Millisecond)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t} else {\n\t\t\tconnected = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !connected {\n\t\tShutdown(errors.New(\"Failed to connect to Firefox's Marionette within 30 seconds\"))\n\t}\n\tmarionette = conn\n\tgo readMarionette()\n\tsendFirefoxCommand(\"WebDriver:NewSession\", map[string]interface{}{})\n}\n\nfunc installWebextension() {\n\tdata, err := browshXpi.ReadFile(\"browsh.xpi\")\n\tif err != nil {\n\t\tShutdown(err)\n\t}\n\tpath := path.Join(os.TempDir(), \"browsh-webext-addon\")\n\tif err := os.WriteFile(path, []byte(data), 0644); err != nil {\n\t\tShutdown(err)\n\t}\n\targs := map[string]interface{}{\"path\": path}\n\tsendFirefoxCommand(\"Addon:Install\", args)\n}\n\n// Set a Firefox preference as you would in `about:config`\n// `value` needs to be supplied with quotes if it's to be used as a JS string\nfunc setFFPreference(key string, value string) {\n\tvar args map[string]interface{}\n\tvar script string\n\tsendFirefoxCommand(\"Marionette:SetContext\", map[string]interface{}{\"value\": \"chrome\"})\n\tscript = fmt.Sprintf(`\n\t\tComponents.utils.import(\"resource://gre/modules/Preferences.jsm\");\n\t\tprefs = new Preferences({defaultBranch: \"root\"});\n    prefs.set(\"%s\", %s);`, key, value)\n\targs = map[string]interface{}{\"script\": script}\n\tsendFirefoxCommand(\"WebDriver:ExecuteScript\", args)\n\tsendFirefoxCommand(\"Marionette:SetContext\", map[string]interface{}{\"value\": \"content\"})\n}\n\n// Consume output from Marionette, we don't do anything with it. It\"s just\n// useful to have it in the logs.\nfunc readMarionette() {\n\tbuffer := make([]byte, 4096)\n\tcount, err := marionette.Read(buffer)\n\tif err != nil {\n\t\tslog.Error(\"Error reading from Marionette connection\", \"error\", err)\n\t\treturn\n\t}\n\tslog.Info(\"FF-MRNT\", \"buffer\", string(buffer[:count]))\n}\n\nfunc sendFirefoxCommand(command string, args map[string]interface{}) {\n\tslog.Info(\"Sending command to Firefox Marionette\", \"command\", command, \"args\", args)\n\tfullCommand := []interface{}{0, ffCommandCount, command, args}\n\tmarshalled, _ := json.Marshal(fullCommand)\n\tmessage := fmt.Sprintf(\"%d:%s\", len(marshalled), marshalled)\n\tfmt.Fprintf(marionette, \"%s\", message)\n\tffCommandCount++\n\tgo readMarionette()\n}\n\nfunc setDefaultFirefoxPreferences() {\n\tfor key, value := range defaultFFPrefs {\n\t\tsetFFPreference(key, value)\n\t}\n\tfor _, pref := range viper.GetStringSlice(\"firefox.preferences\") {\n\t\tparts := strings.SplitN(pref, \"=\", 2)\n\t\tsetFFPreference(parts[0], parts[1])\n\t}\n}\n\nfunc beginTimeLimit() {\n\twarningLength := 10\n\twarningLimit := time.Duration(*timeLimit - warningLength)\n\ttime.Sleep(warningLimit * time.Second)\n\tmessage := fmt.Sprintf(\"Browsh will close in %d seconds...\", warningLength)\n\tsendMessageToWebExtension(\"/status,\" + message)\n\ttime.Sleep(time.Duration(warningLength) * time.Second)\n\tquitBrowsh()\n}\n\n// Careful what you change here as it isn't tested during CI\nfunc setupFirefox() {\n\tgo startHeadlessFirefox()\n\tif *timeLimit > 0 {\n\t\tgo beginTimeLimit()\n\t}\n\tsigs := make(chan os.Signal, 1)\n\tsignal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)\n\tgo func() {\n\t\t<-sigs\n\t\tquitBrowsh()\n\t}()\n\n\tfirefoxMarionette()\n\tinstallWebextension()\n}\n\nfunc StartFirefox() {\n\tif !viper.GetBool(\"firefox.use-existing\") {\n\t\twriteString(0, 16, \"Waiting for Firefox to connect...\", tcell.StyleDefault)\n\t\tif IsTesting {\n\t\t\twriteString(0, 17, \"TEST MODE\", tcell.StyleDefault)\n\t\t\tgo startWERFirefox()\n\t\t\tfirefoxMarionette()\n\t\t} else {\n\t\t\tsetupFirefox()\n\t\t}\n\t} else {\n\t\tfirefoxMarionette()\n\t\twriteString(0, 16, \"Waiting for a user-initiated Firefox instance to connect...\", tcell.StyleDefault)\n\t}\n}\n\nfunc quitFirefox() {\n\tsendFirefoxCommand(\"Marionette:Quit\", map[string]interface{}{})\n}\n"
  },
  {
    "path": "interfacer/src/browsh/firefox_unix.go",
    "content": "//go:build darwin || dragonfly || freebsd || linux || nacl || netbsd || openbsd || solaris\n\npackage browsh\n\nimport (\n\t\"strings\"\n\n\t\"github.com/go-errors/errors\"\n)\n\nfunc getFirefoxPath() string {\n\treturn Shell(\"which firefox\")\n}\n\nfunc ensureFirefoxVersion(path string) {\n\toutput := Shell(path + \" --version\")\n\tpieces := strings.Split(output, \" \")\n\tversion := pieces[len(pieces)-1]\n\tif versionOrdinal(version) < versionOrdinal(\"57\") {\n\t\tmessage := \"Installed Firefox version \" + version + \" is too old. \" +\n\t\t\t\"Firefox 57 or newer is needed.\"\n\t\tShutdown(errors.New(message))\n\t}\n}\n"
  },
  {
    "path": "interfacer/src/browsh/firefox_windows.go",
    "content": "//go:build windows\n\npackage browsh\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"strings\"\n\n\t\"github.com/go-errors/errors\"\n\t\"golang.org/x/sys/windows/registry\"\n)\n\nfunc getFirefoxPath() string {\n\tversionString := getWindowsFirefoxVersionString()\n\tflavor := getFirefoxFlavor()\n\n\tk, err := registry.OpenKey(\n\t\tregistry.LOCAL_MACHINE,\n\t\t`Software\\Mozilla\\`+flavor+`\\`+versionString+`\\Main`,\n\t\tregistry.QUERY_VALUE)\n\tif err != nil {\n\t\tShutdown(fmt.Errorf(\"Error reading Windows registry: %w\", err))\n\t}\n\tdefer k.Close()\n\n\tpath, _, err := k.GetStringValue(\"PathToExe\")\n\tif err != nil {\n\t\tShutdown(fmt.Errorf(\"Error reading Windows registry: %w\", err))\n\t}\n\n\treturn path\n}\n\nfunc getWindowsFirefoxVersionString() string {\n\tflavor := getFirefoxFlavor()\n\n\tk, err := registry.OpenKey(\n\t\tregistry.LOCAL_MACHINE,\n\t\t`Software\\Mozilla\\`+flavor,\n\t\tregistry.QUERY_VALUE)\n\tif err != nil {\n\t\tShutdown(fmt.Errorf(\"Error reading Windows registry: %w\", err))\n\t}\n\tdefer k.Close()\n\n\tversionString, _, err := k.GetStringValue(\"CurrentVersion\")\n\tif err != nil {\n\t\tShutdown(fmt.Errorf(\"Error reading Windows registry: %w\", err))\n\t}\n\n\tslog.Info(\"Windows registry Firefox\", \"version\", versionString)\n\n\treturn versionString\n}\n\nfunc getFirefoxFlavor() string {\n\tflavor := \"null\"\n\tk, err := registry.OpenKey(\n\t\tregistry.LOCAL_MACHINE,\n\t\t`Software\\Mozilla\\Mozilla Firefox`,\n\t\tregistry.QUERY_VALUE)\n\n\tif err == nil {\n\t\tflavor = \"Mozilla Firefox\"\n\t}\n\tdefer k.Close()\n\n\tif flavor == \"null\" {\n\t\tk, err := registry.OpenKey(\n\t\t\tregistry.LOCAL_MACHINE,\n\t\t\t`Software\\Mozilla\\Firefox Developer Edition`,\n\t\t\tregistry.QUERY_VALUE)\n\n\t\tif err == nil {\n\t\t\tflavor = \"Firefox Developer Edition\"\n\t\t}\n\t\tdefer k.Close()\n\t}\n\n\tif flavor == \"null\" {\n\t\tk, err := registry.OpenKey(\n\t\t\tregistry.LOCAL_MACHINE,\n\t\t\t`Software\\Mozilla\\Nightly`,\n\t\t\tregistry.QUERY_VALUE)\n\n\t\tif err == nil {\n\t\t\tflavor = \"Nightly\"\n\t\t}\n\t\tdefer k.Close()\n\t}\n\n\tif flavor == \"null\" {\n\t\tShutdown(errors.New(\"Could not find Firefox on your registry\"))\n\t}\n\treturn flavor\n}\n\nfunc ensureFirefoxVersion(path string) {\n\tversionString := getWindowsFirefoxVersionString()\n\tpieces := strings.Split(versionString, \" \")\n\tversion := pieces[0]\n\tif versionOrdinal(version) < versionOrdinal(\"57\") {\n\t\tmessage := \"Installed Firefox version \" + version + \" is too old. \" +\n\t\t\t\"Firefox 57 or newer is needed.\"\n\t\tShutdown(errors.New(message))\n\t}\n}\n"
  },
  {
    "path": "interfacer/src/browsh/frame_builder.go",
    "content": "package browsh\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"unicode\"\n\n\t\"github.com/gdamore/tcell\"\n)\n\n// A frame is a single snapshot of the DOM. The TTY is merely a window onto a\n// region of this frame.\ntype frame struct {\n\t// Dimensions of the frame's real data. Can be less than the DOM dimensions because\n\t// we cannot sync frames of unlimited size from the browser.\n\tsubWidth  int\n\tsubHeight int\n\t// If the frame is smaller than the DOM, then this is the frame's position\n\t// within the overall DOM.\n\tsubLeft int\n\tsubTop  int\n\t// The total DOM dimensions. These are measured in the same units of the frame\n\ttotalWidth  int\n\ttotalHeight int\n\t// The current position of the scroll in the TTY. Should be synced with the real\n\t// browser.\n\txScroll int\n\tyScroll int\n\t// Usually we want to just overlay new data. But if the DOM changes then all bets are off\n\t// and we need to start from scratch again. It's just too unpredictable how data for a DOM\n\t// of a different size and shape will interact with data from another DOM.\n\tisDOMSizeChanged bool\n\t// Raw data used to build a single, usable frame\n\tpixels      map[int][2]tcell.Color\n\ttext        map[int][]rune\n\ttextColours map[int]tcell.Color\n\t// The actual built frame, can be used to render cells to the TTY\n\tcells *threadSafeCellsMap\n\t// Input boxes, like for entering passwords, sending emails etc\n\tinputBoxes map[string]*inputBox\n}\n\ntype jsonFrameBase struct {\n\tTabID       int `json:\"id\"`\n\tSubWidth    int `json:\"sub_width\"`\n\tSubHeight   int `json:\"sub_height\"`\n\tSubLeft     int `json:\"sub_left\"`\n\tSubTop      int `json:\"sub_top\"`\n\tTotalWidth  int `json:\"total_width\"`\n\tTotalHeight int `json:\"total_height\"`\n}\n\ntype incomingFrameText struct {\n\tMeta       jsonFrameBase       `json:\"meta\"`\n\tText       []string            `json:\"text\"`\n\tColours    []int32             `json:\"colours\"`\n\tInputBoxes map[string]inputBox `json:\"input_boxes\"`\n}\n\n// TODO: Can these be sent as binary blobs?\ntype incomingFramePixels struct {\n\tMeta    jsonFrameBase `json:\"meta\"`\n\tColours []int32       `json:\"colours\"`\n}\n\nfunc (f *frame) domRowCount() int {\n\treturn f.totalHeight / 2\n}\n\nfunc (f *frame) subRowCount() int {\n\treturn f.subHeight / 2\n}\n\nfunc parseJSONFrameText(jsonString string) {\n\tvar incoming incomingFrameText\n\tjsonBytes := []byte(jsonString)\n\tif err := json.Unmarshal(jsonBytes, &incoming); err != nil {\n\t\tShutdown(err)\n\t}\n\tif !isTabPresent(incoming.Meta.TabID) {\n\t\tslog.Info(\n\t\t\tfmt.Sprintf(\"Not building frame for non-existent tab ID: %d\", incoming.Meta.TabID),\n\t\t)\n\t\treturn\n\t}\n\tTabs[incoming.Meta.TabID].frame.buildFrameText(incoming)\n}\n\nfunc (f *frame) buildFrameText(incoming incomingFrameText) {\n\tf.setup(incoming.Meta)\n\tif !f.isIncomingFrameTextValid(incoming) {\n\t\treturn\n\t}\n\tf.updateInputBoxes(incoming)\n\tf.populateFrameText(incoming)\n}\n\nfunc parseJSONFramePixels(jsonString string) {\n\tvar incoming incomingFramePixels\n\tjsonBytes := []byte(jsonString)\n\tif err := json.Unmarshal(jsonBytes, &incoming); err != nil {\n\t\tShutdown(err)\n\t}\n\tif !isTabPresent(incoming.Meta.TabID) {\n\t\tslog.Warn(\"Not building frame for non-existent tab ID\", \"TabID\", incoming.Meta.TabID)\n\t\treturn\n\t}\n\tif len(Tabs[incoming.Meta.TabID].frame.text) == 0 {\n\t\treturn\n\t}\n\tTabs[incoming.Meta.TabID].frame.buildFramePixels(incoming)\n}\n\nfunc (f *frame) buildFramePixels(incoming incomingFramePixels) {\n\tf.setup(incoming.Meta)\n\tif !f.isIncomingFramePixelsValid(incoming) {\n\t\treturn\n\t}\n\tf.populateFramePixels(incoming)\n}\n\nfunc (f *frame) setup(meta jsonFrameBase) {\n\tf.isDOMSizeChanged = meta.TotalWidth != f.totalWidth || meta.TotalHeight != f.totalHeight\n\tif f.isDOMSizeChanged || f.cells == nil {\n\t\tf.resetCells()\n\t}\n\tif f.inputBoxes == nil {\n\t\tf.inputBoxes = make(map[string]*inputBox)\n\t}\n\tf.subWidth = meta.SubWidth\n\tf.subHeight = meta.SubHeight\n\tf.totalWidth = meta.TotalWidth\n\tf.totalHeight = meta.TotalHeight\n\tf.subLeft = meta.SubLeft\n\tf.subTop = meta.SubTop\n}\n\nfunc (f *frame) resetCells() {\n\tf.cells = newCellsMap()\n}\n\nfunc (f *frame) isIncomingFrameTextValid(incoming incomingFrameText) bool {\n\tif len(incoming.Text) == 0 {\n\t\tslog.Warn(\"Not parsing zero-size text frame\")\n\t\treturn false\n\t}\n\treturn true\n}\n\n// TODO: There must be a more idiomatic way of doing this?\nfunc (f *frame) updateInputBoxes(incoming incomingFrameText) {\n\tfor _, existingInputBox := range f.inputBoxes {\n\t\tif _, ok := incoming.InputBoxes[existingInputBox.ID]; !ok {\n\t\t\t// TODO: Does this also delete the memory pointed to by the reference?\n\t\t\tdelete(f.inputBoxes, existingInputBox.ID)\n\t\t}\n\t}\n\tfor _, incomingInputBox := range incoming.InputBoxes {\n\t\tif _, ok := f.inputBoxes[incomingInputBox.ID]; !ok {\n\t\t\tf.inputBoxes[incomingInputBox.ID] = newInputBox(incomingInputBox.ID)\n\t\t}\n\t\tinputBox := f.inputBoxes[incomingInputBox.ID]\n\t\tinputBox.X = incomingInputBox.X\n\t\t// TODO: Why do we have to add the 1 to the y coord??\n\t\tinputBox.Y = (incomingInputBox.Y + 1) / 2\n\t\tinputBox.Width = incomingInputBox.Width\n\t\tinputBox.Height = incomingInputBox.Height / 2\n\t\tinputBox.FgColour = incomingInputBox.FgColour\n\t\tinputBox.TagName = incomingInputBox.TagName\n\t\tinputBox.Type = incomingInputBox.Type\n\t}\n}\n\nfunc (f *frame) populateFrameText(incoming incomingFrameText) {\n\tvar cellIndex, frameIndex, colourIndex int\n\tif f.isDOMSizeChanged || f.text == nil {\n\t\tf.text = make(map[int][]rune, (f.domRowCount())*f.totalWidth)\n\t\tf.textColours = make(map[int]tcell.Color, (f.domRowCount())*f.totalWidth)\n\t}\n\tfor y := 0; y < f.subRowCount(); y++ {\n\t\tfor x := 0; x < f.subWidth; x++ {\n\t\t\tcellIndex = f.getCellIndexFromSubCoords(x, y*2)\n\t\t\tframeIndex = (y * f.subWidth) + x\n\t\t\tcolourIndex = frameIndex * 3\n\t\t\tf.textColours[cellIndex] = tcell.NewRGBColor(\n\t\t\t\tincoming.Colours[colourIndex+0],\n\t\t\t\tincoming.Colours[colourIndex+1],\n\t\t\t\tincoming.Colours[colourIndex+2],\n\t\t\t)\n\t\t\tf.text[cellIndex] = []rune(incoming.Text[frameIndex])\n\t\t\tf.buildCell(f.subLeft+x, (f.subTop/2)+y)\n\t\t}\n\t}\n}\n\nfunc (f *frame) populateFramePixels(incoming incomingFramePixels) {\n\tvar cellIndex, frameIndexFg, frameIndexBg, pixelIndexFg, pixelIndexBg int\n\tif f.isDOMSizeChanged || f.pixels == nil {\n\t\tf.pixels = make(map[int][2]tcell.Color, f.totalHeight*f.totalWidth)\n\t}\n\tdata := incoming.Colours\n\tfor y := 0; y < f.subHeight; y += 2 {\n\t\tfor x := 0; x < f.subWidth; x++ {\n\t\t\tcellIndex = f.getCellIndexFromSubCoords(x, y)\n\t\t\tframeIndexBg = (y * f.subWidth) + x\n\t\t\tframeIndexFg = ((y + 1) * f.subWidth) + x\n\t\t\tpixelIndexBg = frameIndexBg * 3\n\t\t\tpixelIndexFg = frameIndexFg * 3\n\t\t\tpixels := [2]tcell.Color{\n\t\t\t\ttcell.NewRGBColor(\n\t\t\t\t\tdata[pixelIndexBg+0],\n\t\t\t\t\tdata[pixelIndexBg+1],\n\t\t\t\t\tdata[pixelIndexBg+2],\n\t\t\t\t),\n\t\t\t\ttcell.NewRGBColor(\n\t\t\t\t\tdata[pixelIndexFg+0],\n\t\t\t\t\tdata[pixelIndexFg+1],\n\t\t\t\t\tdata[pixelIndexFg+2],\n\t\t\t\t),\n\t\t\t}\n\t\t\tf.pixels[cellIndex] = pixels\n\t\t\tf.buildCell(f.subLeft+x, (f.subTop+y)/2)\n\t\t}\n\t}\n}\n\nfunc (f *frame) isIncomingFramePixelsValid(incoming incomingFramePixels) bool {\n\tif len(incoming.Colours) == 0 {\n\t\tslog.Warn(\"Not parsing zero-size text frame\")\n\t\treturn false\n\t}\n\treturn true\n}\n\n// This is where we implement the UTF8 half-block trick.\n// This a half-block: \"▄\", notice how it takes up precisely half a text cell. This\n// means that we can get 2 pixel colours from it, the top pixel comes from setting\n// the background colour and the bottom pixel comes from setting the foreground\n// colour, namely the colour of the text.\nfunc (f *frame) buildCell(x int, y int) {\n\tindex := (y * f.totalWidth) + x\n\tcharacter, fgColour := f.getCharacterAt(index)\n\tpixelFg, bgColour := f.getPixelColoursAt(index)\n\tif isCharacterTransparent(character) {\n\t\tcharacter = []rune(\"▄\")\n\t\tfgColour = pixelFg\n\t}\n\tf.addCell(index, fgColour, bgColour, character)\n}\n\nfunc (f *frame) getCharacterAt(index int) ([]rune, tcell.Color) {\n\tvar colour tcell.Color\n\tvar character []rune\n\tif result, ok := f.text[index]; ok {\n\t\tcharacter = result\n\t\tcolour = f.textColours[index]\n\t} else {\n\t\tcharacter = []rune(\" \")\n\t\tcolour = tcell.ColorBlack\n\t}\n\treturn character, colour\n}\n\nfunc (f *frame) getPixelColoursAt(index int) (tcell.Color, tcell.Color) {\n\tvar fgColour, bgColour tcell.Color\n\tif result, ok := f.pixels[index]; ok {\n\t\tbgColour = result[0]\n\t\tfgColour = result[1]\n\t} else {\n\t\tx := index % f.subWidth\n\t\tfgColour, bgColour = getHatchedCellColours(x)\n\t}\n\treturn fgColour, bgColour\n}\n\nfunc isCharacterTransparent(character []rune) bool {\n\treturn string(character) == \"\" || unicode.IsSpace(character[0])\n}\n\nfunc (f *frame) addCell(index int, fgColour, bgColour tcell.Color, character []rune) {\n\tnewCell := cell{\n\t\tfgColour:  fgColour,\n\t\tbgColour:  bgColour,\n\t\tcharacter: character,\n\t}\n\tf.cells.store(index, newCell)\n}\n\n// When iterating over a sub frame we still need to place the resulting data into the\n// overall frame grid. So here we're essentially mapping relative coordinates to\n// absolute ones. Also note that the y coord is converted from the frame pixels value\n// to the TTY row value.\nfunc (f *frame) getCellIndexFromSubCoords(x, y int) int {\n\tyInAbsoluteFrameTTY := (y + f.subTop) / 2\n\treturn (yInAbsoluteFrameTTY * f.totalWidth) + (x + f.subLeft)\n}\n\nfunc (f *frame) limitScroll(height int) {\n\tmaxYScroll := f.domRowCount() - height\n\tif f.yScroll > maxYScroll {\n\t\tf.yScroll = maxYScroll\n\t}\n\tif f.yScroll < 0 {\n\t\tf.yScroll = 0\n\t}\n}\n\nfunc (f *frame) maybeFocusInputBox(x, y int) {\n\tactiveInputBox = nil\n\tfor _, inputBox := range f.inputBoxes {\n\t\tinputBox.isActive = false\n\t\ttop := inputBox.Y\n\t\tbottom := inputBox.Y + inputBox.Height\n\t\tleft := inputBox.X\n\t\tright := inputBox.X + inputBox.Width\n\t\tif x >= left && x < right && y >= top && y < bottom {\n\t\t\turlBarFocus(false)\n\t\t\tinputBox.isActive = true\n\t\t\tactiveInputBox = inputBox\n\t\t}\n\t}\n}\n\nfunc (f *frame) overlayInputBoxContent() {\n\tfor _, inputBox := range f.inputBoxes {\n\t\tinputBox.setCells()\n\t}\n}\n"
  },
  {
    "path": "interfacer/src/browsh/frame_builder_test.go",
    "content": "package browsh\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo\"\n\t. \"github.com/onsi/gomega\"\n)\n\nfunc TestFrameBuilder(t *testing.T) {\n\tRegisterFailHandler(Fail)\n}\n\nvar testFrame *frame\n\nfunc testGetCell(index int) cell {\n\tresult, _ := Tabs[1].frame.cells.load(index)\n\treturn result\n}\n\nfunc testGetCellChar(index int) string {\n\tresult, _ := Tabs[1].frame.cells.load(index)\n\treturn string(result.character[0])\n}\n\nfunc debugCells() {\n\tfmt.Printf(\"\\n\")\n\tfor i := 0; i < 20; i++ {\n\t\tif result, ok := Tabs[1].frame.cells.load(i); ok {\n\t\t\tfmt.Printf(\"%d:%s \", i, string(result.character[0]))\n\t\t}\n\t}\n}\n\nvar _ = Describe(\"Frame struct\", func() {\n\tBeforeEach(func() {\n\t\tnewTab(1)\n\t})\n\n\tDescribe(\"No Offset\", func() {\n\t\tvar frameJSONText = `{\n\t\t\t\"meta\": {\n\t\t\t\t\"id\": 1,\n\t\t\t\t\"sub_left\": 0,\n\t\t\t\t\"sub_top\": 0,\n\t\t\t\t\"sub_width\": 2,\n\t\t\t\t\"sub_height\": 4,\n\t\t\t\t\"total_width\": 2,\n\t\t\t\t\"total_height\": 8\n\t\t\t},\n\t\t\t\"text\": [\"A\", \"b\", \"c\", \"\"],\n\t\t\t\"colours\": [\n\t\t\t\t77, 77, 77,\n\t\t\t\t101, 101, 101,\n\t\t\t\t102, 102, 102,\n\t\t\t\t103, 103, 103\n\t\t\t]\n\t\t}`\n\n\t\tvar frameJSONPixels = `{\n\t\t\t\"meta\": {\n\t\t\t\t\"id\": 1,\n\t\t\t\t\"sub_left\": 0,\n\t\t\t\t\"sub_top\": 0,\n\t\t\t\t\"sub_width\": 2,\n\t\t\t\t\"sub_height\": 4,\n\t\t\t\t\"total_width\": 2,\n\t\t\t\t\"total_height\": 8\n\t\t\t},\n\t\t\t\"colours\": [\n\t\t\t\t254, 254, 254, 111, 111, 111,\n\t\t\t\t1, 1, 1, 2, 2, 2,\n\t\t\t\t3, 3, 3, 4, 4, 4,\n\t\t\t\t123, 123, 123, 200, 200, 200\n\t\t\t]\n\t\t}`\n\n\t\tBeforeEach(func() {\n\t\t\tparseJSONFrameText(frameJSONText)\n\t\t})\n\n\t\tIt(\"should parse JSON frame text\", func() {\n\t\t\tExpect(testGetCell(0).character[0]).To(Equal('A'))\n\t\t\tExpect(testGetCell(1).character[0]).To(Equal('b'))\n\t\t\tExpect(testGetCell(2).character[0]).To(Equal('c'))\n\t\t\tExpect(testGetCell(3).character[0]).To(Equal('▄'))\n\t\t})\n\n\t\tIt(\"should parse JSON pixels (for text-less cells)\", func() {\n\t\t\tvar r, g, b int32\n\t\t\tparseJSONFramePixels(frameJSONPixels)\n\t\t\tr, g, b = testGetCell(3).fgColour.RGB()\n\t\t\tExpect([3]int32{r, g, b}).To(Equal([3]int32{200, 200, 200}))\n\t\t\tr, g, b = testGetCell(3).bgColour.RGB()\n\t\t\tExpect([3]int32{r, g, b}).To(Equal([3]int32{4, 4, 4}))\n\t\t})\n\n\t\tIt(\"should parse JSON pixels (using text for foreground)\", func() {\n\t\t\tvar r, g, b int32\n\t\t\tparseJSONFramePixels(frameJSONPixels)\n\t\t\tr, g, b = testGetCell(0).fgColour.RGB()\n\t\t\tExpect([3]int32{r, g, b}).To(Equal([3]int32{77, 77, 77}))\n\t\t\tr, g, b = testGetCell(0).bgColour.RGB()\n\t\t\tExpect([3]int32{r, g, b}).To(Equal([3]int32{254, 254, 254}))\n\t\t})\n\t})\n\n\tDescribe(\"With Offset\", func() {\n\t\tvar subFrameJSONText = `{\n\t\t\t\"meta\": {\n\t\t\t\t\"id\": 1,\n\t\t\t\t\"sub_left\": 1,\n\t\t\t\t\"sub_top\": 2,\n\t\t\t\t\"sub_width\": 2,\n\t\t\t\t\"sub_height\": 4,\n\t\t\t\t\"total_width\": 3,\n\t\t\t\t\"total_height\": 8\n\t\t\t},\n\t\t\t\"text\": [\"A\", \"b\", \"c\", \"\"],\n\t\t\t\"colours\": [\n\t\t\t\t77, 77, 77,\n\t\t\t\t101, 101, 101,\n\t\t\t\t102, 102, 102,\n\t\t\t\t103, 103, 103\n\t\t\t]\n\t\t}`\n\n\t\tvar subFrameJSONPixels = `{\n\t\t\t\"meta\": {\n\t\t\t\t\"id\": 1,\n\t\t\t\t\"sub_left\": 1,\n\t\t\t\t\"sub_top\": 2,\n\t\t\t\t\"sub_width\": 2,\n\t\t\t\t\"sub_height\": 4,\n\t\t\t\t\"total_width\": 3,\n\t\t\t\t\"total_height\": 8\n\t\t\t},\n\t\t\t\"colours\": [\n\t\t\t\t254, 254, 254, 111, 111, 111,\n\t\t\t\t1, 1, 1, 2, 2, 2,\n\t\t\t\t3, 3, 3, 4, 4, 4,\n\t\t\t\t123, 123, 123, 200, 200, 200\n\t\t\t]\n\t\t}`\n\n\t\tBeforeEach(func() {\n\t\t\tparseJSONFrameText(subFrameJSONText)\n\t\t})\n\n\t\tIt(\"should parse text for an offset sub-frame\", func() {\n\t\t\tExpect(testGetCell(4).character[0]).To(Equal('A'))\n\t\t\tExpect(testGetCell(5).character[0]).To(Equal('b'))\n\t\t\tExpect(testGetCell(7).character[0]).To(Equal('c'))\n\t\t\tExpect(testGetCell(8).character[0]).To(Equal('▄'))\n\t\t})\n\n\t\tIt(\"should parse offset JSON pixels (for text-less cells)\", func() {\n\t\t\tvar r, g, b int32\n\t\t\tparseJSONFramePixels(subFrameJSONPixels)\n\t\t\tr, g, b = testGetCell(8).fgColour.RGB()\n\t\t\tExpect([3]int32{r, g, b}).To(Equal([3]int32{200, 200, 200}))\n\t\t\tr, g, b = testGetCell(8).bgColour.RGB()\n\t\t\tExpect([3]int32{r, g, b}).To(Equal([3]int32{4, 4, 4}))\n\t\t})\n\n\t\tIt(\"should parse offset JSON pixels (using text for foreground)\", func() {\n\t\t\tvar r, g, b int32\n\t\t\tparseJSONFramePixels(subFrameJSONPixels)\n\t\t\tr, g, b = testGetCell(4).fgColour.RGB()\n\t\t\tExpect([3]int32{r, g, b}).To(Equal([3]int32{77, 77, 77}))\n\t\t\tr, g, b = testGetCell(4).bgColour.RGB()\n\t\t\tExpect([3]int32{r, g, b}).To(Equal([3]int32{254, 254, 254}))\n\t\t})\n\n\t\tDescribe(\"Partially overwriting previous cells\", func() {\n\t\t\tvar overSubFrameJSONText = `{\n\t\t\t\t\"meta\": {\n\t\t\t\t\t\"id\": 1,\n\t\t\t\t\t\"sub_left\": 1,\n\t\t\t\t\t\"sub_top\": 4,\n\t\t\t\t\t\"sub_width\": 2,\n\t\t\t\t\t\"sub_height\": 4,\n\t\t\t\t\t\"total_width\": 3,\n\t\t\t\t\t\"total_height\": 8\n\t\t\t\t},\n\t\t\t\t\"text\": [\"D\", \"\", \"f\", \"\"],\n\t\t\t\t\"colours\": [\n\t\t\t\t\t78, 78, 78,\n\t\t\t\t\t111, 111, 111,\n\t\t\t\t\t112, 112, 112,\n\t\t\t\t\t113, 113, 113\n\t\t\t\t]\n\t\t\t}`\n\n\t\t\tvar overSubFrameJSONPixels = `{\n\t\t\t\t\"meta\": {\n\t\t\t\t\t\"id\": 1,\n\t\t\t\t\t\"sub_left\": 1,\n\t\t\t\t\t\"sub_top\": 4,\n\t\t\t\t\t\"sub_width\": 2,\n\t\t\t\t\t\"sub_height\": 4,\n\t\t\t\t\t\"total_width\": 3,\n\t\t\t\t\t\"total_height\": 8\n\t\t\t\t},\n\t\t\t\t\"colours\": [\n\t\t\t\t\t154, 154, 154, 211, 211, 211,\n\t\t\t\t\t11, 11, 11, 12, 12, 12,\n\t\t\t\t\t13, 13, 13, 14, 14, 14,\n\t\t\t\t\t223, 223, 223, 100, 100, 100\n\t\t\t\t]\n\t\t\t}`\n\n\t\t\tIt(\"should partially overwrite text\", func() {\n\t\t\t\tparseJSONFrameText(overSubFrameJSONText)\n\n\t\t\t\t// Pre-existing cells\n\t\t\t\tExpect(testGetCellChar(4)).To(Equal(\"A\"))\n\t\t\t\tExpect(testGetCellChar(5)).To(Equal(\"b\"))\n\n\t\t\t\t// Overwritten cells\n\t\t\t\tExpect(testGetCellChar(7)).To(Equal(\"D\"))\n\t\t\t\tExpect(testGetCellChar(8)).To(Equal(\"▄\"))\n\t\t\t\tExpect(testGetCellChar(10)).To(Equal(\"f\"))\n\t\t\t\tExpect(testGetCellChar(11)).To(Equal(\"▄\"))\n\t\t\t})\n\n\t\t\tIt(\"should overwrite colours in text-less cells\", func() {\n\t\t\t\tvar r, g, b int32\n\t\t\t\tparseJSONFramePixels(subFrameJSONPixels)\n\t\t\t\tparseJSONFrameText(overSubFrameJSONText)\n\t\t\t\tparseJSONFramePixels(overSubFrameJSONPixels)\n\n\t\t\t\toverwrittenCell := 8\n\t\t\t\tr, g, b = testGetCell(overwrittenCell).fgColour.RGB()\n\t\t\t\tExpect([3]int32{r, g, b}).To(Equal([3]int32{12, 12, 12}))\n\t\t\t\tr, g, b = testGetCell(overwrittenCell).bgColour.RGB()\n\t\t\t\tExpect([3]int32{r, g, b}).To(Equal([3]int32{211, 211, 211}))\n\t\t\t})\n\n\t\t\tIt(\"should partially overwrite text colours\", func() {\n\t\t\t\tvar r, g, b int32\n\t\t\t\tparseJSONFramePixels(subFrameJSONPixels)\n\t\t\t\tparseJSONFrameText(overSubFrameJSONText)\n\t\t\t\tparseJSONFramePixels(overSubFrameJSONPixels)\n\n\t\t\t\tpreExistingCell := 4\n\t\t\t\tr, g, b = testGetCell(preExistingCell).fgColour.RGB()\n\t\t\t\tExpect([3]int32{r, g, b}).To(Equal([3]int32{77, 77, 77}))\n\t\t\t\tr, g, b = testGetCell(preExistingCell).bgColour.RGB()\n\t\t\t\tExpect([3]int32{r, g, b}).To(Equal([3]int32{254, 254, 254}))\n\n\t\t\t\toverwrittenCell := 7\n\t\t\t\tr, g, b = testGetCell(overwrittenCell).fgColour.RGB()\n\t\t\t\tExpect([3]int32{r, g, b}).To(Equal([3]int32{78, 78, 78}))\n\t\t\t\tr, g, b = testGetCell(overwrittenCell).bgColour.RGB()\n\t\t\t\tExpect([3]int32{r, g, b}).To(Equal([3]int32{154, 154, 154}))\n\t\t\t})\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "interfacer/src/browsh/input_box.go",
    "content": "package browsh\n\nimport (\n\t\"encoding/json\"\n\t\"unicode/utf8\"\n\n\t\"github.com/gdamore/tcell\"\n)\n\nvar activeInputBox *inputBox\n\n// A box into which you can enter text. Generally will be forwarded to a standard\n// HTML input box in the real browser.\n//\n// Note that tcell alreay has some ready-made code in its 'views' concept for\n// dealing with input areas. However, at the time of writing it wasn't well documented,\n// so it was unclear how easy it would be to integrate the requirements of Browsh's\n// input boxes - namely overlaying them onto the existing graphics and having them\n// scroll in sync.\ntype inputBox struct {\n\tID             string   `json:\"id\"`\n\tX              int      `json:\"x\"`\n\tY              int      `json:\"y\"`\n\tWidth          int      `json:\"width\"`\n\tHeight         int      `json:\"height\"`\n\tTagName        string   `json:\"tag_name\"`\n\tType           string   `json:\"type\"`\n\tFgColour       [3]int32 `json:\"colour\"`\n\tbgColour       [3]int32\n\tisActive       bool\n\tmultiLiner     multiLine\n\ttext           []rune\n\txCursor        int\n\tyCursor        int\n\ttextCursor     int\n\txScroll        int\n\tyScroll        int\n\tselectionStart int\n\tselectionEnd   int\n}\n\nfunc newInputBox(id string) *inputBox {\n\tnewInputBox := &inputBox{\n\t\tID: id,\n\t}\n\t// TODO: Circular reference, what's the proper Golang way to do this?\n\tnewInputBox.multiLiner.inputBox = newInputBox\n\treturn newInputBox\n}\n\n// This is used only for the URL input box\nfunc (i *inputBox) renderURLBox() {\n\tbgRGB := tcell.ColorDefault\n\tfgRGB := tcell.NewRGBColor(i.FgColour[0], i.FgColour[1], i.FgColour[2])\n\tstyle := tcell.StyleDefault\n\tstyle = style.Foreground(fgRGB).Background(bgRGB)\n\tx := i.X\n\tfor _, c := range i.textToDisplay() {\n\t\tscreen.SetContent(x, i.Y, c, nil, style)\n\t\tx++\n\t}\n\ti.renderCursor()\n\tscreen.Show()\n}\n\n// This is used for all input boxes in the frame\nfunc (i *inputBox) setCells() {\n\tif i == nil {\n\t\treturn\n\t}\n\ti.resetCells()\n\tx := i.X\n\ty := i.Y\n\tlineCount := 0\n\tfor index, c := range i.textToDisplay() {\n\t\tif i.isMultiLine() && lineCount < i.yScroll {\n\t\t\tif isLineBreak(string(c)) {\n\t\t\t\tlineCount++\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif i.Type == \"password\" && index != len(i.text) {\n\t\t\tc = '●'\n\t\t}\n\t\ti.addCharacterToFrame(x, y, c)\n\t\tx++\n\t\tif i.isMultiLine() && isLineBreak(string(c)) {\n\t\t\tx = i.X\n\t\t\ty++\n\t\t\tlineCount++\n\t\t\tif lineCount-i.yScroll > i.Height {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\tscreen.Show()\n}\n\nfunc (i *inputBox) resetCells() {\n\tfor y := i.Y; y < i.Height; y++ {\n\t\tfor x := i.X; x < i.Width; x++ {\n\t\t\ti.addCharacterToFrame(x, y, ' ')\n\t\t}\n\t}\n}\n\nfunc (i *inputBox) addCharacterToFrame(x int, y int, c rune) {\n\tvar (\n\t\tindex                      int\n\t\tinputBoxCell, existingCell cell\n\t\tcellFGColour, cellBGColour tcell.Color\n\t\tok                         bool\n\t)\n\tcellFGColour = tcell.NewRGBColor(i.FgColour[0], i.FgColour[1], i.FgColour[2])\n\tindex = (y * CurrentTab.frame.totalWidth) + x\n\tif existingCell, ok = CurrentTab.frame.cells.load(index); ok {\n\t\tcellBGColour = existingCell.bgColour\n\t} else {\n\t\treturn\n\t}\n\tinputBoxCell = cell{\n\t\tcharacter: []rune{c},\n\t\tfgColour:  cellFGColour,\n\t\tbgColour:  cellBGColour,\n\t}\n\tCurrentTab.frame.cells.store(index, inputBoxCell)\n}\n\n// Different methods are used for containing and displaying overflowed text depending on the\n// size of the input box.\nfunc (i *inputBox) isMultiLine() bool {\n\tif urlInputBox.isActive {\n\t\treturn false\n\t}\n\treturn i.TagName == \"TEXTAREA\" || i.Type == \"textbox\"\n}\n\nfunc (i *inputBox) textToDisplay() []rune {\n\tif i.isMultiLine() {\n\t\treturn i.multiLiner.convert()\n\t}\n\treturn i.textToDisplayForSingleLine()\n}\n\nfunc (i *inputBox) textToDisplayForSingleLine() []rune {\n\tvar textToDisplay string\n\tindex := 0\n\tfor _, c := range append(i.text, ' ') {\n\t\tif index >= i.xScroll {\n\t\t\ttextToDisplay += string(c)\n\t\t}\n\t\tif utf8.RuneCountInString(textToDisplay) >= i.Width {\n\t\t\tbreak\n\t\t}\n\t\tindex++\n\t}\n\treturn []rune(textToDisplay)\n}\n\nfunc (i *inputBox) lineCount() int {\n\treturn len(i.multiLiner.finalText)\n}\n\nfunc isLineBreak(character string) bool {\n\treturn character == \"\\n\" || character == \"\\r\"\n}\n\nfunc (i *inputBox) sendInputBoxToBrowser() {\n\tinputBoxMap := map[string]interface{}{\n\t\t\"id\":   i.ID,\n\t\t\"text\": string(i.text),\n\t}\n\tmarshalled, _ := json.Marshal(inputBoxMap)\n\tsendMessageToWebExtension(\"/tab_command,/input_box,\" + string(marshalled))\n}\n\nfunc (i *inputBox) handleEnterKey(modifier tcell.ModMask) {\n\tif urlInputBox.isActive {\n\t\tif isNewEmptyTabActive() {\n\t\t\tsendMessageToWebExtension(\"/new_tab,\" + string(i.text))\n\t\t} else {\n\t\t\tsendMessageToWebExtension(\"/url_bar,\" + string(i.text))\n\t\t}\n\t\turlBarFocus(false)\n\t}\n\tif i.isMultiLine() && modifier != tcell.ModAlt {\n\t\ti.cursorInsertRune([]rune(\"\\n\")[0])\n\t} else {\n\t\ti.isActive = false\n\t}\n\tif i.isMultiLine() && modifier == tcell.ModAlt {\n\t\ti.text = nil\n\t\ti.isActive = true\n\t}\n\ti.updateAllCursors()\n}\n\nfunc (i *inputBox) selectionOff() {\n\ti.selectionStart = 0\n\ti.selectionEnd = 0\n}\n\nfunc (i *inputBox) selectAll() {\n\turlInputBox.selectionStart = 0\n\turlInputBox.selectionEnd = len(urlInputBox.text)\n}\n\nfunc (i *inputBox) removeSelectedText() {\n\tif i.selectionEnd-i.selectionStart <= 0 {\n\t\treturn\n\t}\n\tstart := i.text[:i.selectionStart]\n\tend := i.text[i.selectionEnd:]\n\ti.text = append(start, end...)\n\ti.textCursor = i.selectionStart\n\ti.updateXYCursors()\n\tactiveInputBox.selectionOff()\n}\n\nfunc handleInputBoxInput(ev *tcell.EventKey) {\n\tswitch ev.Key() {\n\tcase tcell.KeyLeft:\n\t\tactiveInputBox.selectionOff()\n\t\tactiveInputBox.cursorLeft()\n\tcase tcell.KeyRight:\n\t\tactiveInputBox.selectionOff()\n\t\tactiveInputBox.cursorRight()\n\tcase tcell.KeyDown:\n\t\tactiveInputBox.selectionOff()\n\t\tactiveInputBox.cursorDown()\n\tcase tcell.KeyUp:\n\t\tactiveInputBox.selectionOff()\n\t\tactiveInputBox.cursorUp()\n\tcase tcell.KeyBackspace, tcell.KeyBackspace2:\n\t\tactiveInputBox.removeSelectedText()\n\t\tactiveInputBox.cursorBackspace()\n\tcase tcell.KeyEnter:\n\t\tactiveInputBox.removeSelectedText()\n\t\tactiveInputBox.handleEnterKey(ev.Modifiers())\n\tcase tcell.KeyRune:\n\t\tactiveInputBox.removeSelectedText()\n\t\tactiveInputBox.cursorInsertRune(ev.Rune())\n\t}\n\tif urlInputBox.isActive {\n\t\trenderURLBar()\n\t} else {\n\t\trenderCurrentTabWindow()\n\t}\n}\n"
  },
  {
    "path": "interfacer/src/browsh/input_cursor.go",
    "content": "package browsh\n\nfunc (i *inputBox) renderCursor() {\n\tif !i.isActive {\n\t\treturn\n\t}\n\tif i.isSelection() {\n\t\ti.renderSelectionCursor()\n\t} else {\n\t\ti.renderSingleCursor()\n\t}\n}\n\nfunc (i *inputBox) isSelection() bool {\n\treturn i.selectionStart > 0 || i.selectionEnd > 0\n}\n\nfunc (i *inputBox) renderSingleCursor() {\n\tx, y := i.getCoordsOfCursor()\n\treverseCellColour(x, y)\n}\n\nfunc (i *inputBox) renderSelectionCursor() {\n\tvar x, y int\n\ttextLength := len(i.text)\n\tfor index := 0; index < textLength; index++ {\n\t\tx, y = i.getCoordsOfIndex(index)\n\t\tif x >= i.selectionStart && x < i.selectionEnd {\n\t\t\treverseCellColour(x, y)\n\t\t}\n\t}\n}\n\nfunc (i *inputBox) getCoordsOfCursor() (int, int) {\n\tvar index int\n\tif i.isMultiLine() {\n\t\tindex = i.xCursor\n\t} else {\n\t\tindex = i.textCursor\n\t}\n\treturn i.getCoordsOfIndex(index)\n}\n\nfunc (i *inputBox) getCoordsOfIndex(index int) (int, int) {\n\txFrameOffset := CurrentTab.frame.xScroll\n\tyFrameOffset := CurrentTab.frame.yScroll - uiHeight\n\tif urlInputBox.isActive {\n\t\txFrameOffset = 0\n\t\tyFrameOffset = 0\n\t}\n\tx := (i.X + index) - i.xScroll - xFrameOffset\n\ty := (i.Y + i.yCursor) - i.yScroll - yFrameOffset\n\treturn x, y\n}\n\nfunc (i *inputBox) cursorLeft() {\n\ti.xCursor--\n\ti.textCursor--\n\ti.updateAllCursors()\n}\n\nfunc (i *inputBox) cursorRight() {\n\ti.xCursor++\n\ti.textCursor++\n\ti.updateAllCursors()\n}\n\nfunc (i *inputBox) cursorUp() {\n\ti.multiLiner.moveYCursorBy(-1)\n\ti.updateAllCursors()\n}\n\nfunc (i *inputBox) cursorDown() {\n\ti.multiLiner.moveYCursorBy(1)\n\ti.updateAllCursors()\n}\n\nfunc (i *inputBox) cursorBackspace() {\n\tif len(i.text) == 0 {\n\t\treturn\n\t}\n\tif i.textCursor == 0 {\n\t\treturn\n\t}\n\tstart := i.text[:i.textCursor-1]\n\tend := i.text[i.textCursor:]\n\ti.text = append(start, end...)\n\ti.cursorLeft()\n\ti.sendInputBoxToBrowser()\n}\n\nfunc (i *inputBox) cursorInsertRune(theRune rune) {\n\tstart := i.text[:i.textCursor]\n\tend := i.text[i.textCursor:]\n\tendWithRune := append([]rune{theRune}, end...)\n\ti.text = append(start, endWithRune...)\n\ti.cursorRight()\n\ti.sendInputBoxToBrowser()\n}\n\nfunc (i *inputBox) isCursorOverRightEdge() bool {\n\treturn i.textCursor-i.xScroll >= i.Width\n}\n\nfunc (i *inputBox) isCursorOverLeftEdge() bool {\n\treturn i.textCursor-i.xScroll <= -1\n}\n\nfunc (i *inputBox) isCursorOverTopEdge() bool {\n\treturn i.yCursor-i.yScroll <= -1\n}\n\nfunc (i *inputBox) isCursorOverBottomEdge() bool {\n\treturn i.yCursor-i.yScroll > i.Height\n}\n\nfunc (i *inputBox) putCursorAtEnd() {\n\ti.textCursor = len(urlInputBox.text)\n\t// TODO: Do for multiline\n}\n\nfunc (i *inputBox) updateAllCursors() {\n\ti.updateXYCursors()\n\tif i.isCursorOverLeftEdge() || !i.isBestFit() {\n\t\ti.xScrollBy(-1)\n\t}\n\tif i.isCursorOverTopEdge() {\n\t\ti.yScrollBy(-1)\n\t}\n\tif i.isCursorOverRightEdge() {\n\t\ti.xScrollBy(1)\n\t}\n\tif i.isCursorOverBottomEdge() {\n\t\ti.yScrollBy(1)\n\t}\n\ti.limitTextCursor()\n\ti.updateXYCursors()\n}\n\nfunc (i *inputBox) limitTextCursor() {\n\tif i.textCursor < 0 {\n\t\ti.textCursor = 0\n\t}\n\tif i.textCursor > len(i.text) {\n\t\ti.textCursor = len(i.text)\n\t}\n}\n\nfunc (i *inputBox) updateXYCursors() {\n\tif !i.isMultiLine() {\n\t\treturn\n\t}\n\ti.multiLiner.updateCursor()\n\ti.renderCursor()\n}\n"
  },
  {
    "path": "interfacer/src/browsh/input_multiline.go",
    "content": "package browsh\n\nimport (\n\t\"strings\"\n\t\"unicode\"\n\t\"unicode/utf8\"\n)\n\ntype multiLine struct {\n\tinputBox          *inputBox\n\tindex             int\n\tfinalText         []string\n\tpreviousCharacter string\n\tcurrentCharacter  string\n\tcurrentWordish    string\n\tcurrentLine       string\n\tuserAddedLines    []int\n}\n\nfunc (m *multiLine) convert() []rune {\n\tvar aRune rune\n\tm.reset()\n\tfor m.index, aRune = range append(m.inputBox.text, ' ') {\n\t\tm.previousCharacter = m.currentCharacter\n\t\tm.currentCharacter = string(aRune)\n\t\tif m.isWordishReady() {\n\t\t\tm.addWordish()\n\t\t}\n\t\tif m.isInsideWord() {\n\t\t\t// TODO: This sometimes causes a panic :/\n\t\t\tm.currentWordish += m.currentCharacter\n\t\t} else {\n\t\t\tm.addWhitespace()\n\t\t}\n\t\tif m.isFinalCharacter() {\n\t\t\tm.finish()\n\t\t}\n\t}\n\tfinalText := []rune(strings.Join(m.finalText, \"\\n\"))\n\treturn finalText\n}\n\nfunc (m *multiLine) reset() {\n\tm.finalText = nil\n\tm.previousCharacter = \"\"\n\tm.currentCharacter = \"\"\n\tm.currentWordish = \"\"\n\tm.currentLine = \"\"\n\tm.userAddedLines = nil\n}\n\nfunc (m *multiLine) isInsideWord() bool {\n\treturn !m.isCurrentCharacterWhitespace()\n}\n\nfunc (m *multiLine) isPreviousCharacterWhitespace() bool {\n\t// TODO: Not certain returning `true` for emptiness is best\n\tif m.previousCharacter == \"\" {\n\t\treturn true\n\t}\n\trunes := []rune(m.previousCharacter)\n\tif len(runes) == 0 {\n\t\treturn true\n\t}\n\treturn unicode.IsSpace(runes[0])\n}\n\nfunc (m *multiLine) isCurrentCharacterWhitespace() bool {\n\tif len([]rune(m.currentCharacter)) == 0 {\n\t\treturn false\n\t}\n\treturn unicode.IsSpace([]rune(m.currentCharacter)[0])\n}\n\nfunc (m *multiLine) isWordishReady() bool {\n\treturn m.isNaturalWordEnding() || m.isProjectedLineFull()\n}\n\nfunc (m *multiLine) isNaturalWordEnding() bool {\n\treturn !m.isPreviousCharacterWhitespace() && m.isCurrentCharacterWhitespace()\n}\n\nfunc (m *multiLine) isForcedWordEnding() bool {\n\treturn m.isCurrentWordishFillingLine() && m.isProjectedLineFull()\n}\n\nfunc (m *multiLine) isCurrentWordishFillingLine() bool {\n\treturn m.currentWordishLength() == m.inputBox.Width\n}\n\nfunc (m *multiLine) currentWordishLength() int {\n\treturn utf8.RuneCountInString(m.currentWordish)\n}\n\nfunc (m *multiLine) currentLineLength() int {\n\treturn utf8.RuneCountInString(m.currentLine)\n}\n\nfunc (m *multiLine) isProjectedLineFull() bool {\n\treturn m.currentLineLength()+m.currentWordishLength() >= m.inputBox.Width\n}\n\nfunc (m *multiLine) addWordish() {\n\tif m.isProjectedLineFull() {\n\t\tif m.isForcedWordEnding() {\n\t\t\tm.addLineWithTruncatedWordish()\n\t\t} else {\n\t\t\tm.addLineButWrapWord()\n\t\t}\n\t} else {\n\t\tm.appendWordToLine()\n\t}\n}\n\nfunc (m *multiLine) addLineWithTruncatedWordish() {\n\tm.currentLine += m.currentWordish\n\tm.currentWordish = \"\"\n\tm.addLine()\n}\n\nfunc (m *multiLine) addLineButWrapWord() {\n\tm.addLine()\n\tif m.isNaturalWordEnding() {\n\t\tm.appendWordToLine()\n\t}\n}\n\nfunc (m *multiLine) noteUserAddedLineIndex() {\n\tm.userAddedLines = append(m.userAddedLines, m.lineCount()-1)\n}\n\nfunc (m *multiLine) appendWordToLine() {\n\tm.currentLine += m.currentWordish\n\tm.currentWordish = \"\"\n}\n\nfunc (m *multiLine) addLine() {\n\tm.finalText = append(m.finalText, m.currentLine)\n\tm.currentLine = \"\"\n}\n\nfunc (m *multiLine) addWhitespace() {\n\tif m.isNaturalLineBreak() {\n\t\tm.addLine()\n\t\tm.noteUserAddedLineIndex()\n\t} else {\n\t\tm.currentLine += string(m.currentCharacter)\n\t}\n}\n\nfunc (m *multiLine) isNaturalLineBreak() bool {\n\treturn isLineBreak(m.currentCharacter)\n}\n\nfunc (m *multiLine) isFinalCharacter() bool {\n\treturn m.index+1 == len(m.inputBox.text)+1\n}\n\nfunc (m *multiLine) lineCount() int {\n\treturn len(m.finalText)\n}\n\nfunc (m *multiLine) finish() {\n\tm.finalText = append(m.finalText, m.currentLine)\n}\n\nfunc (m *multiLine) updateCursor() {\n\txCursor := 0\n\tyCursor := 0\n\tindex := 0\n\tm.convert()\n\tfor lineIndex, line := range m.finalText {\n\t\tfor range line + \" \" {\n\t\t\tif index == m.inputBox.textCursor {\n\t\t\t\tm.inputBox.xCursor = xCursor\n\t\t\t\tm.inputBox.yCursor = yCursor\n\t\t\t}\n\t\t\txCursor++\n\t\t\tindex++\n\t\t}\n\t\tif !m.isUserAddedLine(lineIndex) {\n\t\t\tindex--\n\t\t}\n\t\txCursor = 0\n\t\tyCursor++\n\t}\n}\n\nfunc (m *multiLine) moveYCursorBy(magnitude int) {\n\tif !m.inputBox.isMultiLine() {\n\t\treturn\n\t}\n\tm.convert()\n\tm.updateCursor()\n\tlastLineIndex := m.lineCount() - 1\n\tm.inputBox.yCursor += magnitude\n\tif m.inputBox.yCursor < 0 {\n\t\tm.inputBox.yCursor = 0\n\t}\n\tif m.inputBox.yCursor > lastLineIndex {\n\t\tm.inputBox.yCursor = lastLineIndex\n\t}\n\ttargetLineLength := utf8.RuneCountInString(m.finalText[m.inputBox.yCursor])\n\tif m.inputBox.xCursor > targetLineLength-1 {\n\t\tm.inputBox.xCursor = targetLineLength\n\t\tif !m.isUserAddedLine(m.inputBox.yCursor) {\n\t\t\tm.inputBox.xCursor--\n\t\t}\n\t\tif m.inputBox.xCursor < 0 {\n\t\t\tm.inputBox.xCursor = 0\n\t\t}\n\t}\n\tm.convertXYCursorToTextCursor()\n}\n\nfunc (m *multiLine) convertXYCursorToTextCursor() {\n\tnewTextCursor := 0\n\tfor i := 0; i < m.inputBox.yCursor; i++ {\n\t\tnewTextCursor += utf8.RuneCountInString(m.finalText[i])\n\t\tif m.isUserAddedLine(i) {\n\t\t\tnewTextCursor++\n\t\t}\n\t}\n\tnewTextCursor += m.inputBox.xCursor\n\tm.inputBox.textCursor = newTextCursor\n\tm.updateCursor()\n}\n\nfunc (m *multiLine) isUserAddedLine(index int) bool {\n\tfor i := 0; i < len(m.userAddedLines); i++ {\n\t\tif m.userAddedLines[i] == index {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "interfacer/src/browsh/input_multiline_test.go",
    "content": "package browsh\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo\"\n\t. \"github.com/onsi/gomega\"\n)\n\nfunc TestMultiLineTextBuilder(t *testing.T) {\n\tRegisterFailHandler(Fail)\n}\n\nvar input *inputBox\n\nfunc toMulti(text string, width int) string {\n\tinput = newInputBox(\"0\")\n\tinput.text = []rune(text)\n\tinput.Width = width\n\tinput.TagName = \"TEXTAREA\"\n\ttextRunes := input.multiLiner.convert()\n\traw := string(textRunes)\n\traw = visualiseWhitespace(raw)\n\treturn raw\n}\n\nfunc visualiseWhitespace(text string) string {\n\ttext = strings.Replace(text, \" \", \"_\", -1)\n\ttext = strings.Replace(text, \"\\n\", \"\\\\n\\n\", -1)\n\treturn text\n}\n\nfunc showWhitespace(textArray []string) string {\n\ttext := strings.Join(textArray, \"\\n\")\n\treturn visualiseWhitespace(text)\n}\n\nvar _ = Describe(\"Multiline text\", func() {\n\tIt(\"should wrap basic text\", func() {\n\t\tactual := toMulti(\"a ab 12 qw 34\", 3)\n\t\texpected := showWhitespace([]string{\n\t\t\t\"a \",\n\t\t\t\"ab \",\n\t\t\t\"12 \",\n\t\t\t\"qw \",\n\t\t\t\"34 \",\n\t\t})\n\t\tExpect(actual).To(Equal(expected))\n\t})\n\n\tIt(\"should wrap text with a word longer than the width limit\", func() {\n\t\tactual := toMulti(\"a looooong 12 qw 34\", 3)\n\t\texpected := showWhitespace([]string{\n\t\t\t\"a \",\n\t\t\t\"loo\",\n\t\t\t\"ooo\",\n\t\t\t\"ng \",\n\t\t\t\"12 \",\n\t\t\t\"qw \",\n\t\t\t\"34 \",\n\t\t})\n\t\tExpect(actual).To(Equal(expected))\n\t})\n\n\tIt(\"should wrap text lines with multiple words\", func() {\n\t\tactual := toMulti(\"some words to make a long sentence with many words on each line\", 20)\n\t\texpected := showWhitespace([]string{\n\t\t\t\"some words to make \",\n\t\t\t\"a long sentence \",\n\t\t\t\"with many words on \",\n\t\t\t\"each line \",\n\t\t})\n\t\tExpect(actual).To(Equal(expected))\n\t})\n\n\tDescribe(\"Moving the Y cursor\", func() {\n\t\tIt(\"should move to a line of greater width\", func() {\n\t\t\ttoMulti(\n\t\t\t\t`some words !o make `+\n\t\t\t\t\t`a long sent+nce `+\n\t\t\t\t\t`with many words on `+\n\t\t\t\t\t`each line `, 20)\n\t\t\tinput.textCursor = 11\n\t\t\tinput.multiLiner.moveYCursorBy(1)\n\t\t\tExpect(input.textCursor).To(Equal(30))\n\t\t\tExpect(input.xCursor).To(Equal(11))\n\t\t\tExpect(input.yCursor).To(Equal(1))\n\t\t})\n\n\t\tIt(\"should move to a line of smaller width\", func() {\n\t\t\ttoMulti(\n\t\t\t\t`some words to make `+\n\t\t\t\t\t`a long sentence `+\n\t\t\t\t\t`with many w!rds on `+\n\t\t\t\t\t`each line+`, 20)\n\t\t\tinput.textCursor = 47\n\t\t\tinput.multiLiner.moveYCursorBy(1)\n\t\t\tExpect(input.textCursor).To(Equal(64))\n\t\t\tExpect(input.xCursor).To(Equal(10))\n\t\t\tExpect(input.yCursor).To(Equal(3))\n\t\t})\n\t\tDescribe(\"In text that has user-added line breaks\", func() {\n\t\t\tIt(\"should move to a line of smaller width\", func() {\n\t\t\t\ttoMulti(\n\t\t\t\t\t`some words to make `+\n\t\t\t\t\t\t\"a long \\n\"+\n\t\t\t\t\t\t`sentence with man! `+\n\t\t\t\t\t\t`words+`, 20)\n\t\t\t\tinput.textCursor = 45\n\t\t\t\tinput.multiLiner.moveYCursorBy(1)\n\t\t\t\tExpect(input.textCursor).To(Equal(52))\n\t\t\t\tExpect(input.xCursor).To(Equal(6))\n\t\t\t\tExpect(input.yCursor).To(Equal(3))\n\t\t\t})\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "interfacer/src/browsh/input_scroll.go",
    "content": "package browsh\n\nfunc (i *inputBox) xScrollBy(magnitude int) {\n\tif !i.isMultiLine() {\n\t\ti.handleSingleLineScroll(magnitude)\n\t}\n\ti.limitScroll()\n}\n\nfunc (i *inputBox) yScrollBy(magnitude int) {\n\tif i.isMultiLine() {\n\t\ti.yScroll += magnitude\n\t}\n\ti.limitScroll()\n}\n\nfunc (i *inputBox) handleSingleLineScroll(magnitude int) {\n\tdetectionTextWidth := len(i.text)\n\tdetectionBoxWidth := i.Width\n\tif magnitude < 0 {\n\t\tdetectionTextWidth++\n\t\tdetectionBoxWidth -= 2\n\t}\n\tisOverflowing := detectionTextWidth >= i.Width\n\tif isOverflowing {\n\t\tif i.isCursorAtEdgeOfBox(detectionBoxWidth) || !i.isBestFit() {\n\t\t\ti.xScroll += magnitude\n\t\t}\n\t}\n}\n\nfunc (i *inputBox) isCursorAtEdgeOfBox(detectionBoxWidth int) bool {\n\tisCursorAtStartOfBox := i.textCursor-i.xScroll < 0\n\tisCursorAtEndOfBox := i.textCursor-i.xScroll >= detectionBoxWidth\n\treturn isCursorAtStartOfBox || isCursorAtEndOfBox\n}\n\nfunc (i *inputBox) isBestFit() bool {\n\tlengthOfVisibleText := len(i.text) - i.xScroll\n\treturn lengthOfVisibleText >= i.Width\n}\n\n// Note that distinct methods are used for single line and multiline overflow, so their\n// respective limit checks never encroach on each other.\nfunc (i *inputBox) limitScroll() {\n\tif i.xScroll < 0 {\n\t\ti.xScroll = 0\n\t}\n\tif i.xScroll > len(i.text) {\n\t\ti.xScroll = len(i.text)\n\t}\n\tif i.isMultiLine() {\n\t\tif i.yScroll < 0 {\n\t\t\ti.yScroll = 0\n\t\t}\n\t\tif i.yScroll > i.lineCount()-1 {\n\t\t\ti.yScroll = (i.lineCount() - 1) - i.Height\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "interfacer/src/browsh/raw_text_server.go",
    "content": "package browsh\n\nimport (\n\t\"crypto/rand\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/NYTimes/gziphandler\"\n\t\"github.com/spf13/viper\"\n\t\"github.com/ulule/limiter\"\n\t\"github.com/ulule/limiter/drivers/middleware/stdlib\"\n\t\"github.com/ulule/limiter/drivers/store/memory\"\n)\n\n// In order to communicate between the incoming HTTP request and the websocket request to the\n// real browser to render the webpage, we keep track of requests in a map.\nvar rawTextRequests = newRequestsMap()\n\ntype threadSafeRequestsMap struct {\n\tsync.RWMutex\n\tinternal map[string]string\n}\n\nfunc newRequestsMap() *threadSafeRequestsMap {\n\treturn &threadSafeRequestsMap{\n\t\tinternal: make(map[string]string),\n\t}\n}\n\nfunc (m *threadSafeRequestsMap) load(key string) (value string, ok bool) {\n\tm.RLock()\n\tresult, ok := m.internal[key]\n\tm.RUnlock()\n\treturn result, ok\n}\n\nfunc (m *threadSafeRequestsMap) store(key string, value string) {\n\tm.Lock()\n\tm.internal[key] = value\n\tm.Unlock()\n}\n\nfunc (m *threadSafeRequestsMap) remove(key string) {\n\tm.Lock()\n\tdelete(m.internal, key)\n\tm.Unlock()\n}\n\ntype rawTextResponse struct {\n\tPageloadDuration int    `json:\"page_load_duration\"`\n\tParsingDuration  int    `json:\"parsing_duration\"`\n\tText             string `json:\"body\"`\n}\n\n// HTTPServerStart starts the HTTP server is a seperate service from the usual interactive TTY\n// app. It accepts normal HTTP requests and uses the path portion of the URL as the entry to the\n// Browsh URL bar. It then returns a simple line-broken text version of whatever the browser\n// loads. So for example, if you request `curl browsh-http-service.com/http://something.com`,\n// it will return:\n// `Something                                                                    `\nfunc HTTPServerStart() {\n\tIsHTTPServerMode = true\n\tStartFirefox()\n\tgo startWebSocketServer()\n\tslog.Info(\"Starting Browsh HTTP server\")\n\tbind := viper.GetString(\"http-server.bind\")\n\tport := viper.GetString(\"http-server.port\")\n\tserverMux := http.NewServeMux()\n\tuncompressed := http.HandlerFunc(handleHTTPServerRequest)\n\tlimiterMiddleware := setupRateLimiter()\n\tserverMux.Handle(\"/\", limiterMiddleware.Handler(gziphandler.GzipHandler(uncompressed)))\n\tif err := http.ListenAndServe(bind+\":\"+port, &slashFix{serverMux}); err != nil {\n\t\tShutdown(err)\n\t}\n}\n\nfunc setupRateLimiter() *stdlib.Middleware {\n\trate, err := limiter.NewRateFromFormatted(viper.GetString(\"http-server.rate-limit\"))\n\tif err != nil {\n\t\tShutdown(err)\n\t}\n\t// TODO: Centralise store amongst instances with Redis\n\tstore := memory.NewStore()\n\tmiddleware := stdlib.NewMiddleware(limiter.New(store, rate), stdlib.WithForwardHeader(true))\n\treturn middleware\n}\n\nfunc pseudoUUID() (uuid string) {\n\tb := make([]byte, 16)\n\t_, err := rand.Read(b)\n\tif err != nil {\n\t\tfmt.Println(\"Error: \", err)\n\t\treturn\n\t}\n\tuuid = fmt.Sprintf(\"%X-%X-%X-%X-%X\", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])\n\treturn uuid\n}\n\ntype slashFix struct {\n\tmux http.Handler\n}\n\n// The default router from net/http collapses double slashes to a single slash in URL paths.\n// This is obviously a problem for putting URLs in the path part of a URL, eg;\n// https://domain.com/http://anotherdomain.com\n// So here is a little hack that simply escapes the entire path portion to make sure it gets\n// through the router unchanged.\nfunc (h *slashFix) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n\tr.URL.Path = \"/\" + url.PathEscape(strings.TrimPrefix(r.URL.RequestURI(), \"/\"))\n\th.mux.ServeHTTP(w, r)\n}\n\nfunc handleHTTPServerRequest(w http.ResponseWriter, r *http.Request) {\n\tvar message string\n\tvar isErrored bool\n\tstart := time.Now().Format(time.RFC3339)\n\turlForBrowsh, _ := url.PathUnescape(strings.TrimPrefix(r.URL.Path, \"/\"))\n\turlForBrowsh, isErrored = deRecurseURL(urlForBrowsh)\n\tif isErrored {\n\t\tmessage = \"Invalid URL\"\n\t\tio.WriteString(w, message)\n\t\treturn\n\t}\n\tif isProductionHTTP(r) {\n\t\thttp.Redirect(w, r, \"https://\"+r.Host+\"/\"+urlForBrowsh, 301)\n\t\treturn\n\t}\n\tif urlForBrowsh == \"favicon.ico\" {\n\t\thttp.Redirect(w, r, \"https://www.brow.sh/assets/favicon-16x16.png\", 301)\n\t\treturn\n\t}\n\tw.Header().Set(\"Cache-Control\", \"public, max-age=600\")\n\tif isDisallowedDomain(urlForBrowsh) {\n\t\thttp.Redirect(w, r, \"/\", 301)\n\t\treturn\n\t}\n\tif isDisallowedUserAgent(r.Header.Get(\"User-Agent\")) {\n\t\tif urlForBrowsh != \"\" {\n\t\t\thttp.Redirect(w, r, \"/\", 403)\n\t\t\treturn\n\t\t}\n\t}\n\tslog.Info(\"Handling request\", \"User-Agent\", r.Header.Get(\"User-Agent\"))\n\tif isKubeReadinessProbe(r.Header.Get(\"User-Agent\")) {\n\t\tio.WriteString(w, \"healthy\")\n\t\treturn\n\t}\n\tif strings.TrimSpace(urlForBrowsh) == \"\" {\n\t\tif strings.Contains(r.Host, \"text.\") {\n\t\t\tmessage = \"Welcome to the Browsh plain text client.\\n\" +\n\t\t\t\t\"You can use it by appending URLs like this;\\n\" +\n\t\t\t\t\"https://text.brow.sh/https://www.brow.sh\"\n\t\t\tio.WriteString(w, message)\n\t\t\treturn\n\t\t}\n\t\turlForBrowsh = \"https://www.brow.sh/html-service-welcome\"\n\t}\n\tif urlForBrowsh == \"robots.txt\" {\n\t\tmessage = \"User-agent: *\\nAllow: /$\\nDisallow: /\\n\"\n\t\tio.WriteString(w, message)\n\t\treturn\n\t}\n\trawTextRequestID := pseudoUUID()\n\trawTextRequests.store(rawTextRequestID+\"-start\", start)\n\tmode := getRawTextMode(r)\n\tsendMessageToWebExtension(\n\t\t\"/raw_text_request,\" + rawTextRequestID + \",\" +\n\t\t\tmode + \",\" +\n\t\t\turlForBrowsh)\n\twaitForResponse(rawTextRequestID, w)\n}\n\n// Prevent https://html.brow.sh/html.brow.sh/... being recursive\nfunc deRecurseURL(urlForBrowsh string) (string, bool) {\n\tnestedURL, err := url.Parse(urlForBrowsh)\n\tif err != nil {\n\t\treturn urlForBrowsh, false\n\t}\n\tif nestedURL.Host != \"html.brow.sh\" && nestedURL.Host != \"text.brow.sh\" {\n\t\treturn urlForBrowsh, false\n\t}\n\treturn deRecurseURL(strings.TrimPrefix(nestedURL.RequestURI(), \"/\"))\n}\n\nfunc isDisallowedDomain(urlForBrowsh string) bool {\n\tfor _, domainish := range viper.GetStringSlice(\"http-server.blocked-domains\") {\n\t\tr, _ := regexp.Compile(domainish)\n\t\tif r.MatchString(urlForBrowsh) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc isDisallowedUserAgent(userAgent string) bool {\n\tfor _, agentish := range viper.GetStringSlice(\"http-server.blocked-user-agents\") {\n\t\tr, _ := regexp.Compile(agentish)\n\t\tif r.MatchString(userAgent) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc isKubeReadinessProbe(userAgent string) bool {\n\tr, _ := regexp.Compile(\"GoogleHC\")\n\tif r.MatchString(userAgent) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isProductionHTTP(r *http.Request) bool {\n\tif strings.Contains(r.Host, \"brow.sh\") {\n\t\treturn r.Header.Get(\"X-Forwarded-Proto\") == \"http\"\n\t}\n\treturn false\n}\n\n// 'PLAIN' mode returns raw text without any HTML whatsoever.\n// 'HTML' mode returns some basic HTML tags for things like anchor links.\n// 'DOM' mode returns a simple dump of the DOM.\nfunc getRawTextMode(r *http.Request) string {\n\tmode := \"HTML\"\n\tif strings.Contains(r.Host, \"text.\") {\n\t\tmode = \"PLAIN\"\n\t}\n\tif r.Header.Get(\"X-Browsh-Raw-Mode\") == \"PLAIN\" {\n\t\tmode = \"PLAIN\"\n\t}\n\tif r.Header.Get(\"X-Browsh-Raw-Mode\") == \"DOM\" {\n\t\tmode = \"DOM\"\n\t}\n\treturn mode\n}\n\nfunc waitForResponse(rawTextRequestID string, w http.ResponseWriter) {\n\tvar rawTextRequestResponse string\n\tvar ok bool\n\tisSent := false\n\tmaxTime := time.Duration(viper.GetInt(\"http-server.timeout\")) * time.Second\n\tstart := time.Now()\n\tfor time.Since(start) < maxTime {\n\t\tif rawTextRequestResponse, ok = rawTextRequests.load(rawTextRequestID); ok {\n\t\t\tsendResponse(rawTextRequestResponse, rawTextRequestID, w)\n\t\t\tisSent = true\n\t\t\tbreak\n\t\t}\n\t\ttime.Sleep(1 * time.Millisecond)\n\t}\n\trawTextRequests.remove(rawTextRequestID)\n\tif !isSent {\n\t\ttimeout := viper.GetInt(\"http-server.timeout\")\n\t\tmessage := fmt.Sprintf(\"Browsh rendering aborted after %ds timeout.\", timeout)\n\t\tio.WriteString(w, message)\n\t}\n}\n\nfunc sendResponse(response, rawTextRequestID string, w http.ResponseWriter) {\n\tjsonResponse := unpackResponse(response)\n\trequestStart, _ := rawTextRequests.load(rawTextRequestID + \"-start\")\n\ttotalTime := getTotalTiming(requestStart)\n\tpageLoad := fmt.Sprintf(\"%d\", jsonResponse.PageloadDuration)\n\tparsing := fmt.Sprintf(\"%d\", jsonResponse.ParsingDuration)\n\tw.Header().Set(\"X-Browsh-Duration-Total\", totalTime)\n\tw.Header().Set(\"X-Browsh-Duration-Pageload\", pageLoad)\n\tw.Header().Set(\"X-Browsh-Duration-Parsing\", parsing)\n\tio.WriteString(w, jsonResponse.Text)\n}\n\nfunc unpackResponse(jsonString string) rawTextResponse {\n\tvar response rawTextResponse\n\tjsonBytes := []byte(jsonString)\n\tif err := json.Unmarshal(jsonBytes, &response); err != nil {\n\t}\n\treturn response\n}\n\nfunc getTotalTiming(startString string) string {\n\tstart, _ := time.Parse(time.RFC3339, startString)\n\telapsed := time.Since(start) / time.Millisecond\n\treturn fmt.Sprintf(\"%d\", elapsed)\n}\n"
  },
  {
    "path": "interfacer/src/browsh/raw_text_server_test.go",
    "content": "package browsh\n\nimport (\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo\"\n\t. \"github.com/onsi/gomega\"\n)\n\nfunc TestRawTextServer(t *testing.T) {\n\tRegisterFailHandler(Fail)\n}\n\nvar _ = Describe(\"Raw text server\", func() {\n\tDescribe(\"De-recursing URLs\", func() {\n\t\tIt(\"should not do anything to normal URLs\", func() {\n\t\t\ttestURL := \"https://google.com/path?q=hey\"\n\t\t\turl, _ := deRecurseURL(testURL)\n\t\t\tExpect(url).To(Equal(testURL))\n\t\t})\n\t\tIt(\"should de-recurse a single level\", func() {\n\t\t\ttestURL := \"https://html.brow.sh/word\"\n\t\t\turl, _ := deRecurseURL(testURL)\n\t\t\tExpect(url).To(Equal(\"word\"))\n\t\t})\n\t\tIt(\"should de-recurse a multi level recurse without a URL ending\", func() {\n\t\t\ttestURL := \"https://html.brow.sh/https://html.brow.sh\"\n\t\t\turl, _ := deRecurseURL(testURL)\n\t\t\tExpect(url).To(Equal(\"\"))\n\t\t})\n\t\tIt(\"should de-recurse a multi level recurse with a URL ending\", func() {\n\t\t\tgoogle := \"https://google.com/path?q=hey\"\n\t\t\ttestURL := \"https://html.brow.sh/https://html.brow.sh/\" + google\n\t\t\turl, _ := deRecurseURL(testURL)\n\t\t\tExpect(url).To(Equal(google))\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "interfacer/src/browsh/tab.go",
    "content": "package browsh\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n)\n\n// Tabs is a map of all tab data\nvar Tabs = make(map[int]*tab)\n\n// CurrentTab is the currently active tab in the TTY browser\nvar CurrentTab *tab\n\n// Slice of the order in which tabs appear in the tab bar\nvar tabsOrder []int\n\n// There can be a race condition between the webext sending a tab state update and the\n// the tab being deleted, so we need to keep track of all deleted IDs\nvar tabsDeleted []int\n\n// A single tab synced from the browser\ntype tab struct {\n\tID            int    `json:\"id\"`\n\tActive        bool   `json:\"active\"`\n\tTitle         string `json:\"title\"`\n\tURI           string `json:\"uri\"`\n\tPageState     string `json:\"page_state\"`\n\tStatusMessage string `json:\"status_message\"`\n\tframe         frame\n}\n\nfunc ResetTabs() {\n\tTabs = make(map[int]*tab)\n\tCurrentTab = nil\n\ttabsOrder = nil\n\ttabsDeleted = nil\n}\n\nfunc ensureTabExists(id int) {\n\tif _, ok := Tabs[id]; !ok {\n\t\tnewTab(id)\n\t\tif isNewEmptyTabActive() {\n\t\t\tremoveTab(-1)\n\t\t}\n\t}\n}\n\nfunc isTabPresent(id int) bool {\n\t_, ok := Tabs[id]\n\treturn ok\n}\n\nfunc newTab(id int) {\n\ttabsOrder = append(tabsOrder, id)\n\tTabs[id] = &tab{\n\t\tID: id,\n\t\tframe: frame{\n\t\t\txScroll: 0,\n\t\t\tyScroll: 0,\n\t\t},\n\t}\n}\n\nfunc removeTab(id int) {\n\tif len(Tabs) == 1 {\n\t\tquitBrowsh()\n\t}\n\ttabsDeleted = append(tabsDeleted, id)\n\tsendMessageToWebExtension(fmt.Sprintf(\"/remove_tab,%d\", id))\n\tnextTab()\n\tremoveTabIDfromTabsOrder(id)\n\tdelete(Tabs, id)\n\trenderUI()\n\trenderCurrentTabWindow()\n}\n\n// A bit complicated! Just want to remove an integer from a slice whilst retaining\n// order :/\nfunc removeTabIDfromTabsOrder(id int) {\n\tfor i := 0; i < len(tabsOrder); i++ {\n\t\tif tabsOrder[i] == id {\n\t\t\ttabsOrder = append(tabsOrder[:i], tabsOrder[i+1:]...)\n\t\t}\n\t}\n}\n\n// Creating a new tab in the browser without a URI means it won't register with the\n// web extension, which means that, come the moment when we actually have a URI for the new\n// tab then we can't talk to it to tell it navigate. So we need to only create a real new\n// tab when we actually have a URL.\nfunc createNewEmptyTab() {\n\tif isNewEmptyTabActive() {\n\t\treturn\n\t}\n\tnewTab(-1)\n\ttab := Tabs[-1]\n\ttab.Title = \"New Tab\"\n\ttab.URI = \"\"\n\ttab.Active = true\n\tCurrentTab = tab\n\tCurrentTab.frame.resetCells()\n\trenderUI()\n\turlBarFocus(true)\n\trenderCurrentTabWindow()\n}\n\nfunc isNewEmptyTabActive() bool {\n\treturn isTabPresent(-1)\n}\n\nfunc nextTab() {\n\tfor i := 0; i < len(tabsOrder); i++ {\n\t\tif tabsOrder[i] == CurrentTab.ID {\n\t\t\tif i+1 == len(tabsOrder) {\n\t\t\t\ti = 0\n\t\t\t} else {\n\t\t\t\ti++\n\t\t\t}\n\t\t\tsendMessageToWebExtension(fmt.Sprintf(\"/switch_to_tab,%d\", tabsOrder[i]))\n\t\t\tCurrentTab = Tabs[tabsOrder[i]]\n\t\t\trenderUI()\n\t\t\trenderCurrentTabWindow()\n\t\t\tbreak\n\t\t}\n\t}\n}\n\nfunc isTabPreviouslyDeleted(id int) bool {\n\tfor i := 0; i < len(tabsDeleted); i++ {\n\t\tif tabsDeleted[i] == id {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc parseJSONTabState(jsonString string) {\n\tvar incoming tab\n\tjsonBytes := []byte(jsonString)\n\tif err := json.Unmarshal(jsonBytes, &incoming); err != nil {\n\t\tShutdown(err)\n\t}\n\tif isTabPreviouslyDeleted(incoming.ID) {\n\t\treturn\n\t}\n\tensureTabExists(incoming.ID)\n\tif incoming.Active && !isNewEmptyTabActive() {\n\t\tCurrentTab = Tabs[incoming.ID]\n\t}\n\tTabs[incoming.ID].handleStateChange(&incoming)\n}\n\nfunc (t *tab) handleStateChange(incoming *tab) {\n\tif t.PageState != incoming.PageState {\n\t\t// TODO: Take the browser's scroll events as lead\n\t\tif incoming.PageState == \"page_init\" {\n\t\t\tt.frame.yScroll = 0\n\t\t}\n\t}\n\n\t// TODO: What's the idiomatic Golang way to do this?\n\tt.Title = incoming.Title\n\tt.URI = incoming.URI\n\tt.PageState = incoming.PageState\n\tt.StatusMessage = incoming.StatusMessage\n}\n"
  },
  {
    "path": "interfacer/src/browsh/tty.go",
    "content": "package browsh\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"strconv\"\n\n\t\"github.com/gdamore/tcell\"\n\t\"github.com/go-errors/errors\"\n\t\"github.com/spf13/viper\"\n)\n\nvar (\n\tscreen   tcell.Screen\n\tuiHeight = 2\n\t// IsMonochromeMode decides whether to render the TTY in full colour or monochrome\n\tIsMonochromeMode = false\n\n\terrNormalExit = errors.New(\"normal\")\n)\n\nfunc setupTcell() {\n\tvar err error\n\tif err = screen.Init(); err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"%v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\tIsMonochromeMode = viper.GetBool(\"monochrome\")\n\tscreen.EnableMouse()\n\tscreen.Clear()\n}\n\nfunc sendTtySize() {\n\twidth, height := screen.Size()\n\turlInputBox.Width = width\n\tsendMessageToWebExtension(fmt.Sprintf(\"/tty_size,%d,%d\", width, height))\n}\n\n// This is basically a proxy that listens to STDIN and forwards all relevant input\n// from the user to the webextension. So keyboard, mouse, terminal resizes, etc.\nfunc readStdin() {\n\tfor {\n\t\tev := screen.PollEvent()\n\t\tswitch ev := ev.(type) {\n\t\tcase *tcell.EventKey:\n\t\t\thandleUserKeyPress(ev)\n\t\tcase *tcell.EventResize:\n\t\t\thandleTTYResize()\n\t\tcase *tcell.EventMouse:\n\t\t\thandleMouseEvent(ev)\n\t\t}\n\t}\n}\n\nfunc handleUserKeyPress(ev *tcell.EventKey) {\n\tif CurrentTab == nil {\n\t\tif ev.Key() == tcell.KeyCtrlQ {\n\t\t\tquitBrowsh()\n\t\t}\n\t\treturn\n\t}\n\tswitch ev.Key() {\n\tcase tcell.KeyCtrlQ:\n\t\tquitBrowsh()\n\tcase tcell.KeyCtrlL:\n\t\turlBarFocusToggle()\n\tcase tcell.KeyCtrlT:\n\t\tcreateNewEmptyTab()\n\tcase tcell.KeyCtrlU:\n\t\tif !isNewEmptyTabActive() {\n\t\t\tsendMessageToWebExtension(\"/new_tab,view-source:\" + CurrentTab.URI)\n\t\t}\n\tcase tcell.KeyCtrlW:\n\t\tremoveTab(CurrentTab.ID)\n\tcase tcell.KeyBackspace, tcell.KeyBackspace2:\n\t\tif activeInputBox == nil {\n\t\t\tsendMessageToWebExtension(\"/tab_command,/history_back\")\n\t\t}\n\t}\n\tif ev.Rune() == 'm' && ev.Modifiers() == 4 {\n\t\ttoggleMonochromeMode()\n\t}\n\tif ev.Key() == 279 && ev.Modifiers() == 0 {\n\t\t// F1 key\n\t\topenHelpTab()\n\t}\n\tif isKey(\"tty.keys.next-tab\", ev) {\n\t\tnextTab()\n\t}\n\tif !urlInputBox.isActive {\n\t\tforwardKeyPress(ev)\n\t}\n\tif activeInputBox != nil {\n\t\thandleInputBoxInput(ev)\n\t} else {\n\t\thandleScrolling(ev) // TODO: shouldn't you be able to still use mouse scrolling?\n\t}\n}\n\nfunc isKey(userKey string, ev *tcell.EventKey) bool {\n\tkey := viper.GetStringSlice(userKey)\n\truneMatch := []rune(key[0])[0] == ev.Rune()\n\tintKey, _ := strconv.Atoi(key[1])\n\tkeyCodeMatch := intKey == int(ev.Key())\n\tmodifierKey, _ := strconv.Atoi(key[2])\n\tmodifierMatch := modifierKey == int(ev.Modifiers())\n\treturn runeMatch && keyCodeMatch && modifierMatch\n}\n\nfunc quitBrowsh() {\n\tif !viper.GetBool(\"firefox.use-existing\") {\n\t\tquitFirefox()\n\t}\n\tShutdown(errNormalExit)\n}\n\nfunc toggleMonochromeMode() {\n\tIsMonochromeMode = !IsMonochromeMode\n}\n\nfunc openHelpTab() {\n\tsendMessageToWebExtension(\"/new_tab,https://www.brow.sh/docs/introduction/\")\n}\n\nfunc forwardKeyPress(ev *tcell.EventKey) {\n\tif isMultiLineEnter(ev) {\n\t\treturn\n\t}\n\teventMap := map[string]interface{}{\n\t\t\"key\":  int(ev.Key()),\n\t\t\"char\": string(ev.Rune()),\n\t\t\"mod\":  int(ev.Modifiers()),\n\t}\n\tmarshalled, _ := json.Marshal(eventMap)\n\tsendMessageToWebExtension(\"/stdin,\" + string(marshalled))\n}\n\n// Allow user to use ENTER key without triggering submission on multiline input\n// boxes.\nfunc isMultiLineEnter(ev *tcell.EventKey) bool {\n\tif activeInputBox == nil {\n\t\treturn false\n\t}\n\treturn activeInputBox.isMultiLine() && ev.Key() == 13 && ev.Modifiers() != 4\n}\n\nfunc handleScrolling(ev *tcell.EventKey) {\n\tyScrollOriginal := CurrentTab.frame.yScroll\n\t_, height := screen.Size()\n\theight -= uiHeight\n\tif ev.Key() == tcell.KeyUp {\n\t\tCurrentTab.frame.yScroll -= 2\n\t}\n\tif ev.Key() == tcell.KeyDown {\n\t\tCurrentTab.frame.yScroll += 2\n\t}\n\tif ev.Key() == tcell.KeyPgUp {\n\t\tCurrentTab.frame.yScroll -= height\n\t}\n\tif ev.Key() == tcell.KeyPgDn {\n\t\tCurrentTab.frame.yScroll += height\n\t}\n\tCurrentTab.frame.limitScroll(height)\n\tsendMessageToWebExtension(\n\t\tfmt.Sprintf(\n\t\t\t\"/tab_command,/scroll_status,%d,%d\",\n\t\t\tCurrentTab.frame.xScroll,\n\t\t\tCurrentTab.frame.yScroll*2))\n\tif CurrentTab.frame.yScroll != yScrollOriginal {\n\t\trenderCurrentTabWindow()\n\t}\n}\n\nfunc handleMouseEvent(ev *tcell.EventMouse) {\n\tif CurrentTab == nil {\n\t\treturn\n\t}\n\tx, y := ev.Position()\n\txInFrame := x + CurrentTab.frame.xScroll\n\tyInFrame := y - uiHeight + CurrentTab.frame.yScroll\n\tbutton := ev.Buttons()\n\tif button == tcell.WheelUp || button == tcell.WheelDown {\n\t\thandleMouseScroll(button)\n\t}\n\tif button == 1 {\n\t\tCurrentTab.frame.maybeFocusInputBox(xInFrame, yInFrame)\n\t}\n\teventMap := map[string]interface{}{\n\t\t\"button\":    int(button),\n\t\t\"mouse_x\":   int(xInFrame),\n\t\t\"mouse_y\":   int(yInFrame),\n\t\t\"modifiers\": int(ev.Modifiers()),\n\t}\n\tmarshalled, _ := json.Marshal(eventMap)\n\tsendMessageToWebExtension(\"/stdin,\" + string(marshalled))\n}\n\nfunc handleMouseScroll(scrollType tcell.ButtonMask) {\n\tyScrollOriginal := CurrentTab.frame.yScroll\n\t_, height := screen.Size()\n\theight -= uiHeight\n\tif scrollType == tcell.WheelUp {\n\t\tCurrentTab.frame.yScroll -= 1\n\t} else if scrollType == tcell.WheelDown {\n\t\tCurrentTab.frame.yScroll += 1\n\t}\n\tCurrentTab.frame.limitScroll(height)\n\tsendMessageToWebExtension(\n\t\tfmt.Sprintf(\n\t\t\t\"/tab_command,/scroll_status,%d,%d\",\n\t\t\tCurrentTab.frame.xScroll,\n\t\t\tCurrentTab.frame.yScroll*2))\n\tif CurrentTab.frame.yScroll != yScrollOriginal {\n\t\trenderCurrentTabWindow()\n\t}\n}\n\nfunc handleTTYResize() {\n\twidth, _ := screen.Size()\n\turlInputBox.Width = width\n\tscreen.Sync()\n\tsendTtySize()\n}\n\n// Tcell uses a buffer to collect screen updates on, it only actually sends\n// ANSI rendering commands to the terminal when we tell it to. And even then it\n// will try to minimise rendering commands by only rendering parts of the terminal\n// that have changed.\nfunc renderCurrentTabWindow() {\n\tvar currentCell cell\n\tstyling := tcell.StyleDefault\n\tvar runeChars []rune\n\twidth, height := screen.Size()\n\tif CurrentTab == nil || CurrentTab.frame.cells == nil {\n\t\treturn\n\t}\n\tCurrentTab.frame.overlayInputBoxContent()\n\tfor y := 0; y < height-uiHeight; y++ {\n\t\tfor x := 0; x < width; x++ {\n\t\t\tcurrentCell = getCell(x, y)\n\t\t\truneChars = currentCell.character\n\t\t\t// TODO: do this is in isCharacterTransparent()\n\t\t\tif len(runeChars) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif IsMonochromeMode {\n\t\t\t\tstyling = styling.Foreground(tcell.ColorWhite)\n\t\t\t\tstyling = styling.Background(tcell.ColorBlack)\n\t\t\t\tif runeChars[0] == '▄' {\n\t\t\t\t\truneChars[0] = ' '\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tstyling = styling.Foreground(currentCell.fgColour)\n\t\t\t\tstyling = styling.Background(currentCell.bgColour)\n\t\t\t}\n\t\t\tscreen.SetCell(x, y+uiHeight, styling, runeChars[0])\n\t\t}\n\t}\n\tif activeInputBox != nil {\n\t\tactiveInputBox.renderCursor()\n\t}\n\toverlayPageStatusMessage()\n\toverlayCallToSupport()\n\tscreen.Show()\n}\n\nfunc getCell(x, y int) cell {\n\tvar currentCell cell\n\tvar ok bool\n\tframe := &CurrentTab.frame\n\tindex := ((y + frame.yScroll) * frame.totalWidth) + (x + frame.xScroll)\n\tif currentCell, ok = frame.cells.load(index); !ok {\n\t\tfgColour, bgColour := getHatchedCellColours(x)\n\t\tcurrentCell = cell{\n\t\t\tfgColour:  fgColour,\n\t\t\tbgColour:  bgColour,\n\t\t\tcharacter: []rune(\"▄\"),\n\t\t}\n\t}\n\treturn currentCell\n}\n\nfunc getHatchedCellColours(x int) (tcell.Color, tcell.Color) {\n\tvar bgColour, fgColour tcell.Color\n\tif x%2 == 0 {\n\t\tbgColour = tcell.NewHexColor(0xa9a9a9)\n\t\tfgColour = tcell.NewHexColor(0x797979)\n\t} else {\n\t\tbgColour = tcell.NewHexColor(0x797979)\n\t\tfgColour = tcell.NewHexColor(0xa9a9a9)\n\t}\n\treturn fgColour, bgColour\n}\n"
  },
  {
    "path": "interfacer/src/browsh/ui.go",
    "content": "package browsh\n\nimport (\n\t\"log/slog\"\n\n\t\"github.com/gdamore/tcell\"\n\t\"github.com/spf13/viper\"\n)\n\nvar urlInputBox = inputBox{\n\tX:        0,\n\tY:        1,\n\tHeight:   1,\n\ttext:     nil,\n\tFgColour: [3]int32{255, 255, 255},\n\tbgColour: [3]int32{-1, -1, -1},\n}\n\n// Render tabs, URL bar, status messages, etc\nfunc renderUI() {\n\trenderTabs()\n\trenderURLBar()\n}\n\n// Write a simple text string to the screen.\n// Not for use in the browser frames themselves. If you want anything to appear in\n// the browser that must be done through the webextension.\nfunc writeString(x, y int, str string, style tcell.Style) {\n\txOriginal := x\n\tif viper.GetBool(\"http-server-mode\") {\n\t\tslog.Info(str)\n\t\treturn\n\t}\n\tfor _, c := range str {\n\t\tif string(c) == \"\\n\" {\n\t\t\ty++\n\t\t\tx = xOriginal\n\t\t\tcontinue\n\t\t}\n\t\tscreen.SetCell(x, y, style, c)\n\t\tx++\n\t}\n}\n\nfunc fillLineToEnd(x, y int) {\n\twidth, _ := screen.Size()\n\tfor i := x; i < width-1; i++ {\n\t\twriteString(i, y, \" \", tcell.StyleDefault)\n\t}\n}\n\nfunc renderTabs() {\n\tvar tab *tab\n\tvar style tcell.Style\n\tcount := 0\n\txPosition := 0\n\ttabTitleLength := 20\n\tfor _, tabID := range tabsOrder {\n\t\ttab = Tabs[tabID]\n\t\ttabTitle := []rune(tab.Title)\n\t\ttabTitleContent := string(tabTitle[0:tabTitleLength])\n\t\tstyle = tcell.StyleDefault\n\t\tif CurrentTab.ID == tabID {\n\t\t\tstyle = tcell.StyleDefault.Reverse(true)\n\t\t}\n\t\twriteString(xPosition, 0, tabTitleContent, style)\n\t\tstyle = tcell.StyleDefault.Reverse(false)\n\t\tcount++\n\t\txPosition = count * (tabTitleLength + 1)\n\t\twriteString(xPosition-1, 0, \"|\", style)\n\t}\n\tfillLineToEnd(xPosition, 0)\n}\n\nfunc renderURLBar() {\n\tvar content []rune\n\tif urlInputBox.isActive {\n\t\twriteString(0, 1, string(content), tcell.StyleDefault)\n\t\tcontent = append(urlInputBox.text, ' ')\n\t\turlInputBox.renderURLBox()\n\t} else {\n\t\tcontent = []rune(CurrentTab.URI)\n\t\twriteString(0, 1, string(content), tcell.StyleDefault)\n\t}\n\tfillLineToEnd(len(content), 1)\n}\n\nfunc urlBarFocusToggle() {\n\tif urlInputBox.isActive {\n\t\turlBarFocus(false)\n\t} else {\n\t\turlBarFocus(true)\n\t}\n}\n\nfunc urlBarFocus(on bool) {\n\tif !on {\n\t\tactiveInputBox = nil\n\t\turlInputBox.isActive = false\n\t\turlInputBox.selectionOff()\n\t} else {\n\t\tactiveInputBox = &urlInputBox\n\t\turlInputBox.isActive = true\n\t\turlInputBox.xScroll = 0\n\t\turlInputBox.text = []rune(CurrentTab.URI)\n\t\turlInputBox.putCursorAtEnd()\n\t\turlInputBox.selectAll()\n\t}\n}\n\nfunc overlayPageStatusMessage() {\n\t_, height := screen.Size()\n\twriteString(0, height-1, CurrentTab.StatusMessage, tcell.StyleDefault)\n}\n\nfunc overlayCallToSupport() {\n\tvar right int\n\tvar message string\n\tif viper.GetString(\"browsh_supporter\") == \"I have shown my support for Browsh\" {\n\t\treturn\n\t}\n\twidth, height := screen.Size()\n\tmessage = \" Unsupported version\"\n\tright = width - len(message)\n\twriteString(right, height-2, message, tcell.StyleDefault)\n\tmessage = \"  See brow.sh/donate\"\n\tright = width - len(message)\n\twriteString(right, height-1, message, tcell.StyleDefault)\n}\n\nfunc reverseCellColour(x, y int) {\n\tmainRune, combiningRunes, style, _ := screen.GetContent(x, y)\n\tstyle = style.Reverse(true)\n\tscreen.SetContent(x, y, mainRune, combiningRunes, style)\n}\n"
  },
  {
    "path": "interfacer/src/browsh/unit_test.go",
    "content": "package browsh\n\nimport (\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo\"\n\t. \"github.com/onsi/gomega\"\n)\n\nfunc TestBrowshUnits(t *testing.T) {\n\tRegisterFailHandler(Fail)\n\tRunSpecs(t, \"Unit test\")\n}\n"
  },
  {
    "path": "interfacer/src/browsh/version.go",
    "content": "package browsh\n\nvar browshVersion = \"1.8.2\"\n"
  },
  {
    "path": "interfacer/test/http-server/server_test.go",
    "content": "package test\n\nimport (\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo\"\n\t. \"github.com/onsi/gomega\"\n\t\"github.com/spf13/viper\"\n)\n\nfunc TestHTTPServer(t *testing.T) {\n\tRegisterFailHandler(Fail)\n\tRunSpecs(t, \"HTTP Server tests\")\n}\n\nvar _ = Describe(\"HTTP Server\", func() {\n\tIt(\"should return plain text\", func() {\n\t\tresponse := getPath(\"/smorgasbord\", \"plain\")\n\t\tExpect(response).To(ContainSubstring(\"smörgåsbord\"))\n\t\tExpect(response).ToNot(ContainSubstring(\"<a href\"))\n\t})\n\n\tIt(\"should return HTML text\", func() {\n\t\tresponse := getPath(\"/smorgasbord\", \"html\")\n\t\tExpect(response).To(ContainSubstring(\n\t\t\t\"<a href=\\\"/http://localhost:4444/smorgasbord/another.html\\\">\"))\n\t})\n\n\tIt(\"should return the DOM\", func() {\n\t\tresponse := getPath(\"/smorgasbord\", \"dom\")\n\t\tExpect(response).To(ContainSubstring(\n\t\t\t\"<div class=\\\"big_middle\\\">\"))\n\t})\n\n\tIt(\"should return a background image\", func() {\n\t\tresponse := getPath(\"/smorgasbord\", \"html\")\n\t\tExpect(response).To(ContainSubstring(\"background-image: url(data:image/jpeg\"))\n\t})\n\n\tIt(\"should block specified domains\", func() {\n\t\tviper.Set(\n\t\t\t\"http-server.blocked-domains\",\n\t\t\t[]string{\"[mail|accounts].google.com\", \"other\"},\n\t\t)\n\t\turl := getBrowshServiceBase() + \"/mail.google.com\"\n\t\tclient := &http.Client{}\n\t\trequest, _ := http.NewRequest(\"GET\", url, nil)\n\t\tresponse, _ := client.Do(request)\n\t\tcontents, _ := ioutil.ReadAll(response.Body)\n\t\tExpect(string(contents)).To(ContainSubstring(\"Welcome to the Browsh HTML\"))\n\t})\n\n\tIt(\"should block specified user agents\", func() {\n\t\tviper.Set(\n\t\t\t\"http-server.blocked-user-agents\",\n\t\t\t[]string{\"MJ12bot\", \"other\"},\n\t\t)\n\t\turl := getBrowshServiceBase() + \"/example.com\"\n\t\tclient := &http.Client{}\n\t\trequest, _ := http.NewRequest(\"GET\", url, nil)\n\t\trequest.Header.Add(\"User-Agent\", \"Blah blah MJ12bot etc\")\n\t\tresponse, _ := client.Do(request)\n\t\tExpect(response.StatusCode).To(Equal(403))\n\t})\n\n\tIt(\"should allow a blocked user agent to see the home page\", func() {\n\t\tviper.Set(\n\t\t\t\"http-server.blocked-user-agents\",\n\t\t\t[]string{\"MJ12bot\", \"other\"},\n\t\t)\n\t\turl := getBrowshServiceBase()\n\t\tclient := &http.Client{}\n\t\trequest, _ := http.NewRequest(\"GET\", url, nil)\n\t\trequest.Header.Add(\"User-Agent\", \"Blah blah MJ12bot etc\")\n\t\tresponse, _ := client.Do(request)\n\t\tExpect(response.StatusCode).To(Equal(200))\n\t})\n})\n"
  },
  {
    "path": "interfacer/test/http-server/setup.go",
    "content": "package test\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/browsh-org/browsh/interfacer/src/browsh\"\n\tginkgo \"github.com/onsi/ginkgo\"\n\t\"github.com/spf13/viper\"\n)\n\nvar (\n\tstaticFileServerPort = \"4444\"\n\trootDir              = browsh.Shell(\"git rev-parse --show-toplevel\")\n)\n\nfunc startStaticFileServer() {\n\tserverMux := http.NewServeMux()\n\tserverMux.Handle(\"/\", http.FileServer(http.Dir(rootDir+\"/interfacer/test/sites\")))\n\thttp.ListenAndServe(\":\"+staticFileServerPort, serverMux)\n}\n\nfunc initBrowsh() {\n\tbrowsh.IsTesting = true\n\tbrowsh.Initialise()\n\tviper.Set(\"http-server-mode\", true)\n}\n\nfunc waitUntilConnectedToWebExtension(maxTime time.Duration) {\n\tstart := time.Now()\n\tfor time.Since(start) < maxTime {\n\t\tif browsh.IsConnectedToWebExtension {\n\t\t\treturn\n\t\t}\n\t\ttime.Sleep(50 * time.Millisecond)\n\t}\n\tpanic(\"Didn't connect to webextension in time\")\n}\n\nfunc getBrowshServiceBase() string {\n\treturn \"http://localhost:\" + viper.GetString(\"http-server.port\")\n}\n\nfunc getPath(path string, mode string) string {\n\tbrowshServiceBase := getBrowshServiceBase()\n\tstaticFileServerBase := \"http://localhost:\" + staticFileServerPort\n\tfullBase := browshServiceBase + \"/\" + staticFileServerBase\n\tclient := &http.Client{}\n\trequest, err := http.NewRequest(\"GET\", fullBase+path, nil)\n\tif mode == \"plain\" {\n\t\trequest.Header.Add(\"X-Browsh-Raw-Mode\", \"PLAIN\")\n\t}\n\tif mode == \"dom\" {\n\t\trequest.Header.Add(\"X-Browsh-Raw-Mode\", \"DOM\")\n\t}\n\tresponse, err := client.Do(request)\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"%s\", err))\n\t} else {\n\t\tdefer response.Body.Close()\n\t\tcontents, err := io.ReadAll(response.Body)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"%s\", err)\n\t\t\tpanic(fmt.Sprintf(\"%s\", err))\n\t\t}\n\t\treturn string(contents)\n\t}\n}\n\nfunc stopFirefox() {\n\tbrowsh.IsConnectedToWebExtension = false\n\tbrowsh.Shell(rootDir + \"/webext/contrib/firefoxheadless.sh kill\")\n\ttime.Sleep(500 * time.Millisecond)\n}\n\nvar _ = ginkgo.BeforeEach(func() {\n\tbrowsh.ResetTabs()\n\twaitUntilConnectedToWebExtension(15 * time.Second)\n\tbrowsh.IsMonochromeMode = false\n\tslog.Info(\"\\n---------\")\n\tslog.Info(ginkgo.CurrentGinkgoTestDescription().FullTestText)\n\tslog.Info(\"---------\")\n})\n\nvar _ = ginkgo.BeforeSuite(func() {\n\tinitBrowsh()\n\tstopFirefox()\n\tgo startStaticFileServer()\n\tgo browsh.HTTPServerStart()\n\ttime.Sleep(1 * time.Second)\n})\n\nvar _ = ginkgo.AfterSuite(func() {\n\tstopFirefox()\n})\n"
  },
  {
    "path": "interfacer/test/sites/smorgasbord/another.html",
    "content": "<html>\n  <head>\n    <meta charset=\"utf-8\">\n    <title>Another</title>\n  </head>\n  <body>\n    Another webpage\n  </body>\n</html>\n\n"
  },
  {
    "path": "interfacer/test/sites/smorgasbord/css/main.css",
    "content": "#content {\n  width: 500px;\n  margin: auto;\n}\n\nh1 {\n  text-align: center;\n}\n\n.left_col {\n  width: 45%;\n  float: left;\n}\n\n.right_col {\n  width: 45%;\n  float: right;\n}\n\n.big_middle {\n  clear: both;\n}\n"
  },
  {
    "path": "interfacer/test/sites/smorgasbord/css/spinner.css",
    "content": "/* Animation */\n@-webkit-keyframes spinner {\n  to { -webkit-transform: rotate(360deg); }\n}\n@-moz-keyframes spinner {\n  to { -moz-transform: rotate(360deg); }\n}\n@-ms-keyframes spinner {\n  to { -ms-transform: rotate(360deg); }\n}\n@keyframes spinner {\n  to { transform: rotate(360deg); }\n}\n\n/* Loader (*/\n#spinner {\n  margin: auto;\n  width: 100px;\n  height: 100px;\n  border-radius: 50%;\n\n  background-image: linear-gradient(bottom, #FF00FF 50%, #00FFFF 50%);\n  background-image: -o-linear-gradient(bottom, #FF00FF 50%, #00FFFF 50%);\n  background-image: -moz-linear-gradient(bottom, #FF00FF 50%, #00FFFF 50%);\n  background-image: -webkit-linear-gradient(bottom, #FF00FF 50%, #00FFFF 50%);\n  background-image: -ms-linear-gradient(bottom, #FF00FF 50%, #00FFFF 50%);\n\n  -webkit-animation: spinner 2s infinite linear;\n  -moz-animation: spinner 2s infinite linear;\n  -ms-animation: spinner 2s infinite linear;\n  animation: spinner 2s infinite linear;\n}\n"
  },
  {
    "path": "interfacer/test/sites/smorgasbord/index.html",
    "content": "<html>\n  <head>\n    <meta charset=\"utf-8\">\n    <title>Smörgåsbord</title>\n    <link href=\"css/main.css\" rel=\"stylesheet\" type=\"text/css\" />\n    <link href=\"css/spinner.css\" rel=\"stylesheet\" type=\"text/css\" />\n    <script type=\"text/javascript\">\n      window.onload = function(e){\n        document.getElementById(\"test_form\").addEventListener(\"submit\", function(e){\n          e.preventDefault();\n          let text = document.getElementById(\"test_form_input\").value;\n          text = text.split(\"\").reverse().join(\"\");\n          document.getElementById(\"form_result\").textContent = text;\n        });\n      }\n    </script>\n  </head>\n  <body>\n    <div id=\"content\">\n      <div id=\"spinner\"></div>\n      <h1>Smörgåsbord</h1>\n      <div class=\"left_col\">\n        Smörgåsbord (Swedish: [ˈsmœrɡɔsˌbuːɖ]) is a type of Scandinavian meal,\n        originating in Sweden, served buffet-style with multiple hot and cold\n        dishes of various foods on a table.\n        <p>\n          <a href=\"another.html\">Another page</a>\n        </p>\n        <p>\n          <form id=\"test_form\">\n            <input type=\"text\" id=\"test_form_input\" size=\"15\">\n            <input type=\"submit\">\n          </form>\n        </p>\n        <p id=\"form_result\">Unsubmitted</p>\n      </div>\n      <div class=\"right_col\">\n        The <a href=\"/\">Swedish</a> word smörgåsbord consists of the words smörgås (sandwich,\n        usually open-faced) and bord (table). Smörgås in turn consists of the\n        words smör (butter, cognate with English smear) and gås. Gås literally\n        means goose, but later referred to the small pieces of butter that\n        formed and floated to the surface of cream while it was churned.\n      </div>\n\n      <div class=\"big_middle\">\n        A special Swedish type of smörgåsbord is the julbord (literally \"Christmas table\").\n        The classic Swedish julbord is central to traditional Swedish cuisine, often including\n        bread dipped in ham broth and continuing with a variety of fish (salmon, herring,\n        whitefish and eel), baked ham, meatballs, pork ribs, head cheese, sausages, potato,\n        Janssons frestelse, boiled potatoes, cheeses, beetroot salad, various forms of boiled\n        cabbage, kale and rice pudding.\n\n        It is customary to eat particular foods together; herring is typically eaten\n        with boiled potatoes and hard-boiled eggs and is frequently accompanied by strong\n        spirits like snaps, brännvin or akvavit with or without spices. Other traditional\n        foods are smoked eel, rollmops, herring salad, baked herring and smoked salmon.\n      </div>\n    </div>\n  </body>\n</html>\n"
  },
  {
    "path": "interfacer/test/sites/smorgasbord/textarea.html",
    "content": "<html>\n  <head>\n    <meta charset=\"utf-8\">\n    <title>Another</title>\n  </head>\n  <body>\n    <textarea rows=\"3\"></textarea>\n  </body>\n</html>\n\n"
  },
  {
    "path": "interfacer/test/tty/matchers.go",
    "content": "package test\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\tgomegaTypes \"github.com/onsi/gomega/types\"\n)\n\n// BeInFrameAt is a custom matcher that looks for the expected text at the given\n// coordinates.\nfunc BeInFrameAt(x, y int) gomegaTypes.GomegaMatcher {\n\treturn &textInFrameMatcher{\n\t\tx:     x,\n\t\ty:     y,\n\t\tfound: \"\",\n\t}\n}\n\ntype textInFrameMatcher struct {\n\tx     int\n\ty     int\n\tfound string\n}\n\nfunc (matcher *textInFrameMatcher) Match(actual interface{}) (success bool, err error) {\n\ttext, _ := actual.(string)\n\tstart := time.Now()\n\tfor time.Since(start) < perTestTimeout {\n\t\tmatcher.found = GetText(matcher.x, matcher.y, runeCount(text))\n\t\tif matcher.found == text {\n\t\t\treturn true, nil\n\t\t}\n\t\ttime.Sleep(100 * time.Millisecond)\n\t}\n\treturn false, fmt.Errorf(\"Timeout. Expected\\n\\t%#v\\nto be in the Browsh frame, but found\\n\\t%#v\", text, matcher.found)\n}\n\nfunc (matcher *textInFrameMatcher) FailureMessage(text interface{}) (message string) {\n\treturn fmt.Sprintf(\"Expected\\n\\t%#v\\nto equal\\n\\t%#v\", text, matcher.found)\n}\n\nfunc (matcher *textInFrameMatcher) NegatedFailureMessage(text interface{}) (message string) {\n\treturn fmt.Sprintf(\"Expected\\n\\t%#v\\nnot to equal of\\n\\t%#v\", text, matcher.found)\n}\n"
  },
  {
    "path": "interfacer/test/tty/setup.go",
    "content": "package test\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/browsh-org/browsh/interfacer/src/browsh\"\n\t\"github.com/gdamore/tcell\"\n\t\"github.com/gdamore/tcell/terminfo\"\n\tginkgo \"github.com/onsi/ginkgo\"\n\tgomega \"github.com/onsi/gomega\"\n\t\"github.com/spf13/viper\"\n)\n\nvar (\n\tstaticFileServerPort = \"4444\"\n\tsimScreen            tcell.SimulationScreen\n\tstartupWait          = 60 * time.Second\n\tperTestTimeout       = 2000 * time.Millisecond\n\trootDir              = browsh.Shell(\"git rev-parse --show-toplevel\")\n\ttestSiteURL          = \"http://localhost:\" + staticFileServerPort\n\tti                   *terminfo.Terminfo\n\tframesLogFileName    string\n\tframeLogger          *slog.Logger\n)\n\nfunc init() {\n\tdir, err := os.Getwd()\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tframesLogFileName = fmt.Sprintf(\"%s\", filepath.Join(dir, \"frames.log\"))\n\tframesLogFile, err := os.OpenFile(framesLogFileName,\n\t\tos.O_CREATE|os.O_TRUNC|os.O_WRONLY,\n\t\t0o644,\n\t)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tframeLogger = slog.New(slog.NewTextHandler(framesLogFile, nil))\n}\n\nfunc initTerm() {\n\t// The tests check for true colour RGB values. The only downside to forcing true colour\n\t// in tests is that snapshots of frames with true colour ANSI codes are output to logs.\n\t// Some people may not have true colour terminals, for example like on Travis, so cat'ing\n\t// logs may appear corrupt.\n\tti, _ = terminfo.LookupTerminfo(\"xterm-truecolor\")\n}\n\n// GetFrame returns the current Browsh frame's text\nfunc GetFrame() string {\n\tvar frame, log string\n\tline := 0\n\tstyleDefault := ti.TParm(ti.SetFgBg, int(tcell.ColorWhite), int(tcell.ColorBlack))\n\twidth, _ := simScreen.Size()\n\tcells, _, _ := simScreen.GetContents()\n\tfor _, element := range cells {\n\t\tline++\n\t\tframe += string(element.Runes)\n\t\tlog += elementColourForTTY(element) + string(element.Runes)\n\t\tif line == width {\n\t\t\tframe += \"\\n\"\n\t\t\tlog += styleDefault + \"\\n\"\n\t\t\tline = 0\n\t\t}\n\t}\n\tframeLogger.Info(\"================================================\")\n\tframeLogger.Info(ginkgo.CurrentGinkgoTestDescription().FullTestText)\n\tframeLogger.Info(\"================================================\\n\")\n\treturn frame\n}\n\n// Trigger the key definition specified by name\nfunc triggerUserKeyFor(name string) {\n\tkey := viper.GetStringSlice(name)\n\tintKey, _ := strconv.Atoi(key[1])\n\tmodifierKey, _ := strconv.Atoi(key[2])\n\tsimScreen.InjectKey(tcell.Key(intKey), []rune(key[0])[0], tcell.ModMask(modifierKey))\n}\n\n// SpecialKey injects a special key into the TTY. See Tcell's `keys.go` file for all\n// the available special keys.\nfunc SpecialKey(key tcell.Key) {\n\tsimScreen.InjectKey(key, 0, tcell.ModNone)\n\ttime.Sleep(100 * time.Millisecond)\n}\n\n// Keyboard types a string of keys into the TTY, as if a user would\nfunc Keyboard(keys string) {\n\tfor _, char := range keys {\n\t\tsimScreen.InjectKey(tcell.KeyRune, char, tcell.ModNone)\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// SpecialMouse injects a special mouse event into the TTY. See Tcell's `mouse.go` file for all\n// the available special mouse values.\nfunc SpecialMouse(mouse tcell.ButtonMask) {\n\tsimScreen.InjectMouse(0, 0, mouse, tcell.ModNone)\n\ttime.Sleep(100 * time.Millisecond)\n}\n\nfunc waitForNextFrame() {\n\t// Need to wait so long because the frame rate is currently so slow\n\t// TODO: Reduce the wait when the FPS is higher\n\ttime.Sleep(250 * time.Millisecond)\n}\n\n// WaitForText waits for a particular string at particular position in the frame\nfunc WaitForText(text string, x, y int) {\n\tvar found string\n\tstart := time.Now()\n\tfor time.Since(start) < perTestTimeout {\n\t\tfound = GetText(x, y, runeCount(text))\n\t\tif found == text {\n\t\t\treturn\n\t\t}\n\t\ttime.Sleep(100 * time.Millisecond)\n\t}\n\tpanic(\"Waiting for '\" + text + \"' to appear but it didn't\")\n}\n\n// WaitForPageLoad waits for the page to load\nfunc WaitForPageLoad() {\n\tsleepUntilPageLoad(perTestTimeout)\n}\n\nfunc sleepUntilPageLoad(maxTime time.Duration) {\n\tstart := time.Now()\n\ttime.Sleep(1000 * time.Millisecond)\n\tfor time.Since(start) < maxTime {\n\t\tif browsh.CurrentTab != nil {\n\t\t\tif browsh.CurrentTab.PageState == \"parsing_complete\" {\n\t\t\t\ttime.Sleep(200 * time.Millisecond)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\ttime.Sleep(50 * time.Millisecond)\n\t}\n\tpanic(\"Page didn't load within timeout\")\n}\n\n// GotoURL sends the browsh browser to the specified URL\nfunc GotoURL(url string) {\n\tSpecialKey(tcell.KeyCtrlL)\n\tKeyboard(url)\n\tSpecialKey(tcell.KeyEnter)\n\tWaitForPageLoad()\n\t// TODO: Looking for the URL isn't optimal because it could be the same URL\n\t// as the previous test.\n\tgomega.Expect(url).To(BeInFrameAt(0, 1))\n\t// TODO: hack to work around bug where text sometimes doesn't render on page load.\n\t// Clicking with the mouse triggers a reparse by the web extension\n\tmouseClick(3, 6)\n\ttime.Sleep(100 * time.Millisecond)\n\tmouseClick(3, 6)\n\ttime.Sleep(500 * time.Millisecond)\n}\n\nfunc mouseClick(x, y int) {\n\tsimScreen.InjectMouse(x, y, 1, tcell.ModNone)\n\tsimScreen.InjectMouse(x, y, 0, tcell.ModNone)\n}\n\nfunc elementColourForTTY(element tcell.SimCell) string {\n\tvar fg, bg tcell.Color\n\tfg, bg, _ = element.Style.Decompose()\n\tr1, g1, b1 := fg.RGB()\n\tr2, g2, b2 := bg.RGB()\n\treturn ti.TParm(ti.SetFgBgRGB,\n\t\tint(r1), int(g1), int(b1),\n\t\tint(r2), int(g2), int(b2))\n}\n\n// GetText retruns an individual piece of a frame\nfunc GetText(x, y, length int) string {\n\tvar text string\n\tframe := []rune(GetFrame())\n\twidth, _ := simScreen.Size()\n\tindex := ((width + 1) * y) + x\n\tfor {\n\t\ttext += string(frame[index])\n\t\tindex++\n\t\tif runeCount(text) == length {\n\t\t\tbreak\n\t\t}\n\t}\n\treturn text\n}\n\n// GetFgColour returns the foreground colour of a single cell\nfunc GetFgColour(x, y int) [3]int32 {\n\tGetFrame()\n\tcells, _, _ := simScreen.GetContents()\n\twidth, _ := simScreen.Size()\n\tindex := (width * y) + x\n\tfg, _, _ := cells[index].Style.Decompose()\n\tr1, g1, b1 := fg.RGB()\n\treturn [3]int32{r1, g1, b1}\n}\n\n// GetBgColour returns the background colour of a single cell\nfunc GetBgColour(x, y int) [3]int32 {\n\tGetFrame()\n\tcells, _, _ := simScreen.GetContents()\n\twidth, _ := simScreen.Size()\n\tindex := (width * y) + x\n\t_, bg, _ := cells[index].Style.Decompose()\n\tr1, g1, b1 := bg.RGB()\n\treturn [3]int32{r1, g1, b1}\n}\n\nfunc ensureOnlyOneTab() {\n\tif len(browsh.Tabs) > 1 {\n\t\tSpecialKey(tcell.KeyCtrlW)\n\t}\n}\n\nfunc startStaticFileServer() {\n\tserverMux := http.NewServeMux()\n\tserverMux.Handle(\"/\", http.FileServer(http.Dir(rootDir+\"/interfacer/test/sites\")))\n\thttp.ListenAndServe(\":\"+staticFileServerPort, serverMux)\n}\n\nfunc initBrowsh() {\n\tbrowsh.IsTesting = true\n\tsimScreen = tcell.NewSimulationScreen(\"UTF-8\")\n\tbrowsh.Initialise()\n}\n\nfunc stopFirefox() {\n\tslog.Info(\"Attempting to kill all firefox processes\")\n\tbrowsh.IsConnectedToWebExtension = false\n\tbrowsh.Shell(rootDir + \"/webext/contrib/firefoxheadless.sh kill\")\n\ttime.Sleep(500 * time.Millisecond)\n}\n\nfunc runeCount(text string) int {\n\treturn utf8.RuneCountInString(text)\n}\n\nvar _ = ginkgo.BeforeEach(func() {\n\tslog.Info(\"Attempting to restart WER Firefox...\")\n\tstopFirefox()\n\tbrowsh.ResetTabs()\n\tbrowsh.StartFirefox()\n\tsleepUntilPageLoad(startupWait)\n\tbrowsh.IsMonochromeMode = false\n\tslog.Info(\"\\n---------\")\n\tslog.Info(ginkgo.CurrentGinkgoTestDescription().FullTestText)\n\tslog.Info(\"---------\")\n})\n\nvar _ = ginkgo.BeforeSuite(func() {\n\tos.Truncate(framesLogFileName, 0)\n\tinitTerm()\n\tinitBrowsh()\n\tstopFirefox()\n\tgo startStaticFileServer()\n\tgo browsh.TTYStart(simScreen)\n\t// Firefox seems to take longer to die after its first run\n\ttime.Sleep(500 * time.Millisecond)\n\tstopFirefox()\n\ttime.Sleep(5000 * time.Millisecond)\n})\n\nvar _ = ginkgo.AfterSuite(func() {\n\tstopFirefox()\n})\n"
  },
  {
    "path": "interfacer/test/tty/tty_test.go",
    "content": "package test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/gdamore/tcell\"\n\t. \"github.com/onsi/ginkgo\"\n\t. \"github.com/onsi/gomega\"\n)\n\nfunc TestIntegration(t *testing.T) {\n\tRegisterFailHandler(Fail)\n\tRunSpecs(t, \"Integration tests\")\n}\n\nvar _ = Describe(\"Showing a basic webpage\", func() {\n\tBeforeEach(func() {\n\t\tGotoURL(testSiteURL + \"/smorgasbord/\")\n\t})\n\n\tDescribe(\"Browser UI\", func() {\n\t\tIt(\"should have the page title and current URL\", func() {\n\t\t\tExpect(\"Smörgåsbord\").To(BeInFrameAt(0, 0))\n\t\t\tURL := testSiteURL + \"/smorgasbord/\"\n\t\t\tExpect(URL).To(BeInFrameAt(0, 1))\n\t\t})\n\n\t\tDescribe(\"Interaction\", func() {\n\t\t\tIt(\"should navigate to a new page by using the URL bar\", func() {\n\t\t\t\tSpecialKey(tcell.KeyCtrlL)\n\t\t\t\tKeyboard(testSiteURL + \"/smorgasbord/another.html\")\n\t\t\t\tSpecialKey(tcell.KeyEnter)\n\t\t\t\tExpect(\"Another\").To(BeInFrameAt(0, 0))\n\t\t\t})\n\n\t\t\tIt(\"should navigate to a new page by clicking a link\", func() {\n\t\t\t\tExpect(\"Another▄page\").To(BeInFrameAt(12, 18))\n\t\t\t\tmouseClick(12, 18)\n\t\t\t\tExpect(\"Another\").To(BeInFrameAt(0, 0))\n\t\t\t})\n\n\t\t\tIt(\"should scroll the page by one line using the mouse\", func() {\n\t\t\t\tSpecialMouse(tcell.WheelDown)\n\t\t\t\tSpecialMouse(tcell.WheelDown)\n\t\t\t\tExpect(\"meal,▄originating▄in▄\").To(BeInFrameAt(12, 11))\n\t\t\t})\n\n\t\t\tIt(\"should scroll the page by one line\", func() {\n\t\t\t\tSpecialKey(tcell.KeyDown)\n\t\t\t\tExpect(\"meal,▄originating▄in▄\").To(BeInFrameAt(12, 11))\n\t\t\t})\n\n\t\t\tIt(\"should scroll the page by one page\", func() {\n\t\t\t\tSpecialKey(tcell.KeyPgDn)\n\t\t\t\tExpect(\"continuing▄with▄a▄variety▄of▄fish\").To(BeInFrameAt(12, 13))\n\t\t\t})\n\n\t\t\tDescribe(\"Text Input\", func() {\n\t\t\t\tDescribe(\"Single line\", func() {\n\t\t\t\t\tBeforeEach(func() {\n\t\t\t\t\t\tSpecialKey(tcell.KeyDown)\n\t\t\t\t\t\tSpecialKey(tcell.KeyDown)\n\t\t\t\t\t\tsimScreen.InjectMouse(12, 16, tcell.Button1, tcell.ModNone)\n\t\t\t\t\t})\n\n\t\t\t\t\tIt(\"should have basic cursor movement\", func() {\n\t\t\t\t\t\tKeyboard(\"|||\")\n\t\t\t\t\t\tSpecialKey(tcell.KeyLeft)\n\t\t\t\t\t\tKeyboard(\"2\")\n\t\t\t\t\t\tSpecialKey(tcell.KeyLeft)\n\t\t\t\t\t\tSpecialKey(tcell.KeyLeft)\n\t\t\t\t\t\tKeyboard(\"1\")\n\t\t\t\t\t\tExpect(\"|1|2|\").To(BeInFrameAt(12, 16))\n\t\t\t\t\t})\n\n\t\t\t\t\tIt(\"should scroll single line boxes on overflow\", func() {\n\t\t\t\t\t\tKeyboard(\"12345678901234567890\")\n\t\t\t\t\t\tExpect(\"5678901234567890 \").To(BeInFrameAt(12, 16))\n\t\t\t\t\t})\n\n\t\t\t\t\tIt(\"should scroll overflowed boxes to the left and right\", func() {\n\t\t\t\t\t\tKeyboard(\"12345678901234567890\")\n\t\t\t\t\t\tfor i := 0; i < 19; i++ {\n\t\t\t\t\t\t\tSpecialKey(tcell.KeyLeft)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tExpect(\"23456789012345678\").To(BeInFrameAt(12, 16))\n\t\t\t\t\t\tfor i := 0; i < 19; i++ {\n\t\t\t\t\t\t\tSpecialKey(tcell.KeyRight)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tExpect(\"5678901234567890 \").To(BeInFrameAt(12, 16))\n\t\t\t\t\t})\n\n\t\t\t\t\tIt(\"should submit text into an input box\", func() {\n\t\t\t\t\t\tExpect(\"Unsubmitted\").To(BeInFrameAt(12, 19))\n\t\t\t\t\t\tKeyboard(\"Reverse Me!\")\n\t\t\t\t\t\tSpecialKey(tcell.KeyEnter)\n\t\t\t\t\t\tSkip(\"'Unsubmitted' remains. Is form submission broken?\")\n\t\t\t\t\t\tExpect(\"!eM▄esreveR\").To(BeInFrameAt(12, 19))\n\t\t\t\t\t})\n\t\t\t\t})\n\n\t\t\t\tDescribe(\"Multi line\", func() {\n\t\t\t\t\tBeforeEach(func() {\n\t\t\t\t\t\tGotoURL(testSiteURL + \"/smorgasbord/textarea.html\")\n\t\t\t\t\t\tmouseClick(2, 3)\n\t\t\t\t\t})\n\n\t\t\t\t\tIt(\"should enter multiple lines of text\", func() {\n\t\t\t\t\t\tKeyboard(`So here is a lot of text that will hopefully split across lines`)\n\t\t\t\t\t\tExpect(\"So here is a lot of\").To(BeInFrameAt(1, 3))\n\t\t\t\t\t\tExpect(\"text that will\").To(BeInFrameAt(1, 4))\n\t\t\t\t\t\tExpect(\"hopefully split across\").To(BeInFrameAt(1, 5))\n\t\t\t\t\t\tExpect(\"lines\").To(BeInFrameAt(1, 6))\n\t\t\t\t\t})\n\n\t\t\t\t\tIt(\"should scroll multiple lines of text\", func() {\n\t\t\t\t\t\tSkip(\"Maybe the ENTER key just isn't working?\")\n\t\t\t\t\t\tKeyboard(`So here is a lot of text that will hopefully split across lines`)\n\t\t\t\t\t\tSpecialKey(tcell.KeyEnter)\n\t\t\t\t\t\tKeyboard(`And here is even more filler, it's endless!`)\n\t\t\t\t\t\tExpect(\"filler, it's endless!\").To(BeInFrameAt(1, 6))\n\t\t\t\t\t\tfor i := 1; i <= 6; i++ {\n\t\t\t\t\t\t\tSpecialKey(tcell.KeyUp)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tExpect(\"lines\").To(BeInFrameAt(1, 6))\n\t\t\t\t\t})\n\t\t\t\t})\n\t\t\t})\n\n\t\t\tDescribe(\"Tabs\", func() {\n\t\t\t\tBeforeEach(func() {\n\t\t\t\t\tSpecialKey(tcell.KeyCtrlT)\n\t\t\t\t})\n\n\t\t\t\tAfterEach(func() {\n\t\t\t\t\tensureOnlyOneTab()\n\t\t\t\t})\n\n\t\t\t\tIt(\"should create a new tab\", func() {\n\t\t\t\t\tExpect(\"New Tab\").To(BeInFrameAt(21, 0))\n\n\t\t\t\t\t// HACK to prevent URL bar being focussed at the start of the next test.\n\t\t\t\t\t// TODO: Find a more consistent and abstracted way to ensure that the URL\n\t\t\t\t\t// bar is not focussed at the beginning of new tests.\n\t\t\t\t\tSpecialKey(tcell.KeyCtrlL)\n\t\t\t\t})\n\n\t\t\t\tIt(\"should be able to goto a new URL\", func() {\n\t\t\t\t\tKeyboard(testSiteURL + \"/smorgasbord/another.html\")\n\t\t\t\t\tSpecialKey(tcell.KeyEnter)\n\t\t\t\t\tExpect(\"Another\").To(BeInFrameAt(21, 0))\n\t\t\t\t})\n\n\t\t\t\tIt(\"should cycle to the next tab\", func() {\n\t\t\t\t\tExpect(\"                   \").To(BeInFrameAt(0, 1))\n\t\t\t\t\tSpecialKey(tcell.KeyCtrlL)\n\t\t\t\t\tGotoURL(testSiteURL + \"/smorgasbord/another.html\")\n\t\t\t\t\ttriggerUserKeyFor(\"tty.keys.next-tab\")\n\t\t\t\t\tURL := testSiteURL + \"/smorgasbord/             \"\n\t\t\t\t\tExpect(URL).To(BeInFrameAt(0, 1))\n\t\t\t\t})\n\t\t\t})\n\t\t})\n\t})\n\n\tDescribe(\"Rendering\", func() {\n\t\tIt(\"should reset page scroll to zero on page load\", func() {\n\t\t\tSpecialKey(tcell.KeyPgDn)\n\t\t\tExpect(\"continuing▄with▄a▄variety▄of▄fish\").To(BeInFrameAt(12, 13))\n\t\t\tGotoURL(testSiteURL + \"/smorgasbord/another.html\")\n\t\t\tExpect(\"Another▄webpage\").To(BeInFrameAt(1, 3))\n\t\t})\n\n\t\tIt(\"should render dynamic content\", func() {\n\t\t\tvar greens, pinks int\n\t\t\tvar colours [10][3]int32\n\t\t\tfor i := 0; i < 10; i++ {\n\t\t\t\tcolours[i] = GetFgColour(39, 3)\n\t\t\t\twaitForNextFrame()\n\t\t\t}\n\t\t\tfor i := 0; i < 10; i++ {\n\t\t\t\tif colours[i] == [3]int32{0, 255, 255} {\n\t\t\t\t\tgreens++\n\t\t\t\t}\n\t\t\t\tif colours[i] == [3]int32{255, 0, 255} {\n\t\t\t\t\tpinks++\n\t\t\t\t}\n\t\t\t}\n\t\t\tExpect(greens).To(BeNumerically(\">=\", 1))\n\t\t\tExpect(pinks).To(BeNumerically(\">=\", 1))\n\t\t})\n\n\t\tIt(\"should switch to monochrome mode\", func() {\n\t\t\tsimScreen.InjectKey(tcell.KeyRune, 'm', tcell.ModAlt)\n\t\t\twaitForNextFrame()\n\t\t\tExpect([3]int32{0, 0, 0}).To(Equal(GetBgColour(0, 2)))\n\t\t\tExpect([3]int32{255, 255, 255}).To(Equal(GetFgColour(12, 11)))\n\t\t})\n\n\t\tDescribe(\"Text positioning\", func() {\n\t\t\tIt(\"should position the left/right-aligned coloumns\", func() {\n\t\t\t\tExpect(\"Smörgåsbord▄(Swedish:\").To(BeInFrameAt(12, 10))\n\t\t\t\tExpect(\"The▄Swedish▄word\").To(BeInFrameAt(42, 10))\n\t\t\t})\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "scripts/bundling.bash",
    "content": "#!/usr/bin/env bash\n\nexport XPI_PATH=\"$PROJECT_ROOT\"/interfacer/src/browsh/browsh.xpi\nexport XPI_SOURCE_DIR=$PROJECT_ROOT/webext/dist/web-ext-artifacts\nexport NODE_BIN=$PROJECT_ROOT/webext/node_modules/.bin\nMDN_USER=\"user:13243312:78\"\n\nfunction versioned_xpi_file() {\n\techo \"$XPI_SOURCE_DIR/browsh-$(browsh_version).xpi\"\n}\n\n# You'll want to use this with `go run ./cmd/browsh --debug --firefox.use-existing`\nfunction build_webextension_watch() {\n\t\"$NODE_BIN\"/web-ext run \\\n\t\t--firefox contrib/firefoxheadless.sh \\\n\t\t--verbose\n}\n\nfunction build_webextension_production() {\n\tlocal version && version=$(browsh_version)\n\n\tcd \"$PROJECT_ROOT\"/webext && \"$NODE_BIN\"/webpack\n\tcd \"$PROJECT_ROOT\"/webext/dist && rm ./*.map\n\tif [ -f core ]; then\n\t\t# Is this a core dump for some failed process?\n\t\trm core\n\tfi\n\tls -alh .\n\t\"$NODE_BIN\"/web-ext build --overwrite-dest\n\tls -alh web-ext-artifacts\n\n\twebextension_sign\n\tlocal source_file && source_file=$(versioned_xpi_file)\n\n\techo \"Bundling $source_file to $XPI_PATH\"\n\tcp -f \"$source_file\" \"$XPI_PATH\"\n\n\techo \"Making extra copy for Goreleaser to put in Github release:\"\n\tlocal goreleaser_pwd=\"$PROJECT_ROOT\"/interfacer/\n\tcp -a \"$source_file\" \"$goreleaser_pwd\"\n\tls -alh \"$goreleaser_pwd\"\n}\n\n# It is possible to use unsigned webextensions in Firefox but it requires that Firefox\n# uses problematically insecure config. I know it's a hassle having to jump through all\n# these signing hoops, but I think it's better to use a standard Firefox configuration.\n# Moving away from the webextension alltogether is another story, but something I'm still\n# thinking about.\n#\n# NB: There can only be one canonical XPI for each semantic version.\n#\n# shellcheck disable=2120\nfunction webextension_sign() {\n\tlocal use_existing=$1\n\tif [ \"$use_existing\" == \"\" ]; then\n\t\t\"$NODE_BIN\"/web-ext sign --api-key \"$MDN_USER\" --api-secret \"$MDN_KEY\"\n\t\t_rename_built_xpi\n\telse\n\t\techo \"Skipping signing, downloading existing webextension\"\n\t\tlocal base=\"https://github.com/browsh-org/browsh/releases/download\"\n\t\tcurl -L \\\n\t\t\t-o \"$(versioned_xpi_file)\" \\\n\t\t\t\"$base/v$LATEST_TAGGED_VERSION/browsh-$LATEST_TAGGED_VERSION.xpi\"\n\tfi\n}\n\nfunction _rename_built_xpi() {\n\tpushd \"$XPI_SOURCE_DIR\" || _panic\n\tlocal xpi_file\n\txpi_file=\"$(\n\t\tfind ./*.xpi \\\n\t\t\t-printf \"%T@ %f\\n\" |\n\t\t\tsort |\n\t\t\tcut -d' ' -f2 |\n\t\t\ttail -n1\n\t)\"\n\tcp -a \"$xpi_file\" \"$(versioned_xpi_file)\"\n\tpopd || _panic\n}\n\nfunction bundle_production_webextension() {\n\tlocal version && version=$(browsh_version)\n\tlocal base='https://github.com/browsh-org/browsh/releases/download'\n\tlocal release_url=\"$base/v$version/browsh-$version.xpi\"\n\techo \"Downloading webextension from: $release_url\"\n\tcurl -L -o \"$XPI_PATH\" \"$release_url\"\n\tlocal size && size=$(wc -c <\"$XPI_PATH\")\n\tif [ \"$size\" -lt 500 ]; then\n\t\techo \"XPI size seems too small: $size\"\n\t\t_panic \"Problem downloading latest webextension XPI\"\n\tfi\n\tcp -a \"$XPI_PATH\" \"$(versioned_xpi_file)\"\n}\n"
  },
  {
    "path": "scripts/common.bash",
    "content": "#!/usr/bin/env bash\n\n# shellcheck disable=2120\nfunction _panic() {\n\tlocal message=$1\n\techo >&2 \"$message\"\n\texit 1\n}\n\nfunction _md5() {\n\tlocal path=$1\n\tmd5sum \"$path\" | cut -d' ' -f1\n}\n\nfunction pushd() {\n\t# shellcheck disable=2119\n\tcommand pushd \"$@\" >/dev/null || _panic\n}\n\nfunction popd() {\n\t# shellcheck disable=2119\n\tcommand popd \"$@\" >/dev/null || _panic\n}\n"
  },
  {
    "path": "scripts/docker.bash",
    "content": "#!/usr/bin/env bash\n\nfunction docker_image_name() {\n\t_export_versions\n\techo browsh/browsh:v\"$BROWSH_VERSION\"\n}\n\nfunction docker_build() {\n\tlocal og_xpi && og_xpi=$(versioned_xpi_file)\n\t[ ! -f \"$og_xpi\" ] && _panic \"Can't find latest webextension build: $og_xpi\"\n\t[ ! -f \"$XPI_PATH\" ] && _panic \"Can't find bundleable browsh.xpi: $XPI_PATH\"\n\tif [ \"$(_md5 \"$og_xpi\")\" != \"$(_md5 \"$XPI_PATH\")\" ]; then\n\t\t_panic \"XPI file's MD5 does not match original XPI file's MD5\"\n\tfi\n\tdocker build -t \"$(docker_image_name)\" .\n}\n\nfunction is_docker_logged_in() {\n\tdocker system info | grep -E 'Username|Registry'\n}\n\nfunction docker_login() {\n\tdocker login docker.io \\\n\t\t-u tombh \\\n\t\t-p \"$DOCKER_ACCESS_TOKEN\"\n}\n\nfunction docker_tag_latest() {\n\tlocal latest=browsh/browsh:latest\n\tdocker tag \"$(docker_image_name)\" \"$latest\"\n\tdocker push \"$latest\"\n}\n\nfunction docker_release() {\n\t! is_docker_logged_in && try_docker_login\n\tdocker_build\n\tdocker push \"$(docker_image_name)\"\n\tdocker_tag_latest\n}\n"
  },
  {
    "path": "scripts/misc.bash",
    "content": "#!/usr/bin/env bash\n\nfunction golang_lint_check() {\n\tpushd \"$PROJECT_ROOT\"/interfacer || _panic\n\tdiff -u <(echo -n) <(gofmt -d ./)\n\tpopd || _panic\n}\n\nfunction golang_lint_fix() {\n\tgofmt -w ./interfacer\n}\n\nfunction prettier_fix() {\n\tpushd \"$PROJECT_ROOT\"/webext || _panic\n\tprettier --write '{src,test}/**/*.js'\n\tpopd || _panic\n}\n\nfunction parse_firefox_version_from_ci_config() {\n\tlocal line && line=$(grep 'firefox-version:' <\"$PROJECT_ROOT\"/.github/workflows/main.yml)\n\tlocal version && version=$(echo \"$line\" | tr -s ' ' | cut -d ' ' -f 3)\n\t[ \"$version\" = \"\" ] && _panic \"Couldn't parse Firefox version\"\n\techo -n \"$version\"\n}\n\nfunction install_firefox() {\n\tlocal version && version=$(parse_firefox_version_from_ci_config)\n\tlocal destination=/tmp\n\techo \"Installing Firefox v$version to $destination...\"\n\tmkdir -p \"$destination\"\n\tpushd \"$destination\" || _panic\n\tcurl -L -o firefox.tar.bz2 \\\n\t\t\"https://ftp.mozilla.org/pub/firefox/releases/$version/linux-x86_64/en-US/firefox-$version.tar.bz2\"\n\tbzip2 -d firefox.tar.bz2\n\ttar xf firefox.tar\n\tpopd || _panic\n}\n\nfunction parse_golang_version_from_go_mod() {\n\tlocal path=$1\n\t[ \"$path\" = \"\" ] && _panic \"Path to Golang interfacer code not passed\"\n\tlocal line && line=$(grep '^go ' <\"$path\"/go.mod)\n\tlocal version && version=$(echo \"$line\" | tr -s ' ' | cut -d ' ' -f 2)\n\t[ \"$(echo \"$version\" | tr -s ' ')\" == \"\" ] && _panic \"Couldn't parse Golang version\"\n\techo -n \"$version\"\n}\n\nfunction install_golang() {\n\tlocal path=$1\n\t[ \"$path\" = \"\" ] && _panic \"Path to Golang interfacer code not passed\"\n\tlocal version && version=$(parse_golang_version_from_go_mod \"$path\")\n\t[ \"$GOPATH\" = \"\" ] && _panic \"GOPATH not set\"\n\t[ \"$GOROOT\" = \"\" ] && _panic \"GOROOT not set\"\n\tGOARCH=$(uname -m)\n\t[[ $GOARCH == aarch64 ]] && GOARCH=arm64\n\t[[ $GOARCH == x86_64 ]] && GOARCH=amd64\n\turl=https://dl.google.com/go/go\"$version\".linux-\"$GOARCH\".tar.gz\n\techo \"Installing Golang ($url)... to $GOROOT\"\n\tcurl -L \\\n\t\t-o go.tar.gz \\\n\t\t\"$url\"\n\tmkdir -p \"$GOPATH\"/bin\n\tmkdir -p \"$GOROOT\"\n\ttar -C \"$GOROOT/..\" -xzf go.tar.gz\n\tgo version\n}\n"
  },
  {
    "path": "scripts/releasing.bash",
    "content": "#!/usr/bin/env bash\n\nexport BROWSH_VERSION\nexport LATEST_TAGGED_VERSION\n\nfunction _goreleaser_production() {\n\tif ! command -v goreleaser &>/dev/null; then\n\t\techo \"Installing \\`goreleaser'...\"\n\t\tgo install github.com/goreleaser/goreleaser@v\"$GORELEASER_VERSION\"\n\tfi\n\tpushd \"$PROJECT_ROOT\"/interfacer || _panic\n\t_export_versions\n\t[ \"$BROWSH_VERSION\" = \"\" ] && _panic \"BROWSH_VERSION unset (goreleaser needs it)\"\n\tgoreleaser release \\\n\t\t--config \"$PROJECT_ROOT\"/goreleaser.yml \\\n\t\t--rm-dist\n\tpopd || _panic\n}\n\nfunction _export_versions() {\n\tBROWSH_VERSION=$(_parse_browsh_version)\n\tLATEST_TAGGED_VERSION=$(\n\t\tgit tag --sort=v:refname --list 'v*.*.*' | tail -n1 | sed -e \"s/^v//\"\n\t)\n}\n\nfunction _parse_browsh_version() {\n\tlocal version_file=$PROJECT_ROOT/interfacer/src/browsh/version.go\n\tlocal line && line=$(grep 'browshVersion' <\"$version_file\")\n\tlocal version && version=$(echo \"$line\" | grep -o '\".*\"' | sed 's/\"//g')\n\techo -n \"$version\"\n}\n\nfunction _is_new_version() {\n\t_export_versions\n\t[ \"$BROWSH_VERSION\" = \"\" ] && _panic \"BROWSH_VERSION unset\"\n\t[ \"$LATEST_TAGGED_VERSION\" = \"\" ] && _panic \"LATEST_TAGGED_VERSION unset\"\n\t[[ \"$BROWSH_VERSION\" != \"$LATEST_TAGGED_VERSION\" ]]\n}\n\nfunction _tag_on_version_change() {\n\t_export_versions\n\techo_versions\n\n\tif ! _is_new_version; then\n\t\techo \"Not tagging as there's no new version.\"\n\t\texit 0\n\tfi\n\n\tgit tag v\"$BROWSH_VERSION\"\n\tgit show v\"$BROWSH_VERSION\" --quiet\n\tgit config --global user.email \"ci@github.com\"\n\tgit config --global user.name \"Github Actions\"\n\tgit add --all\n\tgit reset --hard v\"$BROWSH_VERSION\"\n}\n\nfunction echo_versions() {\n\t_export_versions\n\techo \"Browsh binary version: $BROWSH_VERSION\"\n\techo \"Git latest tag: $LATEST_TAGGED_VERSION\"\n}\n\nfunction browsh_version() {\n\t_export_versions\n\techo -n \"$BROWSH_VERSION\"\n}\n\nfunction github_actions_output_version_status() {\n\tlocal status=\"false\"\n\tif _is_new_version; then\n\t\tstatus=\"true\"\n\tfi\n\techo \"::set-output name=is_new_version::$status\"\n}\n\nfunction webext_build_release() {\n\tpushd \"$PROJECT_ROOT\"/webext || _panic\n\tbuild_webextension_production\n\tpopd || _panic\n}\n\nfunction update_browsh_website_with_new_version() {\n\t_export_versions\n\tlocal remote=\"git@github.com:browsh-org/www.brow.sh.git\"\n\tpushd /tmp || _panic\n\tgit clone \"$remote\"\n\tcd www.brow.sh || _panic\n\techo \"latest_version: $BROWSH_VERSION\" >_data/browsh.yml\n\tgit add _data/browsh.yml\n\tgit commit -m \"Github Actions: updated Browsh version to $BROWSH_VERSION\"\n\tgit push \"$remote\"\n\tpopd || _panic\n}\n\nfunction update_homebrew_tap_with_new_version() {\n\t_export_versions\n\tlocal remote=\"git@github.com:browsh-org/homebrew-browsh.git\"\n\tpushd /tmp || _panic\n\tgit clone \"$remote\"\n\tcd homebrew-browsh || _panic\n\tcp -f \"$PROJECT_ROOT\"/interfacer/dist/browsh.rb browsh.rb\n\tgit add browsh.rb\n\tgit commit -m \"Github Actions: updated to $BROWSH_VERSION\"\n\tgit push \"$remote\"\n\tpopd || _panic\n}\n\nfunction goreleaser_local_only() {\n\tpushd \"$PROJECT_ROOT\"/interfacer || _panic\n\tgoreleaser release \\\n\t\t--config \"$PROJECT_ROOT\"/goreleaser.yml \\\n\t\t--snapshot \\\n\t\t--rm-dist\n\tpopd || _panic\n}\n\nfunction build_browsh_binary() {\n\t# Requires $path argument because it's used in the Dockerfile where the GOROOT is\n\t# outside .git/\n\tlocal path=$1\n\tpushd \"$path\" || _panic\n\tlocal webextension=\"src/browsh/browsh.xpi\"\n\t[ ! -f \"$webextension\" ] && _panic \"browsh.xpi not present\"\n\tmd5sum \"$webextension\"\n\tgo build ./cmd/browsh\n\techo \"Freshly built \\`browsh' version: $(./browsh --version 2>&1)\"\n\tpopd || _panic\n}\n\nfunction release() {\n\t[ \"$(git rev-parse --abbrev-ref HEAD)\" != \"master\" ] && _panic \"Not releasing unless on the master branch\"\n\twebext_build_release\n\tbuild_browsh_binary \"$PROJECT_ROOT\"/interfacer\n\t_tag_on_version_change\n\t_goreleaser_production\n}\n"
  },
  {
    "path": "scripts/tests.bash",
    "content": "# For the webextension: in `webext/` folder, `npm test`\n# For CLI unit tests: in `/interfacer` run `go test src/browsh/*.go`\n# For CLI E2E tests: in `/interfacer` run `go test test/tty/*.go`\n# For HTTP Service tests: in `/interfacer` run `go test test/http-server/*.go`\n\nfunction test_all {\n\ttest_webextension\n\tinterfacer_test_setup\n\ttest_interfacer_units\n\ttest_http_server\n\ttest_tty\n}\n\nfunction test_webextension {\n\tpushd $PROJECT_ROOT/webext\n\tnpm test\n}\n\nfunction interfacer_test_setup {\n\tpushd $PROJECT_ROOT/webext\n\ttouch \"$PROJECT_ROOT/interfacer/src/browsh/browsh.xpi\"\n\tnpm run build:dev\n}\n\nfunction test_interfacer_units {\n\tpushd $PROJECT_ROOT/interfacer\n\tgo test -v $(find src/browsh -name '*.go' | grep -v windows)\n}\n\nfunction test_tty {\n\tpushd $PROJECT_ROOT/interfacer\n\tgo test test/tty/*.go -v -ginkgo.slowSpecThreshold=30 -ginkgo.flakeAttempts=3\n}\n\nfunction test_http_server {\n\tpushd $PROJECT_ROOT/interfacer\n\tgo test test/http-server/*.go -v -ginkgo.slowSpecThreshold=30 -ginkgo.flakeAttempts=3\n}\n"
  },
  {
    "path": "webext/.eslintrc",
    "content": "{\n  \"env\" : {\n    \"es6\": true,\n    \"node\" : true,\n    \"browser\" : true,\n    \"webextensions\": true,\n    \"mocha\": true\n  },\n  \"globals\": {\n    \"DEVELOPMENT\": true,\n    \"PRODUCTION\": true,\n    \"TEST\": true\n  },\n  \"parser\": \"babel-eslint\",\n  \"parserOptions\": {\n    \"ecmaVersion\": 6,\n    \"sourceType\": \"module\"\n  },\n  \"extends\": \"eslint:recommended\",\n  \"rules\": {\n    \"no-unused-vars\": [2, {\"args\": \"all\", \"argsIgnorePattern\": \"^_\"}]\n  }\n}\n"
  },
  {
    "path": "webext/.mocharc.cjs",
    "content": "'use strict';\n\nmodule.exports = {\n\trequire: 'babel-register',\n\trecursive: true,\n\ttimeout: '60000'\n};\n\n"
  },
  {
    "path": "webext/.web-extension-id",
    "content": "# This file was created by https://github.com/mozilla/web-ext\n# Your auto-generated extension ID for addons.mozilla.org is:\n{8ff2d753-2dc8-46de-a837-fa28331d9fcf}"
  },
  {
    "path": "webext/assets/browsh-schema.json",
    "content": "{\n  \"$id\": \"https://json.schemastore.org/browsh-schema.json\",\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"$comment\": \"https://www.brow.sh/docs/config/\",\n  \"properties\": {\n    \"browsh_supporter\": {\n      \"default\": \"♥\",\n      \"enum\": [\"I have shown my support for Browsh\", \"♥\"],\n      \"description\": \"By showing your support you can disable the app's branding and nags to donate\",\n      \"type\": \"string\"\n    },\n    \"startup-url\": {\n      \"description\": \"The page to show at startup. Browsh will fail to boot if this URL is not accessible\",\n      \"type\": \"string\"\n    },\n    \"default_search_engine_base\": {\n      \"default\": \"https://www.google.com/search?q=\",\n      \"description\": \"The base query when a non-URL is entered into the URL bar\",\n      \"type\": \"string\"\n    },\n    \"mobile_user_agent\": {\n      \"default\": \"Mozilla/5.0 (Android 7.0; Mobile; rv:54.0) Gecko/58.0 Firefox/58.0\",\n      \"description\": \"The mobile user agent for forcing web pages to use their mobile layout\",\n      \"type\": \"string\"\n    },\n    \"browsh\": {\n      \"description\": \"Browsh internals\",\n      \"properties\": {\n        \"websocket-port\": {\n          \"default\": 3334,\n          \"type\": \"integer\"\n        },\n        \"use_experimental_text_visibility\": {\n          \"description\": \"Possibly better handling of overlapping text in web pages. If a page seems to have text that shouldn't be visible, if it should be behind another element for example, then this experimental feature should help. It can also be toggled in-browser with F6\",\n          \"default\": false,\n          \"type\": \"boolean\"\n        },\n        \"custom_css\": {\n          \"description\": \"Custom CSS to apply to all loaded tabs\",\n          \"type\": \"string\"\n        }\n      },\n      \"type\": \"object\"\n    },\n    \"firefox\": {\n      \"properties\": {\n        \"path\": {\n          \"default\": \"firefox\",\n          \"description\": \"The path to your Firefox binary\",\n          \"type\": \"string\"\n        },\n        \"profile\": {\n          \"default\": \"browsh-default\",\n          \"description\": \"Browsh has its own profile, separate from the normal user's. But you can change that\",\n          \"type\": \"string\"\n        },\n        \"use-existing\": {\n          \"default\": false,\n          \"description\": \"Don't let Browsh launch Firefox, but make it try to connect to an existing one. Note it will need to have been launched with the '--marionette' flag\",\n          \"type\": \"boolean\"\n        },\n        \"with-gui\": {\n          \"default\": \"with-gui\",\n          \"description\": \"Launch Firefox in with its visible GUI window. Useful for setting up the Browsh profile.\",\n          \"type\": \"string\"\n        },\n        \"preferences\": {\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"description\": \"Config that you might usually set through Firefox's 'about:config' page Note that string must be wrapped in quotes\",\n          \"type\": \"array\"\n        }\n      },\n      \"tty\": {\n        \"properties\": {\n          \"small_pixel_frame_rate\": {\n            \"default\": \"250\",\n            \"description\": \"The time in milliseconds between requesting a new TTY-sized pixel frame. This is essentially the frame rate for graphics. Lower values make for smoother animations and feedback, but also increases the CPU load\",\n            \"type\": \"integer\"\n          }\n        },\n        \"type\": \"object\"\n      },\n      \"http-server\": {\n        \"properties\": {\n          \"port\": {\n            \"default\": 4333,\n            \"type\": \"integer\"\n          },\n          \"bind\": {\n            \"default\": \"0.0.0.0\",\n            \"type\": \"string\"\n          },\n          \"render_delay\": {\n            \"default\": 100,\n            \"description\": \"The time to wait in milliseconds after the DOM is ready before trying to parse and render the page's text. Too soon and text risks not being parsed, too long and you wait unnecessarily\",\n            \"type\": \"integer\"\n          },\n          \"timeout\": {\n            \"default\": 30,\n            \"description\": \"The length of time in seconds to wait before aborting the page load\",\n            \"type\": \"integer\"\n          },\n          \"columns\": {\n            \"default\": 100,\n            \"description\": \"The dimensions of a char-based window onto a webpage. The columns are ultimately the width of the final text\",\n            \"type\": \"string\"\n          },\n          \"rows\": {\n            \"default\": 30,\n            \"description\": \"Whereas the rows represent the height of the original web page made visible to the original browser window. So the number of rows can effect things like how far down a web page images are lazy-loaded\",\n            \"type\": \"string\"\n          },\n          \"jpeg_compression\": {\n            \"default\": 0.9,\n            \"description\": \"The amount of lossy JPG compression to apply to the background image of HTML pages\",\n            \"type\": \"string\"\n          },\n          \"rate-limit\": {\n            \"default\": \"100000000-M\",\n            \"description\": \"Rate limit. For syntax, see: https://github.com/ulule/limiter\",\n            \"type\": \"string\"\n          },\n          \"blocked-domains\": {\n            \"items\": {\n              \"type\": \"string\"\n            },\n            \"description\": \"Blocking is useful if the HTTP server is made public. All values are evaluated as regular expressions\",\n            \"type\": \"array\"\n          },\n          \"blocked-user-agents\": {\n            \"items\": {\n              \"type\": \"string\"\n            },\n            \"description\": \"Blocking is useful if the HTTP server is made public. All values are evaluated as regular expressions\",\n            \"type\": \"array\"\n          },\n          \"header\": {\n            \"description\": \"HTML snippets to show at top and bottom of final page\",\n            \"type\": \"string\"\n          },\n          \"footer\": {\n            \"description\": \"HTML snippets to show at top and bottom of final page\",\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"type\": \"object\"\n    }\n  },\n  \"title\": \"JSON schema for browsh\",\n  \"type\": \"object\"\n}\n"
  },
  {
    "path": "webext/assets/styles.css",
    "content": "@font-face {\n  /* A special font that only has unicode full blocks in it, so we can detect */\n  /* font colors and text visibility more easily. */\n  font-family: 'BlockCharMono';\n  src: url('/assets/BlockCharMono.ttf') format('truetype');\n}\n\n@font-face {\n  font-family: 'BlankMono';\n  src: url('/assets/BlankMono.ttf') format('truetype');\n}\n\n/* Force text into a reliable grid */\nhtml * {\n  font-size: 15px !important;\n  line-height: 20px !important;\n  letter-spacing: 0px !important;\n  font-style: normal !important;\n  font-weight: normal !important;\n  font-family: 'BlockCharMono' !important;\n}\n\na {\n  text-decoration: none !important;\n}\n\n.browsh-hide-text,\n.browsh-hide-text *{\n  font-family: 'BlankMono' !important;\n}\n\n.browsh-show-text,\n.browsh-show-text * {\n  font-family: 'BlockCharMono' !important;\n}\n\nsup, sub {\n  vertical-align: baseline !important;\n}\n\n/* Prevents duplicated text caused by the rendering of the DOM's input box content\n * and the CLI app's input box content */\ninput, textarea {\n  color: transparent !important;\n}\n\n/**\n * Site-specific fixes\n *\n * TODO: This is going to need to be much more formally organised\n */\n/* Stackoverflow cookie banner */\n#js-gdpr-consent-banner {\n  display: none;\n}\n"
  },
  {
    "path": "webext/background.js",
    "content": "import BackgroundManager from 'background/manager'\n\nnew BackgroundManager();\n\n"
  },
  {
    "path": "webext/content.js",
    "content": "import DOMManager from 'dom/manager';\n\nnew DOMManager();\n\n"
  },
  {
    "path": "webext/contrib/download_xpi.js",
    "content": "// `npm install -g jsonwebtoken`\nvar jwt = require('jsonwebtoken');\n\nvar key = 'user:13243312:78';\nvar secret = process.env.MDN_KEY;\n\nvar issuedAt = Math.floor(Date.now() / 1000);\nvar payload = {\n  iss: key,\n  jti: Math.random().toString(),\n  iat: issuedAt,\n  exp: issuedAt + 60,\n};\n\nvar token = jwt.sign(payload, secret, {\n  algorithm: 'HS256',  // HMAC-SHA256 signing algorithm\n});\n\nvar auth = 'JWT ' + token;\nvar path = '848208/browsh-0.2.3-an+fx.xpi';\nvar base = 'https://addons.mozilla.org/api/v3/file/';\nvar uri = base + path;\n\nprocess.stdout.write('curl -H \"Authorization: ' + auth + '\" ' + uri);\n"
  },
  {
    "path": "webext/contrib/firefoxheadless.sh",
    "content": "#!/usr/bin/env bash\n\nif [[ \"$1\" = \"kill\" ]]; then\n\tpkill --full 'firefox.*headless.*profile'\n\tsleep 1\n\tif [[ \"$CI\" == \"true\" ]]; then\n\t\tpkill -9 firefox || true\n\tfi\nelse\n\tFIREFOX_BIN=${FIREFOX:-firefox}\n\t\"$FIREFOX_BIN\" --headless --marionette \"$@\"\nfi\n"
  },
  {
    "path": "webext/contrib/font_maker.py",
    "content": "# TODO:\n#   Look into using: https://github.com/adobe-fonts/adobe-blank\n#   It should both reduce the size of the font and support all possible UTF8 chars\n\nimport fontforge\n\ndef generate(name, block):\n    print(\"Generating \" + name)\n    # TODO:\n    #   This needs to reach 0x9FCF to complete the CJK Ideographs\n    #   But above around 0x7f00, we get this error:\n    #   `Internal Error: Attempt to output 81854 into a 16-bit field. It will be\n    #    truncated and the file may not be useful.`\n    for i in range(0x0000, 0x7F00):\n      if i == codepoint: continue\n      glyph = blocks.createChar(i)\n      glyph.width = 600\n      glyph.addReference(block)\n\n    print(blocks[codepoint].foreground)\n    blocks.fontname = name\n    blocks.fullname = name\n    blocks.familyname = name\n\n    # Fontforge's WOFF output doesn't seem to work. No matter, this isn't for an actual\n    # remote production website. The font is served locally from the extension and doesn't\n    # even need to look good.\n    blocks.generate(name + '.ttf')\n\n# A font with just the █ (0x2588) for all unicode characters\nblocks = fontforge.font()\nblocks.encoding = 'UnicodeFull'\n\ncodepoint = 0x2588\nglyph = blocks.createChar(codepoint)\nglyph.width = 600\n\npen = blocks[codepoint].glyphPen()\npen.moveTo((0, -200))\npen.lineTo((0, 800))\npen.lineTo((600, 800))\npen.lineTo((600, -200))\npen.closePath()\n\ngenerate('BlockCharMono', blocks[codepoint].glyphname)\n\n# A font with just the space character, used to hide all text\nblocks = fontforge.font()\nblocks.encoding = 'UnicodeFull'\n\ncodepoint = 0x2003\nglyph = blocks.createChar(codepoint)\nglyph.width = 600\n\npen = blocks[codepoint].glyphPen()\npen.moveTo((0, 0))\npen.lineTo((0, 0))\npen.closePath()\n\ngenerate('BlankMono', blocks[codepoint].glyphname)\n"
  },
  {
    "path": "webext/manifest.json",
    "content": "{\n  \"manifest_version\": 2,\n  \"name\": \"Browsh\",\n  \"version\": \"BROWSH_VERSION\",\n\n  \"description\": \"Renders the browser as realtime, interactive, TTY-compatible text\",\n\n  \"icons\": {\n    \"48\": \"assets/icons/browsh-48.png\",\n    \"96\": \"assets/icons/browsh-96.png\"\n  },\n\n  \"background\": {\n    \"scripts\": [\"background.js\"]\n  },\n\n  \"content_scripts\": [\n    {\n      \"matches\": [\"*://*/*\"],\n      \"js\": [\"content.js\"],\n      \"css\": [\"assets/styles.css\"],\n      \"run_at\": \"document_start\"\n    }\n  ],\n\n  \"web_accessible_resources\": [\n    \"assets/BlockCharMono.ttf\",\n    \"assets/BlankMono.ttf\"\n  ],\n\n  \"permissions\": [\n    \"<all_urls>\",\n    \"webRequest\",\n    \"webRequestBlocking\",\n    \"tabs\"\n  ]\n}\n"
  },
  {
    "path": "webext/package.json",
    "content": "{\n  \"type\": \"module\",\n  \"scripts\": {\n    \"build:dev\": \"webpack\",\n    \"build:watch\": \"webpack --watch\",\n    \"lint\": \"prettier --list-different '{src,test}/**/*.js'\",\n    \"test\": \"NODE_PATH=src:test mocha\"\n  },\n  \"babel\": {\n    \"presets\": [\n      \"es2015\"\n    ]\n  },\n  \"devDependencies\": {\n    \"babel-eslint\": \"^8.2.6\",\n    \"babel-loader\": \"^8.2.5\",\n    \"babel-preset-es2015\": \"^6.24.1\",\n    \"babel-register\": \"^6.26.0\",\n    \"chai\": \"^4.3.6\",\n    \"copy-webpack-plugin\": \"^11.0.0\",\n    \"eslint\": \"^8.20.0\",\n    \"mocha\": \"^10.0.0\",\n    \"prettier\": \"2.7.1\",\n    \"sinon\": \"^14.0.0\",\n    \"strip-ansi\": \"^7.0.1\",\n    \"web-ext\": \"^7.5.0\",\n    \"webpack\": \"^5.73.0\",\n    \"webpack-cli\": \"^4.10.0\"\n  },\n  \"dependencies\": {\n    \"lodash\": \"^4.17.21\",\n    \"string-width\": \"^5.1.2\"\n  }\n}\n"
  },
  {
    "path": "webext/src/background/common_mixin.js",
    "content": "import stripAnsi from \"strip-ansi\";\n\n// Here we keep the public functions used to mediate communications between\n// the background process, tabs and the terminal.\nexport default (MixinBase) =>\n  class extends MixinBase {\n    sendToCurrentTab(message) {\n      if (this.currentTab().channel === undefined) {\n        this.log(`Attempt to send \"${message}\" to tab without a channel`);\n      } else {\n        this.currentTab().channel.postMessage(message);\n      }\n    }\n\n    sendToTerminal(message) {\n      if (this.terminal === undefined) {\n        return;\n      }\n      if (this.terminal.readyState === 1) {\n        this.terminal.send(message);\n      }\n    }\n\n    log(...messages) {\n      if (messages === undefined) {\n        messages = \"undefined\";\n      }\n      if (messages.length === 1) {\n        messages = messages[0].toString();\n        messages = stripAnsi(messages);\n        messages = messages.replace(/\\u001b\\[/g, \"ESC\");\n      }\n      this.sendToTerminal(messages);\n    }\n\n    currentTab() {\n      return this.tabs[this.active_tab_id];\n    }\n  };\n"
  },
  {
    "path": "webext/src/background/dimensions.js",
    "content": "import _ from \"lodash\";\n\nimport utils from \"utils\";\nimport CommonMixin from \"background/common_mixin\";\n\nexport default class extends utils.mixins(CommonMixin) {\n  constructor() {\n    super();\n    this.tty = {};\n    this.char = {};\n    // I *think* this extra height is needed because the browser window height is not the same\n    // as the actual viewport height. But then if that is the case, then we'll also have a\n    // similar issue with the scroll bars.\n    // TODO: Also if this hypothesis is correct, it needs to be applied as an original browser-\n    // relative pixel unit, not as a TTY unit. If you look on Google Maps I think you can\n    // actually see a little bit of white at the bottom perhaps from where the screen capture\n    // goes over the bottom of the viewport.\n    this._window_ui_magic_number = 3;\n  }\n\n  postConfigSetup(config) {\n    this.config = config;\n    this._setRawTextTTYSize();\n  }\n\n  setCharValues(incoming) {\n    if (\n      this.char.width != incoming.width ||\n      this.char.height != incoming.height\n    ) {\n      this.log(\n        `Requesting browser resize for new char dimensions: ` +\n          `${incoming.width}x${incoming.height} (old: ${this.char.width}x${this.char.height})`\n      );\n      this.char = _.clone(incoming);\n      this.resizeBrowserWindow();\n    }\n  }\n\n  // The Browsh HTTP Server service doesn't load a TTY, so we need to supply the size.\n  // Strictly it shouldn't even be needed if the code was completely refactored. Although\n  // it should be worth taking into consideration how the size of the TTY and therefore the\n  // resized browser window affects the rendering of a web page, for instance images outside\n  // of the viewport can sometimes not be loaded. So is it practical to set the TTY size to\n  // the size of the entire DOM?\n  _setRawTextTTYSize() {\n    this.raw_text_tty_size = {\n      width: this.config[\"http-server\"].columns,\n      height: this.config[\"http-server\"].rows,\n    };\n  }\n\n  resizeBrowserWindow() {\n    if (\n      !this.tty.width ||\n      !this.char.width ||\n      !this.tty.height ||\n      !this.char.height\n    ) {\n      this.log(\n        \"Not resizing browser window without all of the TTY and character dimensions\"\n      );\n      return;\n    }\n    // Does this include scrollbars???\n    const window_width = parseInt(Math.round(this.tty.width * this.char.width));\n    // Leave room for tabs and URL bar\n    const tty_dom_height = this.tty.height - 2;\n    const window_height = parseInt(\n      Math.round(\n        (tty_dom_height + this._window_ui_magic_number) * this.char.height\n      )\n    );\n    const current_window = browser.windows.getCurrent();\n    current_window.then(\n      (active_window) => {\n        this._sendWindowResizeRequest(\n          active_window,\n          window_width,\n          window_height\n        );\n      },\n      (error) => {\n        this.log(\"Error getting current browser window\", error);\n      }\n    );\n  }\n\n  _sendWindowResizeRequest(active_window, width, height) {\n    const tag = \"Resizing browser window\";\n    const updating = browser.windows.update(active_window.id, {\n      width: width,\n      height: height,\n      focused: false,\n    });\n    updating.then(\n      (info) => this.log(`${tag} successful (${info.width}x${info.height})`),\n      (error) => this.log(tag + \" error: \", error)\n    );\n  }\n}\n"
  },
  {
    "path": "webext/src/background/manager.js",
    "content": "import _ from \"lodash\";\nimport utils from \"utils\";\nimport CommonMixin from \"background/common_mixin\";\nimport TTYCommandsMixin from \"background/tty_commands_mixin\";\nimport Tab from \"background/tab\";\nimport Dimensions from \"background/dimensions\";\n\n// Boots the background process. Mainly involves connecting to the websocket server\n// launched by the Browsh CLI client and setting up listeners for new tabs that\n// have our webextension content script inside them.\nexport default class extends utils.mixins(CommonMixin, TTYCommandsMixin) {\n  constructor() {\n    super();\n    this.dimensions = new Dimensions();\n    // All of the tabs open in the real browser\n    this.tabs = {};\n    // The ID of the tab currently opened tab\n    this.active_tab_id = null;\n    // When the real GUI browser first launches it's sized to the same size as the desktop\n    this._is_initial_window_size_pending = true;\n    // Used so that reconnections to the terminal don't also attempt to reconnect to the\n    // browser DOM.\n    this._is_connected_to_browser_dom = false;\n    // Raw text mode is for when Browsh is running as an HTTP server that serves single\n    // pages as entire DOMs, in plain text.\n    this._is_raw_text_mode = false;\n    // Toggle user agent\n    this._is_using_mobile_user_agent = false;\n    this._addUserAgentListener();\n    // Listen to HTTP requests. This allows us to display some helpful status messages at the\n    // bottom of the page, eg; \"Loading https://coolwebsite.com...\"\n    this._addWebRequestListener();\n    // The manager is the hub between tabs and the terminal. First we connect to the\n    // terminal, as that is the process that would have initially booted the browser and\n    // this very code that now runs.\n    this._connectToTerminal();\n  }\n\n  _connectToTerminal() {\n    // This is the websocket server run by the CLI client\n    this.terminal = new WebSocket(\"ws://localhost:3334\");\n    this.terminal.addEventListener(\"open\", (_event) => {\n      this.log(\"Webextension connected to the terminal's websocket server\");\n      this.dimensions.terminal = this.terminal;\n      this._listenForTerminalMessages();\n      this._connectToBrowserDOM();\n    });\n    this.terminal.addEventListener(\"close\", (_event) => {\n      this._reconnectToTerminal();\n    });\n  }\n\n  // If we've disconnected from the terminal, but we're still running, then that likely\n  // means the terminal crashed, so we wait to see if the user restarts the terminal.\n  _reconnectToTerminal() {\n    try {\n      this._connectToTerminal();\n    } catch (_e) {\n      _.debounce(() => this._reconnectToTerminal(), 50);\n    }\n  }\n\n  // Mostly listening for forwarded STDIN from the terminal. Therefore, the user\n  // pressing the arrow keys, typing, moving the mouse, etc, etc. But we also listen\n  // to TTY resize events too.\n  _listenForTerminalMessages() {\n    this.log(\"Starting to listen to TTY\");\n    this.terminal.addEventListener(\"message\", (event) => {\n      this.log(\"Message from terminal: \" + event.data);\n      this.handleTerminalMessage(event.data);\n    });\n  }\n\n  _connectToBrowserDOM() {\n    if (!this._is_connected_to_browser_dom) {\n      this._initialDOMConnection();\n    } else {\n      this._reconnectToDOM();\n    }\n  }\n\n  _initialDOMConnection() {\n    this._listenForNewTab();\n    this._listenForTabUpdates();\n    this._listenForTabChannelOpen();\n    this._listenForFocussedTab();\n  }\n\n  _reconnectToDOM() {\n    this.log(\"Attempting to resend browser state to terminal...\");\n    this.currentTab().sendStateToTerminal();\n    if (!this._is_raw_text_mode) {\n      this.sendToCurrentTab(\"/rebuild_text\");\n    }\n  }\n\n  // For when a tab's content script, triggered by `onDOMContentLoaded`,\n  // phone's home.\n  // Curiously `browser.runtime.onMessage` receives the tab's ID, whereas\n  // `browser.runtime.onConnect` doesn't. So we have to have 2 tab listeners:\n  //   1. to get the tab ID so we can talk to it later with 2.\n  //   2. to maintain a long-lived connection to continuously pass messages\n  //      back and forth.\n  _listenForNewTab() {\n    browser.runtime.onMessage.addListener(this._newTabHandler.bind(this));\n  }\n\n  // There's what seems to be a bug: tabs can exist and be processed without\n  // triggering any `browser.tabs.onUpdated` events. Therefore we need to\n  // manually poll :/\n  // TODO: Detect deleted tabs to remove the key from `this.tabs[]`\n  _listenForTabUpdates() {\n    setInterval(() => {\n      this._pollAllTabs((native_tab_object) => {\n        let tab = this._applyUpdates(native_tab_object);\n        tab.ensureConnectionToBackground();\n      });\n    }, 100);\n  }\n\n  _maybeNewTab(tabish_object) {\n    const tab_id = parseInt(tabish_object.id);\n    if (this.tabs[tab_id] === undefined) {\n      let new_tab = new Tab(tabish_object);\n      this.tabs[tab_id] = new_tab;\n    }\n    return this.tabs[tab_id];\n  }\n\n  _handleTabUpdate(_tab_id, changes, native_tab_object) {\n    this.log(\n      `Tab ${native_tab_object.id} detected chages: ${JSON.stringify(changes)}`\n    );\n    let tab = this.tabs[native_tab_object.id];\n    tab.native_last_change = changes;\n    tab.ensureConnectionToBackground();\n    tab.sendGlobalConfig(this.config);\n  }\n\n  // Note that although this callback signifies that the tab now exists, it is not fully\n  // booted and functional until it has opened a communication channel. It can't do that\n  // until it knows its internally represented ID.\n  _newTabHandler(_request, sender, sendResponse) {\n    this.log(\n      `Tab ${sender.tab.id} (${sender.tab.title}) registered with background process`\n    );\n    // Send the tab back to itself, such that it can be enlightened unto its own nature\n    sendResponse(sender.tab);\n    this._acknowledgeNewTab(sender.tab);\n  }\n\n  _acknowledgeNewTab(native_tab_object) {\n    let tab = this._applyUpdates(native_tab_object);\n    tab._is_raw_text_mode = this._is_raw_text_mode;\n    tab.postDOMLoadInit(this.terminal, this.dimensions);\n  }\n\n  _applyUpdates(tabish_object) {\n    let tab = this._maybeNewTab({\n      id: tabish_object.id,\n    });\n    [\n      \"id\",\n      \"title\",\n      \"url\",\n      \"active\",\n      \"request_id\",\n      \"raw_text_mode_type\",\n      \"start_time\",\n    ].map((key) => {\n      if (tabish_object.hasOwnProperty(key)) {\n        tab[key] = tabish_object[key];\n      }\n    });\n    if (tabish_object.active) {\n      this.active_tab_id = tab.id;\n    }\n    return tab;\n  }\n\n  // This is the main communication channel for all back and forth messages to tabs\n  _listenForTabChannelOpen() {\n    browser.runtime.onConnect.addListener(\n      this._tabChannelOpenHandler.bind(this)\n    );\n  }\n\n  _tabChannelOpenHandler(channel) {\n    this.log(\n      `Tab ${channel.name} connected for communication with background process`\n    );\n    let tab = this.tabs[parseInt(channel.name)];\n    tab.postConnectionInit(channel, this.config);\n    if (!this._is_connected_to_browser_dom) {\n      this._startFrameRequestLoop();\n    }\n    this._is_connected_to_browser_dom = true;\n  }\n\n  _listenForFocussedTab() {\n    browser.tabs.onActivated.addListener(this._focussedTabHandler.bind(this));\n  }\n\n  _focussedTabHandler(tab) {\n    this.log(`Tab ${tab.id} received new focus`);\n    this.active_tab_id = tab.id;\n  }\n\n  _getTabsOnSuccess(windowInfoArray, callback) {\n    for (let windowInfo of windowInfoArray) {\n      windowInfo.tabs.map((tab) => {\n        callback(tab);\n      });\n    }\n  }\n\n  _getTabsOnError(error) {\n    this.log(`Error: ${error}`);\n  }\n\n  _pollAllTabs(callback) {\n    var getting = browser.windows.getAll({\n      populate: true,\n      windowTypes: [\"normal\"],\n    });\n    getting.then(\n      (windowInfoArray) => this._getTabsOnSuccess(windowInfoArray, callback),\n      () => this._getTabsOnError(callback)\n    );\n  }\n\n  // The browser window can only be resized once we have both the character dimensions from\n  // the browser tab _and the TTY dimensions from the terminal. There's probably a more\n  // efficient way of triggering this initial window resize, than just waiting for the data\n  // on every frame tick.\n  _initialWindowResize() {\n    if (!this._is_initial_window_size_pending) return;\n    this.dimensions.resizeBrowserWindow();\n    this._is_initial_window_size_pending = false;\n  }\n\n  // Instead of having each tab manage its own frame rate, just keep this single, centralised\n  // heartbeat in the background process that switches automatically to the current active\n  // tab.\n  //\n  // Note that by \"frame rate\" here we justs mean the rate at which a TTY-sized frame of\n  // graphics pixles are sent. Larger frames are sent in response to scroll events and\n  // TTY-sized text frames are sent in response to DOM mutation events.\n  _startFrameRequestLoop() {\n    this.log(\n      \"BACKGROUND: Frame loop starting at \" +\n        this.config.tty.small_pixel_frame_rate +\n        \"ms intervals\"\n    );\n    setInterval(() => {\n      if (this._is_initial_window_size_pending) this._initialWindowResize();\n      if (this._isAbleToRequestFrame()) {\n        this.sendToCurrentTab(\"/request_frame\");\n      }\n    }, this.config.tty.small_pixel_frame_rate);\n  }\n\n  _isAbleToRequestFrame() {\n    if (this._is_raw_text_mode) {\n      return false;\n    }\n    if (!this.dimensions.tty.width || !this.dimensions.tty.height) {\n      this.log(\"Not sending frame to TTY without TTY size\");\n      return false;\n    }\n    if (!this.tabs.hasOwnProperty(this.active_tab_id)) {\n      this.log(\"No active tab, so not requesting a frame\");\n      return false;\n    }\n    if (this.currentTab().channel === undefined) {\n      this.log(\n        `Active tab ${this.active_tab_id} does not have a channel, so not requesting a frame`\n      );\n      return false;\n    }\n    return true;\n  }\n\n  // Listen for HTTP activity so we can notify the user that something is loading in the background\n  _addWebRequestListener() {\n    browser.webRequest.onBeforeRequest.addListener(\n      (e) => {\n        let message;\n        if (e.type == \"main_frame\") {\n          message = `Loading ${e.url}`;\n          if (this.currentTab() !== undefined) {\n            this.currentTab().updateStatus(\"info\", message);\n          }\n        }\n      },\n      {\n        urls: [\"*://*/*\"],\n      },\n      [\"blocking\"]\n    );\n  }\n}\n"
  },
  {
    "path": "webext/src/background/tab.js",
    "content": "import utils from \"utils\";\n\nimport CommonMixin from \"background/common_mixin\";\nimport TabCommandsMixin from \"background/tab_commands_mixin\";\n\nexport default class extends utils.mixins(CommonMixin, TabCommandsMixin) {\n  constructor() {\n    super();\n    // Keep track of automatic reloads to problematic tabs\n    this._tab_reloads = 0;\n    // The maximum amount of times to try to recover a tab that won't connect\n    this._max_number_of_tab_recovery_reloads = 3;\n    // Type of raw text mode; HTML or plain\n    this.raw_text_mode_type = \"\";\n  }\n\n  postDOMLoadInit(terminal, dimensions) {\n    this.terminal = terminal;\n    this.dimensions = dimensions;\n    this._closeUnwantedStartupTabs();\n  }\n\n  postConnectionInit(channel, config) {\n    this.channel = channel;\n    this._sendTTYDimensions();\n    this._listenForMessages();\n    this.sendGlobalConfig(config);\n  }\n\n  _calculateMode() {\n    return !this._is_raw_text_mode\n      ? \"interactive\"\n      : \"raw_text_\" + this.raw_text_mode_type;\n  }\n\n  isConnected() {\n    return this.channel !== undefined;\n  }\n\n  reload() {\n    const reloading = browser.tabs.reload(this.id);\n    reloading.then(\n      (tab) => this.log(`Tab ${tab.id} reloaded.`),\n      (error) => this.log(error)\n    );\n  }\n\n  remove() {\n    const removing = browser.tabs.remove(this.id);\n    removing.then(\n      () => this.log(`Tab ${this.id} removed.`),\n      (error) => this.log(error)\n    );\n  }\n\n  updateStatus(status, message = \"\") {\n    let status_message;\n    switch (status) {\n      case \"page_init\":\n        status_message = `Loading ${this.url}`;\n        break;\n      case \"parsing_complete\":\n        status_message = \"\";\n        break;\n      case \"window_unload\":\n        status_message = \"Loading...\";\n        break;\n      default:\n        if (message != \"\") status_message = message;\n    }\n    this.page_state = status;\n    this.status_message = status_message;\n    this.sendStateToTerminal();\n  }\n\n  getStateObject() {\n    return {\n      id: this.id,\n      active: this.active,\n      removed: this.removed,\n      title: this.title,\n      uri: this.url,\n      page_state: this.page_state,\n      status_message: this.status_message,\n    };\n  }\n\n  sendStateToTerminal() {\n    this.sendToTerminal(`/tab_state,${JSON.stringify(this.getStateObject())}`);\n  }\n\n  // For various reasons a tab's content script doesn't always load. Currently\n  // the known reasons are;\n  //   1. Pages without content, such as direct links to images.\n  //   2. Native pages such as `about:config`.\n  //   3. Unknown buggy behaviour such as on Travis :/\n  // So here we attempt some workarounds.\n  ensureConnectionToBackground() {\n    let native_status;\n    if (!this._isItOKToRetryReload()) {\n      return;\n    }\n    if (this.native_last_change) {\n      native_status = this.native_last_change.status;\n    }\n    if (native_status === \"complete\" && !this._isConnected()) {\n      this.log(\n        `Automatically reloading tab ${this.id} that has loaded but not connected ` +\n          \"to the webextension\"\n      );\n      this.reload();\n      this._reload_count++;\n    }\n  }\n\n  sendGlobalConfig(config) {\n    config.http_server_mode_type = this._calculateMode();\n    config.start_time = this.start_time;\n    if (this.channel) {\n      this.channel.postMessage(`/config,${JSON.stringify(config)}`);\n    } else {\n      setTimeout(() => {\n        this.sendGlobalConfig(config);\n      }, 1);\n    }\n  }\n\n  _listenForMessages() {\n    this.channel.onMessage.addListener(this.handleTabMessage.bind(this));\n  }\n\n  _sendTTYDimensions() {\n    this.channel.postMessage(\n      `/tty_size,${this.dimensions.tty.width},${this.dimensions.tty.height}`\n    );\n  }\n\n  _isItOKToRetryReload() {\n    return this._reload_count <= this._max_number_of_tab_recovery_reloads;\n  }\n\n  // On the very first startup of Firefox on a new profile it loads a tab disclaiming\n  // its data collection to a third-party. Sometimes this tab loads first, sometimes\n  // it loads second. Especially for testing we always need to load the tab we requested\n  // first. So let's just close that tab.\n  // TODO: Only do this for a testing ENV?\n  _closeUnwantedStartupTabs() {\n    if (this.title === undefined) {\n      return false;\n    }\n    if (\n      this.title.includes(\"Firefox by default shares data to:\") ||\n      this.title.includes(\"Firefox Privacy Notice\")\n    ) {\n      this.log(\"Removing Firefox startup page\");\n      this.remove();\n      return true;\n    }\n    return false;\n  }\n}\n"
  },
  {
    "path": "webext/src/background/tab_commands_mixin.js",
    "content": "import utils from \"utils\";\n\n// Handle commands from tabs, like sending a frame or information about\n// the current character dimensions.\nexport default (MixinBase) =>\n  class extends MixinBase {\n    // TODO: There needs to be some consistency in this message sending protocol.\n    //       Eg; always requiring JSON.\n    handleTabMessage(message) {\n      let incoming;\n      const parts = message.split(\",\");\n      const command = parts[0];\n      switch (command) {\n        case \"/frame_text\":\n          this.sendToTerminal(`/frame_text,${message.slice(12)}`);\n          break;\n        case \"/frame_pixels\":\n          this.sendToTerminal(`/frame_pixels,${message.slice(14)}`);\n          break;\n        case \"/tab_info\":\n          incoming = JSON.parse(utils.rebuildArgsToSingleArg(parts));\n          this._updateTabInfo(incoming);\n          break;\n        case \"/dimensions\":\n          incoming = JSON.parse(message.slice(12));\n          this.dimensions.setCharValues(incoming.char);\n          break;\n        case \"/status\":\n          this.updateStatus(parts[1], parts[2]);\n          break;\n        case \"/log\":\n          this.log(message.slice(5));\n          break;\n        case \"/raw_text\":\n          incoming = JSON.parse(utils.rebuildArgsToSingleArg(parts));\n          this._rawTextRequest(incoming);\n          break;\n        default:\n          this.log(\"Unknown command from tab to background\", message);\n      }\n    }\n\n    _updateTabInfo(incoming) {\n      this.title = incoming.title;\n      this.url = incoming.url;\n      this.sendStateToTerminal();\n    }\n\n    _rawTextRequest(incoming) {\n      // I think the only reason that a tab would send a raw text payload is the\n      // automatic startup URL loading, which should now be disabled for HTTP Server\n      // mode.\n      if (this.request_id) {\n        let payload = {\n          json: JSON.stringify(incoming),\n          request_id: this.request_id,\n        };\n        this.sendToTerminal(`/raw_text,${JSON.stringify(payload)}`);\n      }\n      this._tabCount((count) => {\n        if (count > 1) {\n          this.remove();\n        }\n      });\n    }\n\n    _tabCount(callback) {\n      this._getAllTabs((windowInfoArray) => {\n        callback(windowInfoArray[0].tabs.length);\n      });\n    }\n\n    _getAllTabs(callback) {\n      var getting = browser.windows.getAll({\n        populate: true,\n        windowTypes: [\"normal\"],\n      });\n      getting.then(\n        (windowInfoArray) => callback(windowInfoArray),\n        () => this.log(\"Error getting all tabs in Tab class\")\n      );\n    }\n  };\n"
  },
  {
    "path": "webext/src/background/tty_commands_mixin.js",
    "content": "import utils from \"utils\";\n\n// Handle commands coming in from the terminal, like; STDIN keystrokes, TTY resize, etc\nexport default (MixinBase) =>\n  class extends MixinBase {\n    handleTerminalMessage(message) {\n      const parts = message.split(\",\");\n      const command = parts[0];\n      switch (command) {\n        case \"/config\":\n          this._loadConfig(message.slice(8));\n          break;\n        case \"/tab_command\":\n          this.sendToCurrentTab(message.slice(13));\n          break;\n        case \"/tty_size\":\n          this._updateTTYSize(parts[1], parts[2]);\n          break;\n        case \"/stdin\":\n          this._handleUICommand(parts);\n          this.sendToCurrentTab(message);\n          break;\n        case \"/url_bar\":\n          this._handleURLBarInput(parts.slice(1).join(\",\"));\n          break;\n        case \"/new_tab\":\n          this.createNewTab(parts.slice(1).join(\",\"));\n          break;\n        case \"/switch_to_tab\":\n          this.switchToTab(parts.slice(1).join(\",\"));\n          break;\n        case \"/remove_tab\":\n          this.removeTab(parts.slice(1).join(\",\"));\n          break;\n        case \"/raw_text_request\":\n          this._rawTextRequest(parts[1], parts[2], parts.slice(3).join(\",\"));\n          break;\n      }\n    }\n\n    _loadConfig(json_string) {\n      this.log(json_string);\n      this.config = JSON.parse(json_string);\n      this.config.browsh_version = browser.runtime.getManifest().version;\n      if (this.currentTab()) {\n        this.currentTab().sendGlobalConfig(this.config);\n      }\n      this.dimensions.postConfigSetup(this.config);\n      this._setupRawTextMode();\n    }\n\n    _setupRawTextMode() {\n      if (!this.config[\"http-server-mode\"]) {\n        return;\n      }\n      this._is_raw_text_mode = true;\n      this._updateTTYSize(\n        this.dimensions.raw_text_tty_size.width,\n        this.dimensions.raw_text_tty_size.height\n      );\n    }\n\n    _updateTTYSize(width, height) {\n      this.dimensions.tty.width = parseInt(width);\n      this.dimensions.tty.height = parseInt(height);\n      if (this.currentTab()) {\n        this.sendToCurrentTab(\n          `/tty_size,${this.dimensions.tty.width},${this.dimensions.tty.height}`\n        );\n      }\n      this.log(\n        `Requesting browser resize for new TTY dimensions: ` +\n          `${width}x${height}`\n      );\n      this.dimensions.resizeBrowserWindow();\n    }\n\n    _handleUICommand(parts) {\n      const input = JSON.parse(utils.rebuildArgsToSingleArg(parts));\n      // CTRL mappings\n      /*\n      if (input.mod === 2) {\n        switch (input.char) {\n          default:\n        }\n      }\n      */\n      // ALT mappings\n      if (input.mod === 4) {\n        switch (input.char) {\n          case \"p\":\n            this.screenshotActiveTab();\n            break;\n          case \"u\":\n            this.toggleUserAgent();\n            break;\n        }\n      }\n      return false;\n    }\n\n    _handleURLBarInput(input) {\n      const final_url = this._getURLfromUserInput(input);\n      this.gotoURL(final_url);\n    }\n\n    // TODO: move to CLI client\n    _getURLfromUserInput(input) {\n      let url;\n      const search_engine = this.config.default_search_engine_base;\n      // Basically just check to see if there is text either side of a dot\n      const is_straddled_dot = RegExp(/^[^\\s]+\\.[^\\s]+/);\n      // More comprehensive URL pattern\n      const is_url = RegExp(/\\/\\/\\w+(\\.\\w+)*(:[0-9]+)?\\/?(\\/[.\\w]*)*$/);\n      if (is_straddled_dot.test(input) || is_url.test(input)) {\n        url = input;\n        if (!url.startsWith(\"http\")) {\n          url = \"http://\" + url;\n        }\n      } else {\n        url = `${search_engine}${input}`;\n      }\n      this.urlBarUserContent = url;\n      return url;\n    }\n\n    createNewTab(url, callback) {\n      const final_url = this._getURLfromUserInput(url);\n      let creating = browser.tabs.create({\n        url: final_url,\n      });\n      creating.then(\n        (tab) => {\n          if (callback) {\n            callback(tab);\n          }\n          this.log(`New tab created: ${tab}`);\n        },\n        (error) => {\n          this.log(`Error creating new tab: ${error}`);\n        }\n      );\n    }\n\n    gotoURL(url) {\n      let updating = browser.tabs.update(parseInt(this.currentTab().id), {\n        url: url,\n      });\n      updating.then(\n        (tab) => {\n          this.log(`Tab ${tab.id} loaded: ${url}`);\n        },\n        (error) => {\n          this.log(`Error loading: ${url} \\nError: ${error}`);\n        }\n      );\n    }\n\n    switchToTab(id) {\n      let updating = browser.tabs.update(parseInt(id), {\n        active: true,\n      });\n      updating.then(\n        (tab) => {\n          this.log(`Switched to tab: ${tab.id}`);\n        },\n        (error) => {\n          this.log(`Error switching to tab: ${error}`);\n        }\n      );\n    }\n\n    removeTab(id) {\n      this.tabs[id].remove();\n      this.tabs[id] = null;\n    }\n\n    // We use the `browser` object here rather than going into the actual content script\n    // because the content script may have crashed, even never loaded.\n    screenshotActiveTab() {\n      const capturing = browser.tabs.captureVisibleTab({\n        format: \"jpeg\",\n      });\n      capturing.then(this._saveScreenshot.bind(this), (error) =>\n        this.log(error)\n      );\n    }\n\n    _saveScreenshot(imageUri) {\n      const data = imageUri.replace(/^data:image\\/\\w+;base64,/, \"\");\n      this.sendToTerminal(\"/screenshot,\" + data);\n    }\n\n    _rawTextRequest(request_id, mode, url) {\n      this.createNewTab(url, (native_tab) => {\n        this._acknowledgeNewTab({\n          id: native_tab.id,\n          request_id: request_id,\n          raw_text_mode_type: mode.toLowerCase(),\n          start_time: Date.now(),\n        });\n        // Sometimes tabs fail to load for whatever reason. Make sure they get\n        // removed to save RAM in long-lived Browsh HTTP servers\n        setTimeout(() => {\n          if (this.tabs[native_tab.id]) {\n            this.removeTab(native_tab.id);\n          }\n        }, 60000);\n      });\n    }\n\n    toggleUserAgent() {\n      let message;\n      this._is_using_mobile_user_agent = !this._is_using_mobile_user_agent;\n      message = this._is_using_mobile_user_agent\n        ? \"Mobile user agent active\"\n        : \"Desktop user agent active\";\n      this.currentTab().updateStatus(\"info\", message);\n    }\n\n    _addUserAgentListener() {\n      browser.webRequest.onBeforeSendHeaders.addListener(\n        (e) => {\n          if (this._is_using_mobile_user_agent) {\n            e.requestHeaders.forEach((header) => {\n              if (header.name.toLowerCase() == \"user-agent\") {\n                header.value = this.config.mobile_user_agent;\n              }\n            });\n            return {\n              requestHeaders: e.requestHeaders,\n            };\n          }\n        },\n        {\n          urls: [\"*://*/*\"],\n        },\n        [\"blocking\", \"requestHeaders\"]\n      );\n    }\n  };\n"
  },
  {
    "path": "webext/src/dom/commands_mixin.js",
    "content": "import utils from \"utils\";\n\nexport default (MixinBase) =>\n  class extends MixinBase {\n    _handleBackgroundMessage(message) {\n      let input, url, config;\n      const parts = message.split(\",\");\n      const command = parts[0];\n      switch (command) {\n        case \"/config\":\n          config = JSON.parse(utils.rebuildArgsToSingleArg(parts));\n          this._loadConfig(config);\n          break;\n        case \"/request_frame\":\n          this.sendFrame();\n          break;\n        case \"/rebuild_text\":\n          if (this._is_interactive_mode) {\n            this.sendAllBigFrames();\n          }\n          break;\n        case \"/scroll_status\":\n          this._handleScroll(parts[1], parts[2]);\n          break;\n        case \"/tty_size\":\n          this._handleTTYSize(parts[1], parts[2]);\n          break;\n        case \"/stdin\":\n          input = JSON.parse(utils.rebuildArgsToSingleArg(parts));\n          this._handleUserInput(input);\n          break;\n        case \"/input_box\":\n          input = JSON.parse(utils.rebuildArgsToSingleArg(parts));\n          this._handleInputBoxContent(input);\n          break;\n        case \"/url\":\n          url = utils.rebuildArgsToSingleArg(parts);\n          document.location.href = url;\n          break;\n        case \"/history_back\":\n          history.go(-1);\n          break;\n        case \"/window_stop\":\n          window.stop();\n          break;\n        default:\n          this.log(\"Unknown command sent to tab\", message);\n      }\n    }\n\n    _launch() {\n      const mode = this.config.http_server_mode_type;\n      if (mode.includes(\"raw_text_\")) {\n        this._is_raw_text_mode = true;\n        this._is_interactive_mode = false;\n        this._raw_mode_type = mode;\n        this.sendRawText();\n      }\n      if (mode === \"interactive\") {\n        this._is_raw_text_mode = false;\n        this._is_interactive_mode = true;\n        this._setupInteractiveMode();\n      }\n    }\n\n    _loadConfig(config) {\n      this.config = config;\n      this._postSetupConstructor();\n      this._launch();\n    }\n\n    _handleUserInput(input) {\n      this._handleSpecialKeys(input);\n      this._handleCharBasedKeys(input);\n      this._handleMouse(input);\n    }\n\n    _handleSpecialKeys(input) {\n      let state, message;\n      switch (input.key) {\n        case 18: // CTRL+r\n          window.location.reload();\n          break;\n        case 284: // F6\n          state = this.config.browsh.use_experimental_text_visibility;\n          state = !state;\n          this.config.browsh.use_experimental_text_visibility = state;\n          message = state ? \"on\" : \"off\";\n          this.sendMessage(\n            `/status,info,Experimental text visibility: ${message}`\n          );\n          this.sendSmallTextFrame();\n          break;\n      }\n    }\n\n    _handleCharBasedKeys(input) {\n      switch (input.char) {\n        default:\n          this._triggerKeyPress(input);\n      }\n    }\n\n    _handleInputBoxContent(input) {\n      let input_box = document.querySelectorAll(\n        `[data-browsh-id=\"${input.id}\"]`\n      )[0];\n      if (input_box) {\n        input_box.focus();\n        if (input_box.getAttribute(\"role\") == \"textbox\") {\n          input_box.innerHTML = input.text;\n        } else {\n          input_box.value = input.text;\n        }\n      }\n    }\n\n    // TODO: Dragndrop doesn't seem to work :/\n    _handleMouse(input) {\n      switch (input.button) {\n        case 1:\n          this._mouseAction(\"mousemove\", input.mouse_x, input.mouse_y);\n          if (!this._mousedown) {\n            this._mouseAction(\"mousedown\", input.mouse_x, input.mouse_y);\n            setTimeout(() => {\n              this.sendSmallTextFrame();\n            }, 500);\n          }\n          this._mousedown = true;\n          break;\n        case 0:\n          this._mouseAction(\"mousemove\", input.mouse_x, input.mouse_y);\n          if (this._mousedown) {\n            this._mouseAction(\"click\", input.mouse_x, input.mouse_y);\n            this._mouseAction(\"mouseup\", input.mouse_x, input.mouse_y);\n          }\n          this._mousedown = false;\n          break;\n      }\n    }\n\n    _handleTTYSize(x, y) {\n      if (!this._is_first_frame_finished) {\n        this.dimensions.tty.width = parseInt(x);\n        this.dimensions.tty.height = parseInt(y);\n        this.dimensions.update();\n        this.sendAllBigFrames();\n      }\n    }\n\n    _handleScroll(x, y) {\n      this.dimensions.frame.x_scroll = parseInt(x);\n      this.dimensions.frame.y_scroll = parseInt(y);\n      this.dimensions.update();\n      window.scrollTo(\n        this.dimensions.frame.x_scroll / this.dimensions.scale_factor.width,\n        this.dimensions.frame.y_scroll / this.dimensions.scale_factor.height\n      );\n      this._mightSendBigFrames();\n    }\n\n    _triggerKeyPress(key) {\n      let el = document.activeElement;\n      if (el == null) {\n        this.log(\n          `Not pressing '${key.char}(${key.key})' as there is no active element`\n        );\n        return;\n      }\n      const key_object = {\n        key: key.char,\n        keyCode: key.key,\n      };\n      let event_press = new KeyboardEvent(\"keypress\", key_object);\n      let event_down = new KeyboardEvent(\"keydown\", key_object);\n      let event_up = new KeyboardEvent(\"keyup\", key_object);\n      // Generally sending down/up serves more use cases. But default input forms\n      // don't listen for down/up to make the form submit. So this makes the assumption\n      // that it's okay to send ENTER twice to an input box without any serious side\n      // effects.\n      if (key.key === 13 && el.tagName === \"INPUT\") {\n        el.dispatchEvent(event_press);\n      } else {\n        el.dispatchEvent(event_down);\n        el.dispatchEvent(event_up);\n      }\n    }\n\n    _mouseAction(type, x, y) {\n      const [dom_x, dom_y] = this._getDOMCoordsFromMouseCoords(x, y);\n      const element = document.elementFromPoint(\n        dom_x - window.scrollX,\n        dom_y - window.scrollY\n      );\n      element.focus();\n      var clickEvent = document.createEvent(\"MouseEvents\");\n      clickEvent.initMouseEvent(\n        type,\n        true,\n        true,\n        window,\n        0,\n        0,\n        0,\n        dom_x,\n        dom_y,\n        false,\n        false,\n        false,\n        false,\n        0,\n        null\n      );\n      element.dispatchEvent(clickEvent);\n    }\n\n    // The user clicks on a TTY grid which has a significantly lower resolution than the\n    // actual browser window. So we scale the coordinates up as if the user clicked on the\n    // the central \"pixel\" of a TTY cell.\n    //\n    // Furthermore if the TTY click is on a readable character then the click is proxied\n    // to the original position of the character before TextBuilder snapped the character into\n    // position.\n    _getDOMCoordsFromMouseCoords(x, y) {\n      let dom_x, dom_y, char, original_position;\n      const index = y * this.dimensions.frame.width + x;\n      if (this.text_builder.tty_grid.cells[index] !== undefined) {\n        char = this.text_builder.tty_grid.cells[index].rune;\n      } else {\n        char = false;\n      }\n      if (!char || char === \"▄\") {\n        dom_x = x * this.dimensions.char.width;\n        dom_y = y * this.dimensions.char.height;\n      } else {\n        // Recall that text can be shifted from its original position in the browser in order\n        // to snap it consistently to the TTY grid.\n        original_position = this.text_builder.tty_grid.cells[index].dom_coords;\n        dom_x = original_position.x;\n        dom_y = original_position.y;\n      }\n      return [\n        dom_x + this.dimensions.char.width / 2,\n        dom_y + this.dimensions.char.height / 2,\n      ];\n    }\n\n    _sendTabInfo() {\n      const title_object = document.getElementsByTagName(\"title\");\n      let info = {\n        url: document.location.href,\n        title: title_object.length ? title_object[0].innerHTML : \"\",\n      };\n      this.sendMessage(`/tab_info,${JSON.stringify(info)}`);\n    }\n\n    _mightSendBigFrames() {\n      if (this._is_raw_text_mode) {\n        return;\n      }\n      const y_diff =\n        this.dimensions.frame.y_last_big_frame - this.dimensions.frame.y_scroll;\n      const max_y_scroll_without_new_big_frame =\n        (this.dimensions._big_sub_frame_factor - 1) *\n        this.dimensions.tty.height;\n      if (Math.abs(y_diff) > max_y_scroll_without_new_big_frame) {\n        this.log(\n          `Parsing big frames: ` +\n            `previous-y: ${this.dimensions.frame.y_last_big_frame}, ` +\n            `y-scroll: ${this.dimensions.frame.y_scroll}, ` +\n            `diff: ${y_diff}, ` +\n            `max-scroll: ${max_y_scroll_without_new_big_frame} `\n        );\n        this.sendAllBigFrames();\n      }\n    }\n  };\n"
  },
  {
    "path": "webext/src/dom/common_mixin.js",
    "content": "export default (MixinBase) =>\n  class extends MixinBase {\n    constructor() {\n      super();\n      this._is_first_frame_finished = false;\n    }\n\n    sendMessage(message) {\n      if (this.channel == undefined) {\n        return;\n      }\n      this.channel.postMessage(message);\n    }\n\n    log(...messages) {\n      if (this.channel == undefined) {\n        return;\n      }\n      messages.unshift(this.channel.name);\n      this.sendMessage(`/log,${JSON.stringify(messages)}`);\n    }\n\n    logPerformance(work, reference) {\n      let start = performance.now();\n      work();\n      let end = performance.now();\n      let duration = end - start;\n      if (duration > 10) {\n        this.firstFrameLog(`${reference}: ${duration}ms`);\n      }\n    }\n\n    logError(error) {\n      this.log(`'${error.name}' ${error.message}`);\n      this.log(`@${error.fileName}:${error.lineNumber}`);\n      this.log(error.stack);\n    }\n\n    // If you're logging large objects and using a high-ish FPS (<1000ms) then you might\n    // crash the browser. So use this function instead.\n    firstFrameLog(...logs) {\n      if (this._is_first_frame_finished) return;\n      if (DEVELOPMENT) {\n        this.log(logs);\n      }\n    }\n  };\n"
  },
  {
    "path": "webext/src/dom/dimensions.js",
    "content": "import utils from \"utils\";\n\nimport CommonMixin from \"dom/common_mixin\";\n\n// All the various dimensions, sizes, scales, etc\nexport default class extends utils.mixins(CommonMixin) {\n  constructor() {\n    super();\n\n    // ID for element we place in the DOM to measure the size of a single monospace\n    // character.\n    this._measuring_box_id = \"browsh_em_measuring_box\";\n\n    // This used to be dynamically calculated at _calculateCharacterDimensions()\n    // But it proved to be bugy, I think because of a race condition on lightweight sites\n    // where the webextension's CSS wouldn't get applied in time.\n    this._pre_calculated_char = !TEST\n      ? {\n          width: 9,\n          height: 15,\n        }\n      : {\n          width: 1,\n          height: 2,\n        };\n\n    // TODO: WTF is this magic number? The gap between lines?\n    this._char_height_magic_number = !TEST ? 5 : 0;\n\n    // This is the region outside the visible area of the TTY that is pre-parsed and\n    // sent to the TTY to be buffered to support faster scrolling.\n    this._big_sub_frame_factor = 6;\n\n    // The max size in pixels for either the width or height to be for Browsh to parse in\n    // raw text mode.\n    // TODO: Use incremental parses to overcome this limit.\n    this._entire_dom_limit = 30000;\n\n    this.dom = {};\n    this.tty = {};\n    this.frame = {\n      x_scroll: 0,\n      y_scroll: 0,\n      x_last_big_frame: 0,\n      y_last_big_frame: 0,\n    };\n  }\n\n  update() {\n    this._calculateCharacterDimensions();\n    this._updateDOMDimensions();\n    this._calculateScaleFactor();\n    this._updateFrameDimensions();\n    this._notifyBackground();\n  }\n\n  setSubFrameDimensions(size) {\n    this._calculateSmallSubFrame();\n    if (size === \"big\" || size === \"all\") {\n      this._calculateBigSubFrame();\n    }\n    if (size === \"raw_text\") {\n      this._calculateEntireDOMFrames();\n    }\n    // Only the height needs to be even because of the UTF8 half-block trick. A single\n    // TTY cell always contains exactly 2 pseudo pixels.\n    this.frame.sub.height = utils.ensureEven(this.frame.sub.height);\n  }\n\n  // This is the data that is sent with the JSON payload of every frame to the TTY\n  getFrameMeta() {\n    return {\n      sub_left: utils.snap(this.frame.sub.left),\n      sub_top: utils.snap(this.frame.sub.top),\n      sub_width: utils.snap(this.frame.sub.width),\n      sub_height: utils.snap(this.frame.sub.height),\n      total_width: utils.snap(this.frame.width),\n      total_height: utils.snap(this.frame.height),\n    };\n  }\n\n  // This is the sub frame that is the view onto the frame that is visible by the user\n  // in the TTY at any given time.\n  _calculateSmallSubFrame() {\n    this.frame.sub = {\n      left: this.frame.x_scroll,\n      top: this.frame.y_scroll,\n      width: this.tty.width,\n      height: this.tty.height * 2,\n    };\n\n    this._scaleSubFrameToSubDOM();\n  }\n\n  // This is the sub frame that is a few factors bigger than what the user can see\n  // in the TTY.\n  _calculateBigSubFrame() {\n    this.frame.sub = {\n      left: this.frame.x_scroll - this._big_sub_frame_factor * this.tty.width,\n      top:\n        this.frame.y_scroll - this._big_sub_frame_factor * this.tty.height * 2,\n      width: this.tty.width + this._big_sub_frame_factor * 2 * this.tty.width,\n      height:\n        this.tty.height + this._big_sub_frame_factor * 2 * this.tty.height * 2,\n    };\n    this._limitSubFrameDimensions();\n    this._scaleSubFrameToSubDOM();\n  }\n\n  // The raw text frames requested through the Browsh HTTP server need to be built from the\n  // entire DOM, not just a small window onto the DOM.\n  _calculateEntireDOMFrames() {\n    this.dom.sub = {\n      left: 0,\n      top: 0,\n      width: this.dom.width,\n      height: this.dom.height,\n    };\n    if (this.dom.sub.width > this._entire_dom_limit) {\n      this.dom.sub.width = this._entire_dom_limit;\n      this.is_page_truncated = true;\n    }\n    if (this.dom.sub.height > this._entire_dom_limit) {\n      this.dom.sub.height = this._entire_dom_limit;\n      this.is_page_truncated = true;\n    }\n    this.frame.sub = {\n      left: 0,\n      top: 0,\n      width: this.dom.sub.width * this.scale_factor.width,\n      height: this.dom.sub.height * this.scale_factor.height,\n    };\n  }\n\n  _limitSubFrameDimensions() {\n    if (this.frame.sub.left < 0) {\n      this.frame.sub.left = 0;\n    }\n    if (this.frame.sub.top < 0) {\n      this.frame.sub.top = 0;\n    }\n    if (this.frame.sub.width > this.frame.width) {\n      this.frame.sub.width = this.frame.width;\n    }\n    if (this.frame.sub.height > this.frame.height) {\n      this.frame.sub.height = this.frame.height;\n    }\n  }\n\n  _scaleSubFrameToSubDOM() {\n    this.dom.sub = {\n      left: this.frame.sub.left / this.scale_factor.width,\n      top: this.frame.sub.top / this.scale_factor.height,\n      width: this.frame.sub.width / this.scale_factor.width,\n      height: this.frame.sub.height / this.scale_factor.height,\n    };\n  }\n\n  // This is critical in order for the terminal to match the browser as closely as possible.\n  // Ideally we want the browser's window size to be exactly multiples of the terminal's\n  // dimensions. So if the terminal is 80x40 and the font-size is 12px (12x6 pixels), then\n  // the window should be 480x480. Also knowing the precise font-size helps the text builder\n  // map un-snapped text to the best grid cells - grid cells that represent the terminal's\n  // character positions.\n  //\n  // This used to dynamically allocate the character size but it proved to be buggy, both because\n  // of an occasional race condition and because some sites (eg; stackoverflow.com) returned values\n  // over 100.\n  _calculateCharacterDimensions() {\n    if (document.body !== null) {\n      const element = this._getOrCreateMeasuringBox();\n      const dom_rect = element.getBoundingClientRect();\n      if (\n        dom_rect.width != this._pre_calculated_char.width ||\n        dom_rect.height != this._pre_calculated_char.height\n      ) {\n        this.log(\n          `Using char dims ${this._pre_calculated_char.width}x${this._pre_calculated_char.height}`\n        );\n        this.log(`Actual char dims ${dom_rect.width}x${dom_rect.height}`);\n      }\n    }\n    this.char = {\n      width: this._pre_calculated_char.width,\n      height: this._pre_calculated_char.height + this._char_height_magic_number,\n    };\n  }\n\n  // Back when printing was done by physical stamps, it was convention to measure the\n  // font-size using the letter 'M', thus where we get the unit 'em' from. Not that it\n  // should not make any difference to us, but it's nice to keep a tradition.\n  _getOrCreateMeasuringBox() {\n    let measuring_box = this.findMeasuringBox();\n    if (measuring_box) return measuring_box;\n    measuring_box = document.createElement(\"span\");\n    measuring_box.id = this._measuring_box_id;\n    measuring_box.style.visibility = \"hidden\";\n    var M = document.createTextNode(\"M\");\n    measuring_box.appendChild(M);\n    document.body.appendChild(measuring_box);\n    return measuring_box;\n  }\n\n  findMeasuringBox() {\n    return document.getElementById(this._measuring_box_id);\n  }\n\n  _updateDOMDimensions() {\n    const [new_width, new_height] = this._calculateDOMDimensions();\n    const is_new = this.dom.width != new_width || this.dom.height != new_height;\n    this.dom = {\n      sub: this.dom.sub,\n      width: new_width,\n      height: new_height,\n      is_new: is_new,\n    };\n  }\n\n  // For discussion on various methods to get total scrollable DOM dimensions, see:\n  // https://stackoverflow.com/a/44077777/575773\n  _calculateDOMDimensions() {\n    let width = document.documentElement.scrollWidth;\n    if (window.innerWidth > width) width = window.innerWidth;\n    let height = document.documentElement.scrollHeight;\n    if (window.innerHeight > height) height = window.innerHeight;\n    return [width, height];\n  }\n\n  // A frame represents the entire DOM page. Its height usually extends below the window's\n  // bottom and occasionally extends beyond the sides too.\n  //\n  // Note that it treats the height of a single TTY cell as containing 2 pixels. Therefore\n  // a TTY of 4x4 will have frame dimensions of 4x8.\n  _updateFrameDimensions() {\n    let width = this.dom.width * this.scale_factor.width;\n    let height = this.dom.height * this.scale_factor.height;\n    this.frame.width = utils.snap(width);\n    this.frame.height = utils.snap(height);\n  }\n\n  // The scale factor is the ratio of the TTY's representation of the DOM to the browser's\n  // representation of the DOM. The idea is that the TTY just represents a very low\n  // resolution version of the browser - though note that the TTY has the significant\n  // benefit of being able to display native fonts (possibly even retina-like high DPI\n  // fonts). So Browsh's enforced CSS rules reorient the browser page to render all text\n  // at the same monospaced sized - in this sense, theoretically, the TTY and the browser\n  // should essentially be facsimilies of each other. However of course the TTY is limited\n  // by its cell size in how it renders \"pixels\", namely pseudo pixels using the UTF8\n  // block trick.\n  //\n  // All of which is to say that the fundamental relationship between the browser's dimensions\n  // and the TTY's dimensions is represented by a TTY cell - that which displays a single\n  // character. So if we know how many characters fit into the DOM, then we know how many\n  // \"pixels\" the TTY should have.\n  _calculateScaleFactor() {\n    this.scale_factor = {\n      width: 1 / this.char.width,\n      // Recall that 2 UTF8 half-black \"pixels\" can fit into a single TTY cell\n      height: 2 / this.char.height,\n    };\n  }\n\n  _notifyBackground() {\n    const dimensions = {\n      dom: this.dom,\n      frame: this.frame,\n      char: this.char,\n    };\n    this.sendMessage(`/dimensions,${JSON.stringify(dimensions)}`);\n  }\n}\n"
  },
  {
    "path": "webext/src/dom/graphics_builder.js",
    "content": "import utils from \"utils\";\n\nimport CommonMixin from \"dom/common_mixin\";\n\n// Converts an instance of the visible DOM into an array of pixel values.\n// Note that it does this both with and without the text visible in order\n// to aid in a clean separation of the graphics and text in the final frame\n// rendered in the terminal.\nexport default class extends utils.mixins(CommonMixin) {\n  constructor(channel, dimensions, config) {\n    super();\n    this.channel = channel;\n    this.dimensions = dimensions;\n    this.config = config;\n    this._html_image_compression = this.config[\"http-server\"].jpeg_compression;\n    this._screenshot_canvas = document.createElement(\"canvas\");\n    this._converter_canvas = document.createElement(\"canvas\");\n    this._screenshot_ctx = this._screenshot_canvas.getContext(\"2d\");\n    this._converter_ctx = this._converter_canvas.getContext(\"2d\");\n  }\n\n  sendFrame() {\n    this.__getScaledScreenshot();\n    this._sendFrame();\n  }\n\n  // With full-block single-glyph font on\n  getUnscaledFGPixelAt(x, y) {\n    [x, y] = this._convertDOMCoordsToRelative(x, y);\n    if (x === null || y === null) {\n      return [null, null, null];\n    }\n    const width = this.dimensions.dom.sub.width;\n    const pixel_data_start = parseInt(y * width * 4 + x * 4);\n    let fg_rgb = this.pixels_with_text.slice(\n      pixel_data_start,\n      pixel_data_start + 3\n    );\n    return [fg_rgb[0], fg_rgb[1], fg_rgb[2]];\n  }\n\n  // Without any text showing at all\n  getUnscaledBGPixelAt(x, y) {\n    [x, y] = this._convertDOMCoordsToRelative(x, y);\n    if (x === null || y === null) {\n      return [null, null, null];\n    }\n    const width = this.dimensions.dom.sub.width;\n    const pixel_data_start = parseInt(y * width * 4 + x * 4);\n    let bg_rgb = this.pixels_without_text.slice(\n      pixel_data_start,\n      pixel_data_start + 3\n    );\n    return [bg_rgb[0], bg_rgb[1], bg_rgb[2]];\n  }\n\n  getScreenshotWithText(callback) {\n    this.logPerformance(() => {\n      this._getScreenshotWithText(callback);\n    }, \"get screenshot with text\");\n  }\n\n  getScreenshotWithoutText() {\n    this.logPerformance(() => {\n      this._getScreenshotWithoutText();\n    }, \"get screenshot without text\");\n  }\n\n  getOnOffScreenshots(callback) {\n    this.getScreenshotWithoutText();\n    this.getScreenshotWithText(callback);\n  }\n\n  _getScreenshotWithoutText() {\n    this.pixels_without_text = this._getScreenshot().data;\n    return this.pixels_without_text;\n  }\n\n  _getScreenshotWithText(callback) {\n    this.showText();\n    if (this.config[\"http-server-mode\"]) {\n      // It's a little odd that `config['http-server'].render_delay` is named as such\n      // and placed here of all places. But the fact is that a delay is needed here\n      // *anyway* and extending the delay kills 2 birds with one stone. Firstly solving\n      // this tricky little need-to-wait-for-the-font-to-render issue *and* solving the\n      // the fact that some pages just don't finish loading at `windows.onload()`.\n      setTimeout(() => {\n        this._getScreenshotWithTextDelayable(callback);\n      }, this.config[\"http-server\"].render_delay);\n    } else {\n      this._getScreenshotWithTextDelayable(callback);\n    }\n  }\n\n  // I'm not entirely clear on the reason, but when a Browsh tab's only purpose is\n  // to render a single frame (such as in the HTTP service), it needs a few milliseconds\n  // to show the text for the first time. My only theory is that at page load some time\n  // is needed to parse and render the font.\n  // However in normal TTY mode, no such delay is needed, indeed even placing this\n  // function inside `setTimeout()` causes oddities.\n  _getScreenshotWithTextDelayable(callback) {\n    this.pixels_with_text = this._getScreenshot().data;\n    this.hideText();\n    callback();\n  }\n\n  _getScaledScreenshot() {\n    this._scaleCanvas();\n    this.scaled_pixels_image_object = this._getScreenshot();\n    this.scaled_pixels = this.scaled_pixels_image_object.data;\n    this._unScaleCanvas();\n    return this.scaled_pixels;\n  }\n\n  // It's either convert coords to relative in this class or TextBuilder. On balance it\n  // seems better to retain TextBuilder's reference in absolute coords, thus somewhat\n  // hiding the overhead of relative-to-the-frame coords in public methods.\n  _convertDOMCoordsToRelative(x, y) {\n    const top = this.dimensions.dom.sub.top;\n    const bottom = this.dimensions.dom.sub.top + this.dimensions.dom.sub.height;\n    const left = this.dimensions.dom.sub.left;\n    const right = this.dimensions.dom.sub.left + this.dimensions.dom.sub.width;\n    if (x >= left && x < right) {\n      x -= this.dimensions.dom.sub.left;\n    } else {\n      x = null;\n    }\n    if (y >= top && y < bottom) {\n      y -= this.dimensions.dom.sub.top;\n    } else {\n      y = null;\n    }\n    return [x, y];\n  }\n\n  // Scaled to the size where each pixel is the same size as a TTY cell\n  _getScaledPixelAt(x, y) {\n    const width = this.dimensions.frame.sub.width;\n    const pixel_data_start = y * width * 4 + x * 4;\n    const rgb = this.scaled_pixels.slice(\n      pixel_data_start,\n      pixel_data_start + 3\n    );\n    return [rgb[0], rgb[1], rgb[2]];\n  }\n\n  __getScaledScreenshot() {\n    this.logPerformance(() => {\n      this._getScaledScreenshot();\n    }, \"get scaled screenshot\");\n  }\n\n  hideText() {\n    document.body.classList.remove(\"browsh-show-text\");\n    document.body.classList.add(\"browsh-hide-text\");\n  }\n\n  showText() {\n    document.body.classList.remove(\"browsh-hide-text\");\n    document.body.classList.add(\"browsh-show-text\");\n  }\n\n  _getScreenshot() {\n    return this._getPixelData();\n  }\n\n  // Scale the screenshot so that 1 pixel approximates half a TTY cell.\n  _scaleCanvas() {\n    this._is_scaled = true;\n    this._screenshot_ctx.save();\n    this._screenshot_ctx.scale(\n      this.dimensions.scale_factor.width,\n      this.dimensions.scale_factor.height\n    );\n  }\n\n  _unScaleCanvas() {\n    this._screenshot_ctx.restore();\n    this._is_scaled = false;\n  }\n\n  _updateCanvasSize() {\n    if (this._is_scaled) return;\n    this._screenshot_canvas.width = this.dimensions.dom.sub.width;\n    this._screenshot_canvas.height = this.dimensions.dom.sub.height;\n  }\n\n  // Get an array of RGB values.\n  // This is Firefox-only. Chrome has a nicer MediaStream for this.\n  _getPixelData() {\n    let width, height;\n    const background_colour = \"rgb(255,255,255)\";\n    if (this._is_scaled) {\n      width = this.dimensions.frame.sub.width;\n      height = this.dimensions.frame.sub.height;\n    } else {\n      width = this.dimensions.dom.sub.width;\n      height = this.dimensions.dom.sub.height;\n    }\n    if (width <= 0 || height <= 0) {\n      return [];\n    }\n    this._updateCanvasSize();\n    this._screenshot_ctx.drawWindow(\n      window,\n      this.dimensions.dom.sub.left,\n      this.dimensions.dom.sub.top,\n      this.dimensions.dom.sub.width,\n      this.dimensions.dom.sub.height,\n      background_colour\n    );\n    return this._screenshot_ctx.getImageData(0, 0, width, height);\n  }\n\n  // Return the scaled screenshot as a data URI to display in HTML\n  _getScaledDataURI() {\n    this.__getScaledScreenshot();\n    this._converter_canvas.width = this.dimensions.frame.sub.width;\n    this._converter_canvas.height = this.dimensions.frame.sub.height;\n    this._converter_ctx.putImageData(this.scaled_pixels_image_object, 0, 0);\n    return this._converter_canvas.toDataURL(\n      \"image/jpeg\",\n      this._html_image_compression\n    );\n  }\n\n  _sendFrame() {\n    this._serialiseFrame();\n    if (this.frame.colours.length > 0) {\n      this.sendMessage(`/frame_pixels,${JSON.stringify(this.frame)}`);\n    } else {\n      this.log(\"Not sending empty pixels frame\");\n    }\n  }\n\n  _serialiseFrame() {\n    this._setupFrameMeta();\n    const width = this.dimensions.frame.sub.width;\n    const height = this.dimensions.frame.sub.height;\n    for (let y = 0; y < height; y++) {\n      for (let x = 0; x < width; x++) {\n        // TODO: Explore sending as binary data\n        this._getScaledPixelAt(x, y).map((c) => this.frame.colours.push(c));\n      }\n    }\n  }\n\n  _setupFrameMeta() {\n    this.frame = {\n      meta: this.dimensions.getFrameMeta(),\n      colours: [],\n    };\n    this.frame.meta.id = parseInt(this.channel.name);\n  }\n}\n"
  },
  {
    "path": "webext/src/dom/manager.js",
    "content": "import _ from \"lodash\";\n\nimport utils from \"utils\";\n\nimport CommonMixin from \"dom/common_mixin\";\nimport CommandsMixin from \"dom/commands_mixin\";\nimport Dimensions from \"dom/dimensions\";\nimport GraphicsBuilder from \"dom/graphics_builder\";\nimport TextBuilder from \"dom/text_builder\";\n\n// Entrypoint for managing a single tab\nexport default class extends utils.mixins(CommonMixin, CommandsMixin) {\n  constructor() {\n    super();\n    this.dimensions = new Dimensions();\n    // Whether the DOM has loaded\n    this.is_dom_loaded = false;\n    // Whether the page has finished \"spinning\"\n    this.is_page_finished_loading = false;\n    // For Browsh used via the interactive CLI ap\n    this._is_interactive_mode = false;\n    // For Browsh used via the HTTP server\n    this._is_raw_mode = false;\n    this._setupInit();\n  }\n\n  _postSetupConstructor() {\n    this._injectCustomCSS();\n    this.dimensions.channel = this.channel;\n    this.graphics_builder = new GraphicsBuilder(\n      this.channel,\n      this.dimensions,\n      this.config\n    );\n    this.text_builder = new TextBuilder(\n      this.channel,\n      this.dimensions,\n      this.graphics_builder,\n      this.config\n    );\n  }\n\n  _willHideText() {\n    if (this.is_dom_loaded && this.graphics_builder) {\n      this.graphics_builder.hideText();\n    } else {\n      setTimeout(this._willHideText.bind(this), 1);\n    }\n  }\n\n  sendFrame() {\n    this.dimensions.update();\n    if (this.dimensions.dom.is_new) {\n      this.sendAllBigFrames();\n    }\n    this.sendSmallPixelFrame();\n    this._sendTabInfo();\n    if (!this._is_first_frame_finished) {\n      this.sendMessage(\"/status,parsing_complete\");\n    }\n    this._is_first_frame_finished = true;\n  }\n\n  sendAllBigFrames() {\n    if (!this._is_interactive_mode) {\n      return;\n    }\n    if (!this.dimensions.tty.width) {\n      this.log(\"Not sending big frames without TTY data\");\n      return;\n    } else {\n      this.log(\"Sending big frames...\");\n    }\n    this.dimensions.update();\n    this.dimensions.setSubFrameDimensions(\"big\");\n    this.text_builder.sendFrame();\n    this.graphics_builder.sendFrame();\n    this.dimensions.frame.x_last_big_frame = this.dimensions.frame.x_scroll;\n    this.dimensions.frame.y_last_big_frame = this.dimensions.frame.y_scroll;\n  }\n\n  sendRawText() {\n    if (this.is_page_finished_loading) {\n      this.dimensions.update();\n      this.dimensions.setSubFrameDimensions(\"raw_text\");\n      this.text_builder.sendRawText(this._raw_mode_type);\n    } else {\n      setTimeout(this.sendRawText.bind(this), 1);\n    }\n  }\n\n  sendSmallPixelFrame() {\n    if (!this._is_interactive_mode) {\n      return;\n    }\n    if (!this.dimensions.tty.width) {\n      this.log(\"Not sending small frames without TTY data\");\n      return;\n    }\n    this.dimensions.update();\n    this.dimensions.setSubFrameDimensions(\"small\");\n    this.graphics_builder.sendFrame();\n  }\n\n  sendSmallTextFrame() {\n    if (!this._is_interactive_mode) {\n      return;\n    }\n    if (!this.dimensions.tty.width) {\n      this.log(\"Not sending small frames without TTY data\");\n      return;\n    }\n    this.dimensions.update();\n    this.dimensions.setSubFrameDimensions(\"small\");\n    this.text_builder.sendFrame();\n  }\n\n  _postCommsInit() {\n    this.log(\"Webextension postCommsInit()\");\n    this._sendTabInfo();\n    this.sendMessage(\"/status,page_init\");\n    this._listenForBackgroundMessages();\n    this._startWindowEventListeners();\n  }\n\n  // Fire up the TTY interactive mode. It doesn't need to wait for any particular\n  // DOM stage as it's good to just get something in front of the user as soon\n  // as possible.\n  _setupInteractiveMode() {\n    this._setupDebouncedFunctions();\n    this._startMutationObserver();\n    this.sendAllBigFrames();\n    // TODO:\n    //   Disabling CSS transitions is not easy, many pages won't even render\n    //   if they're disabled. Eg; Google's login process.\n    //   What if we could get a post-transition hook?\n    setTimeout(() => {\n      this.sendAllBigFrames();\n    }, 500);\n  }\n\n  _setupDebouncedFunctions() {\n    this._debouncedSmallTextFrame = _.debounce(this.sendSmallTextFrame, 100, {\n      leading: true,\n    });\n  }\n\n  _setupInit() {\n    if (this._isWindowAlreadyLoaded()) {\n      this._init(100);\n    } else {\n      this._init();\n    }\n  }\n\n  _isWindowAlreadyLoaded() {\n    if (document.body === undefined) {\n      return false;\n    }\n    return !!this.dimensions.findMeasuringBox();\n  }\n\n  _init(delay = 0) {\n    // When the webext devtools auto reloads this code, the background process\n    // can sometimes still be loading, in which case we need to wait.\n    setTimeout(() => this._registerWithBackground(), delay);\n  }\n\n  _registerWithBackground() {\n    let sending = browser.runtime.sendMessage(\"/register\");\n    sending.then(\n      (r) => this._registrationSuccess(r),\n      (e) => this._registrationError(e)\n    );\n  }\n\n  _registrationSuccess(registered) {\n    this.channel = browser.runtime.connect({\n      // We need to give ourselves a unique channel name, so the background\n      // process can identify us amongst other tabs.\n      name: registered.id.toString(),\n    });\n    this._postCommsInit();\n  }\n\n  _registrationError(error) {\n    this.log(error);\n  }\n\n  _startWindowEventListeners() {\n    window.addEventListener(\"DOMContentLoaded\", () => {\n      this.is_dom_loaded = true;\n      this.log(\"DOM LOADED\");\n      this._fixStickyElements();\n      this._willHideText();\n    });\n    window.addEventListener(\"load\", () => {\n      this.is_page_finished_loading = true;\n      this.config.page_load_duration = Date.now() - this.config.start_time;\n      this.log(\"PAGE LOADED\");\n    });\n    window.addEventListener(\"unload\", () => {\n      this.sendMessage(\"/status,window_unload\");\n    });\n    window.addEventListener(\"error\", (error) => {\n      this.logError(error);\n    });\n  }\n\n  _startMutationObserver() {\n    let target = document.querySelector(\"body\");\n    let observer = new MutationObserver((mutations) => {\n      mutations.forEach((mutation) => {\n        this.log(\"!!MUTATION!!\", mutation);\n        this._debouncedSmallTextFrame();\n      });\n    });\n    observer.observe(target, {\n      subtree: true,\n      characterData: true,\n      childList: true,\n    });\n  }\n\n  _listenForBackgroundMessages() {\n    this.channel.onMessage.addListener((message) => {\n      try {\n        this._handleBackgroundMessage(message);\n      } catch (error) {\n        this.logError(error);\n      }\n    });\n  }\n\n  // Sticky elements are, for example, those headers that follow you down the page as you\n  // scroll. They are annoying even in desktop browsers, however because of the lower frame\n  // rate of Browsh, sticky elements stutter down the page, so it's even more annoying. Not\n  // to mention the screen real estate that sticky elements take up, which is even more\n  // noticeable on a small TTY screen like Browsh's.\n  //\n  // Note that this uses `getComputedStyle()`, which can be expensive, there should only\n  // be 1 that parses that entire tree during page load. So if there's reason to use more\n  // CSS tricks like this, then the call should be refactored.\n  _fixStickyElements() {\n    let position;\n    let i,\n      elements = document.querySelectorAll(\"body *\");\n    for (i = 0; i < elements.length; i++) {\n      position = getComputedStyle(elements[i]).position;\n      if (position === \"fixed\" || position === \"sticky\") {\n        elements[i].style.setProperty(\"position\", \"absolute\", \"important\");\n      }\n    }\n  }\n\n  _injectCustomCSS() {\n    var node = document.createElement(\"style\");\n    node.innerHTML = this.config.browsh.custom_css;\n    if (document.body) {\n      document.body.appendChild(node);\n    }\n  }\n}\n"
  },
  {
    "path": "webext/src/dom/serialise_mixin.js",
    "content": "import utils from \"utils\";\n\nexport default (MixinBase) =>\n  class extends MixinBase {\n    __serialiseFrame() {\n      let cell, index;\n      const top = this.dimensions.frame.sub.top / 2;\n      const left = this.dimensions.frame.sub.left;\n      const bottom = top + this.dimensions.frame.sub.height / 2;\n      const right = left + this.dimensions.frame.sub.width;\n      this._setupFrameMeta();\n      this._serialiseInputBoxes();\n      for (let y = top; y < bottom; y++) {\n        for (let x = left; x < right; x++) {\n          index = y * this.dimensions.frame.width + x;\n          cell = this.tty_grid.cells[index];\n          if (cell === undefined) {\n            this.frame.colours.push(0);\n            this.frame.colours.push(0);\n            this.frame.colours.push(0);\n            this.frame.text.push(\"\");\n          } else {\n            cell.fg_colour.map((c) => this.frame.colours.push(c));\n            this.frame.text.push(cell.rune);\n          }\n        }\n      }\n    }\n\n    _serialiseRawText() {\n      let raw_text = \"\";\n      this._previous_cell_href = \"\";\n      this._is_inside_anchor = false;\n      const top = this.dimensions.frame.sub.top / 2;\n      const left = this.dimensions.frame.sub.left;\n      const bottom = top + this.dimensions.frame.sub.height / 2;\n      const right = left + this.dimensions.frame.sub.width;\n      for (let y = top; y < bottom; y++) {\n        for (let x = left; x < right; x++) {\n          raw_text += this._addCell(x, y, right);\n        }\n        raw_text += \"\\n\";\n      }\n      return this._wrap(raw_text);\n    }\n\n    _wrap(raw_text) {\n      let head;\n      head =\n        this._raw_mode_type === \"raw_text_html\"\n          ? this._getHTMLHead()\n          : this._getUserHeader();\n      return head + raw_text + this._getFooter();\n    }\n\n    // Whether a use has shown support. This controls certain Browsh branding and\n    // nags to donate.\n    userHasShownSupport() {\n      return (\n        this.config.browsh_supporter === \"I have shown my support for Browsh\"\n      );\n    }\n\n    _byBrowsh() {\n      let by;\n      if (this.userHasShownSupport()) {\n        return \"\";\n      }\n      by =\n        this._raw_mode_type === \"raw_text_html\"\n          ? 'by <a href=\"https://www.brow.sh\">Browsh</a> v'\n          : \"by Browsh v\";\n      return by + this.config.browsh_version + \" \";\n    }\n\n    _getUserFooter() {\n      return \"\\n\" + this.config[\"http-server\"].footer;\n    }\n\n    _getUserHeader() {\n      return this.config[\"http-server\"].header + \"\\n\";\n    }\n\n    _getMetaData() {\n      let metadata = \"\";\n      this._markParsingDuration();\n      const date_time = this._getCurrentDataTime();\n      const elapsed = `${this._parsing_duration}ms`;\n      metadata +=\n        \"\\n\\n\" + `Built ` + this._byBrowsh() + `on ${date_time} in ${elapsed}.`;\n      if (this.dimensions.is_page_truncated) {\n        metadata +=\n          \"\\nBrowsh parser: the page was too large, some text may have been truncated.\";\n      }\n      return metadata;\n    }\n\n    _getDonateCall() {\n      let donating;\n      if (this.userHasShownSupport()) {\n        return \"\";\n      }\n      donating =\n        this._raw_mode_type === \"raw_text_html\"\n          ? '<a href=\"https://www.brow.sh/donate\">donating</a>'\n          : \"https://brow.sh/donate\";\n      return (\n        \"\\nPlease consider \" +\n        donating +\n        \" to help all those with slow and/or expensive internet.\"\n      );\n    }\n\n    _getFooter() {\n      let start, end;\n      if (this._raw_mode_type === \"raw_text_html\") {\n        start = '<span class=\"browsh-footer\">';\n        end = \"</span></pre></body></html>\";\n      } else {\n        start = \"\";\n        end = \"\";\n      }\n      return (\n        start +\n        this._getMetaData() +\n        this._getDonateCall() +\n        this._getUserFooter() +\n        end\n      );\n    }\n\n    _getHTMLHead() {\n      const img_src = this.graphics_builder._getScaledDataURI();\n      const width = this.dimensions.dom.sub.width;\n      const height = this.dimensions.dom.sub.height;\n      return `<html>\n     <head>\n       ${this._getFavicon()}\n       <title>${document.title}</title>\n       <style>\n        html * {\n         font-family: 'Courier New', monospace;\n        }\n        body {\n          font-size: 15px;\n        }\n        pre {\n          background-image: url(${img_src});\n          background-repeat: no-repeat;\n          background-size: ${width}px ${height}px;\n          /* Pixelate the background image */\n          image-rendering: -moz-crisp-edges;          /* Firefox                        */\n          image-rendering: -o-crisp-edges;            /* Opera                          */\n          image-rendering: -webkit-optimize-contrast; /* Chrome (and eventually Safari) */\n          image-rendering: pixelated;                 /* Chrome                         */\n          -ms-interpolation-mode: nearest-neighbor;   /* IE8+                           */\n          width: ${width}px;\n          height: ${height}px;\n          /* These styles need to exactly follow Browsh's rendering styles */\n          font-size: 15px !important;\n          line-height: 20px !important;\n          letter-spacing: 0px !important;\n          font-style: normal !important;\n          font-weight: normal !important;\n        }\n        .browsh-footer {\n          opacity: 0.7;\n        }\n       </style>\n     </head>\n     <body>\n     ${this._getUserHeader()}\n     <pre>`;\n    }\n\n    _getFavicon() {\n      let el = document.querySelector(\"link[rel*='icon']\");\n      if (el) {\n        return `<link rel=\"shortcut icon\" type = \"image/x-icon\" href=\"${el.href}\">`;\n      } else {\n        return \"\";\n      }\n    }\n\n    _markParsingDuration() {\n      this._parsing_duration = performance.now() - this._parse_start_time;\n    }\n\n    _getCurrentDataTime() {\n      let current_date = new Date();\n      const offset = -(new Date().getTimezoneOffset() / 60);\n      const sign = offset > 0 ? \"+\" : \"-\";\n      let date_time =\n        current_date.getDate() +\n        \"/\" +\n        (current_date.getMonth() + 1) +\n        \"/\" +\n        current_date.getFullYear() +\n        \"@\" +\n        current_date.getHours() +\n        \":\" +\n        current_date.getMinutes() +\n        \":\" +\n        current_date.getSeconds() +\n        \" \" +\n        \"UTC\" +\n        sign +\n        offset +\n        \" (\" +\n        Intl.DateTimeFormat().resolvedOptions().timeZone +\n        \")\";\n      return date_time;\n    }\n\n    // TODO: Ultimately we're going to need to know exactly which parts of the input\n    //       box are obscured. This is partly possible using the element's computed\n    //       styles, however this isn't comprehensive - think partially obscuring.\n    //       So the best solution is to use the same trick as we do for normal text,\n    //       except that we can't fill the input box with text, however we can\n    //       temporarily change the background to a contrasting colour.\n    _getAllInputBoxes() {\n      let dom_rect, styles, font_rgb;\n      let parsed_input_boxes = {};\n      let raw_input_boxes = document.querySelectorAll(\n        \"input, \" + \"textarea, \" + '[role=\"textbox\"]'\n      );\n      raw_input_boxes.forEach((i) => {\n        let type;\n        this._ensureBrowshID(i);\n        dom_rect = this._convertDOMRectToAbsoluteCoords(\n          i.getBoundingClientRect()\n        );\n        const width = utils.snap(\n          dom_rect.width * this.dimensions.scale_factor.width\n        );\n        const height = utils.snap(\n          dom_rect.height * this.dimensions.scale_factor.height\n        );\n        if (width == 0 || height == 0) {\n          return;\n        }\n        type =\n          i.getAttribute(\"role\") == \"textbox\"\n            ? \"textbox\"\n            : i.getAttribute(\"type\");\n        styles = window.getComputedStyle(i);\n        font_rgb = styles[\"color\"]\n          .replace(/[^\\d,]/g, \"\")\n          .split(\",\")\n          .map((i) => parseInt(i));\n        const padding_top = parseInt(styles[\"padding-top\"].replace(\"px\", \"\"));\n        const padding_left = parseInt(styles[\"padding-left\"].replace(\"px\", \"\"));\n        if (this._isUnwantedInboxBox(i, styles)) {\n          return;\n        }\n        parsed_input_boxes[i.getAttribute(\"data-browsh-id\")] = {\n          id: i.getAttribute(\"data-browsh-id\"),\n          x: utils.snap(\n            (dom_rect.left + padding_left) * this.dimensions.scale_factor.width\n          ),\n          y: utils.snap(\n            (dom_rect.top + padding_top) * this.dimensions.scale_factor.height\n          ),\n          width: width,\n          height: height,\n          tag_name: i.nodeName,\n          type: type,\n          colour: [font_rgb[0], font_rgb[1], font_rgb[2]],\n        };\n      });\n      return parsed_input_boxes;\n    }\n\n    _ensureBrowshID(element) {\n      if (element.getAttribute(\"data-browsh-id\") === null) {\n        element.setAttribute(\"data-browsh-id\", utils.uuidv4());\n      }\n    }\n\n    _isUnwantedInboxBox(input_box, styles) {\n      return (\n        styles.display === \"none\" ||\n        styles.visibility === \"hidden\" ||\n        input_box.getAttribute(\"aria-hidden\") == \"true\"\n      );\n    }\n\n    _sendRawText() {\n      let body;\n      if (this._raw_mode_type == \"raw_text_dom\") {\n        body =\n          \"<html>\" +\n          document.getElementsByTagName(\"html\")[0].innerHTML +\n          \"</html>\";\n      } else {\n        body = this._serialiseRawText();\n      }\n      let payload = {\n        body: body,\n        page_load_duration: this.config.page_load_duration,\n        parsing_duration: this._parsing_duration,\n      };\n      this.sendMessage(`/raw_text,${JSON.stringify(payload)}`);\n    }\n\n    _sendFrame() {\n      this._serialiseFrame();\n      if (this.frame.text.length > 0) {\n        this.sendMessage(`/frame_text,${JSON.stringify(this.frame)}`);\n      } else {\n        this.log(\"Not sending empty text frame\");\n      }\n    }\n\n    _addCell(x, y, right) {\n      let text = \"\";\n      const index = y * this.dimensions.frame.width + x;\n      this._cell_for_raw_text = this.tty_grid.cells[index];\n      if (this._raw_mode_type === \"raw_text_html\") {\n        this._is_line_end = x === right - 1;\n        text += this._addCellAsHTML();\n      } else {\n        text += this._addCellAsPlainText();\n      }\n      return text;\n    }\n\n    _addCellAsHTML() {\n      this._HTML = \"\";\n      if (!this._cell_for_raw_text) {\n        this._addHTMLForNonExistentCell();\n      } else {\n        this._current_cell_href = this._cell_for_raw_text.parent_element.href;\n        this._is_HREF_changed =\n          this._current_cell_href !== this._previous_cell_href;\n        this._handleCellOutsideAnchor();\n        this._handleCellInsideAnchor();\n        this._HTML += this._cell_for_raw_text.rune;\n        this._previous_cell_href = this._current_cell_href;\n      }\n      if (this._will_be_inside_anchor !== undefined) {\n        this._is_inside_anchor = this._will_be_inside_anchor;\n      }\n      return this._HTML;\n    }\n\n    _addHTMLForNonExistentCell() {\n      if (this._is_inside_anchor) {\n        this._previous_cell_href = undefined;\n        this._closeAnchorTag();\n      }\n      this._HTML += \" \";\n    }\n\n    _handleCellOutsideAnchor() {\n      if (this._is_inside_anchor) {\n        return;\n      }\n      if (this._current_cell_href || this._is_HREF_changed) {\n        this._openAnchorTag();\n      }\n    }\n\n    _handleCellInsideAnchor() {\n      if (!this._is_inside_anchor) {\n        return;\n      }\n      if (\n        this._is_HREF_changed ||\n        !this._current_cell_href ||\n        this._is_line_end\n      ) {\n        this._closeAnchorTag();\n        if (this._current_cell_href) {\n          this._openAnchorTag();\n        }\n      }\n    }\n\n    _openAnchorTag() {\n      this._will_be_inside_anchor = true;\n      this._HTML += `<a href=\"/${this._current_cell_href}\">`;\n    }\n\n    _closeAnchorTag() {\n      this._will_be_inside_anchor = false;\n      this._HTML += `</a>`;\n    }\n\n    _addCellAsPlainText() {\n      if (this._cell_for_raw_text === undefined) {\n        return \" \";\n      }\n      return this._cell_for_raw_text.rune;\n    }\n\n    _setupFrameMeta() {\n      this.frame = {\n        meta: this.dimensions.getFrameMeta(),\n        text: [],\n        colours: [],\n      };\n      this.frame.meta.id = parseInt(this.channel.name);\n    }\n\n    _serialiseInputBoxes() {\n      this.frame.input_boxes = this._getAllInputBoxes();\n    }\n  };\n"
  },
  {
    "path": "webext/src/dom/text_builder.js",
    "content": "import _ from \"lodash\";\n\nimport utils from \"utils\";\nimport CommonMixin from \"dom/common_mixin\";\nimport SerialiseMixin from \"dom/serialise_mixin\";\nimport TTYCell from \"dom/tty_cell\";\nimport TTYGrid from \"dom/tty_grid\";\n\n// Convert the text on the page into a snapped 2-dimensional grid to be displayed directly\n// in the terminal.\nexport default class extends utils.mixins(CommonMixin, SerialiseMixin) {\n  constructor(channel, dimensions, graphics_builder, config) {\n    super();\n    this.channel = channel;\n    this.dimensions = dimensions;\n    this.graphics_builder = graphics_builder;\n    this.config = config;\n    this.tty_grid = new TTYGrid(dimensions, graphics_builder, config);\n    this._parse_started_elements = [];\n    // A `range` is the DOM's representation of elements and nodes as they are rendered in\n    // the DOM. Think of the 'range' that is created when you select/highlight text for\n    // copy-pasting, those usually blue-ish rectangles around the selected text are ranges.\n    this._range = document.createRange();\n  }\n\n  sendFrame() {\n    this.buildFormattedText(this._sendFrame.bind(this));\n  }\n\n  sendRawText(type) {\n    this._raw_mode_type = type;\n    this._parse_start_time = performance.now();\n    if (type == \"raw_text_dom\") {\n      setTimeout(() => {\n        this._sendRawText();\n      }, this.config[\"http-server\"].render_delay);\n    } else {\n      this.buildFormattedText(this._sendRawText.bind(this));\n    }\n  }\n\n  buildFormattedText(callback) {\n    this._updateState();\n    this.graphics_builder.getOnOffScreenshots(() => {\n      this.dimensions.update();\n      this._getTextNodes();\n      this._positionTextNodes();\n      callback();\n    });\n  }\n\n  _updateState() {\n    this.tty_grid.cells = [];\n    this._parse_started_elements = [];\n    this._previous_dom_box = {};\n    this._convertSubFrameToViewportCoords();\n  }\n\n  // This is relatively cheap: around 50ms for a 13,000 word Wikipedia page\n  _getTextNodes() {\n    this.logPerformance(() => {\n      this.__getTextNodes();\n    }, \"tree walker\");\n  }\n\n  // This should be around ?? for a largish Wikipedia page of 13,000 words\n  _positionTextNodes() {\n    this.logPerformance(() => {\n      this.__positionTextNodes();\n    }, \"position text nodes\");\n  }\n\n  _serialiseFrame() {\n    this.logPerformance(() => {\n      this.__serialiseFrame();\n    }, \"serialise text frame\");\n  }\n\n  // Search through every node in the DOM looking for displayable text.\n  __getTextNodes() {\n    this._text_nodes = [];\n    const walker = document.createTreeWalker(\n      document.body,\n      NodeFilter.SHOW_TEXT,\n      null,\n      false\n    );\n    while (walker.nextNode()) {\n      if (this._isRelevantTextNode(walker.currentNode)) {\n        this._text_nodes.push(walker.currentNode);\n      }\n    }\n  }\n\n  // Does the node contain text that we want to parse?\n  _isRelevantTextNode(node) {\n    // Ignore text outside of the sub-frame, therefore outside either the TTY view or\n    // outside the larger buffered TTY view.\n    // Or ignore nodes with only whitespace\n    const dom_rect = node.parentElement.getBoundingClientRect();\n\n    return !(\n      !this._isDOMRectInSubFrame(dom_rect) ||\n      node.textContent.trim().length === 0\n    );\n  }\n\n  // In order to decide if a particular DOM rect is inside the current sub frame then we need\n  // to compare the sub frame's dimensions to those of the DOM rect. However DOM rects are in\n  // viewport-relative coords. In order to save on some CPU cycles, we can just apply the\n  // transform to the sub frame.\n  _convertSubFrameToViewportCoords() {\n    this._viewport_relative_sub_frame = {\n      top: this.dimensions.dom.sub.top - window.scrollY,\n      bottom:\n        this.dimensions.dom.sub.top +\n        this.dimensions.dom.sub.height -\n        window.scrollY,\n      left: this.dimensions.dom.sub.left - window.scrollX,\n      right:\n        this.dimensions.dom.sub.left +\n        this.dimensions.dom.sub.width -\n        window.scrollX,\n    };\n  }\n\n  _isDOMRectInSubFrame(dom_rect) {\n    const isBottomIn =\n      dom_rect.bottom >= this._viewport_relative_sub_frame.top &&\n      dom_rect.bottom <= this._viewport_relative_sub_frame.bottom;\n    const isTopIn =\n      dom_rect.top >= this._viewport_relative_sub_frame.top &&\n      dom_rect.top <= this._viewport_relative_sub_frame.bottom;\n    const isLeftIn =\n      dom_rect.left >= this._viewport_relative_sub_frame.left &&\n      dom_rect.left <= this._viewport_relative_sub_frame.right;\n    const isRightIn =\n      dom_rect.right >= this._viewport_relative_sub_frame.left &&\n      dom_rect.right <= this._viewport_relative_sub_frame.right;\n    return (isBottomIn || isTopIn) && (isLeftIn || isRightIn);\n  }\n\n  __positionTextNodes() {\n    for (const node of this._text_nodes) {\n      this._node = node;\n      this._text = node.textContent;\n      this._formatText();\n      this._character_index = 0;\n      this._positionSingleTextNode();\n    }\n  }\n\n  _formatText() {\n    this._normaliseWhitespace();\n    this._fixJustifiedText();\n  }\n\n  // Justified text uses the space between words to stretch a line to perfectly fit from\n  // end to end. That'd be ok if it only stretched by exact units of monospace width, but\n  // it doesn't, which messes with our fragile grid system.\n  // TODO:\n  //   * It'd be nice to detect right-justified text so we can keep it. Just need to be\n  //     careful with things like traversing parents up the DOM, or using `computedStyle()`\n  //     because they can be expensive.\n  //   * Another approach could be to explore how a global use of `pre` styling renders\n  //     pages.\n  //   * Also, is it possible and/or faster to do this once in the main style sheet? Or\n  //     even by a find-replace on all occurrences of 'justify'?\n  //   * Yet another thing, the style change doesn't actually get picked up until the\n  //     next frame. Thus why the loop is independent of the `positionTextNodes()` loop.\n  _fixJustifiedText() {\n    if (this._node.parentElement) {\n      this._node.parentElement.style.textAlign = \"left\";\n    }\n  }\n\n  // The need for this wasn't immediately obvious to me. The fact is that the DOM stores\n  // text nodes _as they are written in the HTML doc_. Therefore, if you've written some\n  // nicely indented HTML, then the text node will actually contain those as something like\n  //   `\\n      text starts here`\n  // It's just that the way CSS works most of the time means that whitespace is collapsed\n  // so viewers never notice.\n  //\n  // TODO:\n  //   The normalisation here of course destroys the formatting of `white-space: pre`\n  //   styling, like code snippets for example. So hopefully we can detect the node's\n  //   `white-space` setting and skip this function if necessary?\n  _normaliseWhitespace() {\n    // Unify all whitespace to a single space character\n    this._text = this._text.replace(/[\\t\\n\\r ]+/g, \" \");\n    if (this._isFirstParseInElement()) {\n      // Remove whitespace at the beginning\n      if (this._text.charAt(0) === \" \") {\n        this._text = this._text.substring(1, this._text.length);\n      }\n      // Remove whitespace at the end\n      if (this._text.charAt(this._text.length - 1) === \" \") {\n        this._text = this._text.substring(0, this._text.length - 1);\n      }\n    }\n  }\n\n  // Knowing if a text node is the first within its parent element helps to decide\n  // whether to remove its leading whitespace or not.\n  //\n  // An element may contain many text nodes. For example a `<p>` element may contain a\n  // starting text node followed by a `<a>` tag, finishing with another plain text node. We\n  // only want to remove leading whitespace from the text at the _beginning_ of a line.\n  // Usually we can do this just by checking if a DOM rectangle's position is further down\n  // the page than the previous one - but of course there is nothing to compare the first\n  // DOM rectangle to. What's more, DOM rects are grouped per _text node_, NOT per element\n  // and we are not guaranteed to iterate through elements in the order that text flows.\n  // Therefore we need to make the assumption that plain text nodes flow within their shared\n  // parent element. There is a possible caveat here for elements starting with another\n  // element (like a link), where that sub-element contains leading whitespace.\n  _isFirstParseInElement() {\n    let element = this._node.parentElement;\n    const is_parse_started = _.includes(this._parse_started_elements, element);\n    if (is_parse_started) {\n      return false;\n    } else {\n      this._parse_started_elements.push(element);\n      return true;\n    }\n  }\n\n  // Here is where we actually make use of the rather strict monospaced and fixed font size\n  // CSS rules enforced by the webextension. Of course the CSS is never going to be able to\n  // perfectly snap characters onto a grid, so we force it here instead. At least we can be\n  // fairly certain that every character at least takes up the same space as a TTY cell, it\n  // just might not be perfectly aligned. So here we just round down all coordinates to force\n  // the snapping.\n  //\n  // Use `this.addClientRectsOverlay(dom_rects, text);` to see DOM rectangle outlines in a\n  // real browser.\n  _positionSingleTextNode() {\n    this._dom_box = {};\n    for (const dom_box of this._getNodeDOMBoxes()) {\n      if (!this._isDOMRectInSubFrame(dom_box)) {\n        continue;\n      }\n      this._dom_box.top = dom_box.top;\n      this._dom_box.left = dom_box.left;\n      this._dom_box.width = dom_box.width;\n      this._handleSingleDOMBox();\n      this._previous_dom_box = _.clone(this._dom_box);\n    }\n  }\n\n  // This is the key to being able to display formatted text within the strict confines\n  // of a TTY. DOM Rectangles are closely related to selection ranges (like when you click\n  // and drag the mouse cursor over text). Think of an individual DOM rectangle as a single\n  // bar of highlighted selection. So that, for example, a 3 line paragraph will have 3\n  // DOM rectangles. Fortunately DOMRect coordinates and dimensions are precisely defined.\n  // Although do note that, unlike selection ranges, sub-selections can appear seemingly\n  // inside other selections for things like italics or anchor tags.\n  _getNodeDOMBoxes() {\n    let rects = [];\n    // TODO: selectNode() hangs if it can't find a node in the DOM\n    // Node.isConnected() might be faster\n    // It's possible that the node has dissapeared since nodes were collected.\n    if (document.body.contains(this._node)) {\n      this._range.selectNode(this._node);\n      rects = this._range.getClientRects();\n    }\n    return rects;\n  }\n\n  // A single box is always a valid rectangle. Therefore a single box will, for example,\n  // never straddle 2 lines as there is no guarantee that a valid rectangle can be formed.\n  // We can use this to our advantage by stepping through coordinates of a box to get the\n  // exact position of every single individual character. We just have to understand and\n  // follow exactly how the DOM flows text - easier said than done.\n  _handleSingleDOMBox() {\n    this._prepareToParseDOMBox();\n    for (let step = 0; step < this._tty_box.width; step++) {\n      this._handleSingleCharacter();\n      this._stepToNextCharacter();\n    }\n  }\n\n  _prepareToParseDOMBox() {\n    this._dom_box = this._convertDOMRectToAbsoluteCoords(this._dom_box);\n    this._createSyncedTTYBox();\n    this._createTrackers();\n    this._setCurrentCharacter();\n    this._ignoreUnrenderedWhitespace();\n  }\n\n  // Note that it's possible for this._text to straddle many DOM boxes\n  _setCurrentCharacter() {\n    this._current_character = this._text.charAt(this._character_index);\n  }\n\n  // Everything hinges on these 2 trackers being in sync. The DOM tracker is defined by\n  // actual pixel coordinates and we move horizontally, from left to right, each step\n  // being the width of a single character. The TTY tracker moves in the same way except\n  // each step is a new single cell within the TTY.\n  _createTrackers() {\n    this._dom_tracker = {\n      x: this._dom_box.left,\n      y: this._dom_box.top,\n    };\n    this._tty_tracker = {\n      x: this._tty_box.col_start,\n      y: this._tty_box.row,\n    };\n  }\n\n  _handleSingleCharacter() {\n    let cell = new TTYCell();\n    cell.rune = this._current_character;\n    cell.tty_coords = _.clone(this._tty_tracker);\n    cell.dom_coords = _.clone(this._dom_tracker);\n    cell.parent_element = this._node.parentElement;\n    this.tty_grid.addCell(cell);\n  }\n\n  _stepToNextCharacter(tracked = true) {\n    this._character_index++;\n    this._setCurrentCharacter();\n    if (tracked) {\n      this._dom_tracker.x += this.dimensions.char.width;\n      this._tty_tracker.x++;\n    }\n  }\n\n  // There is a careful tracking between the currently parsed character of `this._text`\n  // and the position of the current 'cell' space within `this._dom_box`. So we must be precise\n  // in how we synchronise them. This requires following the DOM's method for wrapping text.\n  // Recall how the DOM will split a line at a space character boundry. That space character\n  // is then in fact never rendered - its existence is never registered within the dimensions\n  // of a DOM rectangle's box (`this._dom_box`).\n  _ignoreUnrenderedWhitespace() {\n    if (this._isNewLine() && this._current_character.trim().length == 0) {\n      this._stepToNextCharacter(false);\n    }\n  }\n\n  // Is the current DOM rectangle further down the page than the previous?\n  _isNewLine() {\n    if (Object.keys(this._previous_dom_box).length === 0) return false;\n    return this._dom_box.top > this._previous_dom_box.top;\n  }\n\n  // The DOM returns box coordinates relative to the viewport. As we are rendering the\n  // entire DOM as a single frame, then we need the coords to be relative to the top-left\n  // of the DOM itself.\n  _convertDOMRectToAbsoluteCoords(dom_rect) {\n    return {\n      top: dom_rect.top + window.scrollY,\n      bottom: dom_rect.bottom + window.scrollY,\n      left: dom_rect.left + window.scrollX,\n      right: dom_rect.right + window.scrollX,\n      height: dom_rect.height,\n      width: dom_rect.width,\n    };\n  }\n\n  // Round and snap a DOM rectangle as if it were placed in the TTY frame\n  _createSyncedTTYBox() {\n    this._tty_box = {\n      col_start: utils.snap(\n        this._dom_box.left * this.dimensions.scale_factor.width\n      ),\n      row: utils.snap(\n        (this._dom_box.top * this.dimensions.scale_factor.height) / 2\n      ),\n      width: utils.snap(\n        this._dom_box.width * this.dimensions.scale_factor.width\n      ),\n    };\n  }\n\n  // Purely for debugging.\n  //\n  // Draws a red border around all the DOMClientRect nodes.\n  // Based on code from the MDN docs site.\n  _addClientRectsOverlay(dom_rects, normalised_text) {\n    // Don't draw on every frame\n    if (this.is_first_frame_finished) return;\n    // Absolutely position a div over each client rect so that its border width\n    // is the same as the rectangle's width.\n    // Note: the overlays will be out of place if the user resizes or zooms.\n    for (const rect of dom_rects) {\n      let tableRectDiv = document.createElement(\"div\");\n      // A DOMClientRect object only contains dimensions, so there's no way to identify it\n      // to a node, so let's put its text as an attribute so we can cross-check if needs be.\n      tableRectDiv.setAttribute(\"browsh-text\", normalised_text);\n      let tty_row = parseInt(\n        Math.round(rect.top / this.dimemnsions.char.height)\n      );\n      tableRectDiv.setAttribute(\"tty_row\", tty_row);\n      tableRectDiv.style.position = \"absolute\";\n      tableRectDiv.style.border = \"1px solid red\";\n      tableRectDiv.style.margin = tableRectDiv.style.padding = \"0\";\n      tableRectDiv.style.top = rect.top + \"px\";\n      tableRectDiv.style.left = rect.left + \"px\";\n      // We want rect.width to be the border width, so content width is 2px less.\n      tableRectDiv.style.width = rect.width - 2 + \"px\";\n      tableRectDiv.style.height = rect.height - 2 + \"px\";\n      document.body.appendChild(tableRectDiv);\n    }\n  }\n}\n"
  },
  {
    "path": "webext/src/dom/tty_cell.js",
    "content": "// A single cell on the TTY grid\nexport default class {\n  // When a character clobbers another character in the grid, we can't use our\n  // text show/hide trick to know if the character is visible in the final DOM. So we have\n  // to use standard CSS inspection instead. Hopefully this doesn't happen often because\n  // it's expensive.\n  // TODO: Make comprehensive\n  isHighestLayer() {\n    const found_element = document.elementFromPoint(\n      this.dom_coords.x,\n      this.dom_coords.y\n    );\n    return this.parent_element == found_element;\n  }\n}\n"
  },
  {
    "path": "webext/src/dom/tty_grid.js",
    "content": "import utils from \"utils\";\n\n// The TTY grid\nexport default class {\n  constructor(dimensions, graphics_builder, config) {\n    this.dimensions = dimensions;\n    this.graphics_builder = graphics_builder;\n    this.config = config;\n    this._setMiddleOfEm();\n  }\n\n  getCell(index) {\n    return this.cells[index];\n  }\n\n  getCellAt(x, y) {\n    return this.cells[y * this.dimensions.frame.width + x];\n  }\n\n  addCell(new_cell) {\n    new_cell.index = this._calculateIndex(new_cell);\n    const is_cell_possibly_obscured = !this._handleCellVisibility(new_cell);\n    const is_cell_at_highest_layer = this._isNewCellAtHighestLayer(new_cell);\n    if (is_cell_at_highest_layer && !is_cell_possibly_obscured) {\n      this.cells[new_cell.index] = new_cell;\n    }\n  }\n\n  _isNewCellAtHighestLayer(new_cell) {\n    let existing_cell = this.cells[new_cell.index];\n\n    return !(\n      existing_cell !== undefined && !new_cell.isHighestLayer(existing_cell)\n    );\n  }\n\n  _handleCellVisibility(new_cell) {\n    const colours = this._getColours(new_cell);\n    if (!colours) return false;\n    if (this._isCharObscured(colours)) return false;\n    new_cell.fg_colour = colours[0];\n    new_cell.bg_colour = colours[1];\n    return true;\n  }\n\n  _calculateIndex(cell) {\n    return cell.tty_coords.y * this.dimensions.frame.width + cell.tty_coords.x;\n  }\n\n  // Get the colours right in the middle of the character's font. Returns both the colour\n  // when the text is displayed and when it's hidden.\n  _getColours(cell) {\n    const offset_x = utils.snap(\n      cell.dom_coords.x + this.dimensions.char.width * this._middle_of_em\n    );\n    const offset_y = utils.snap(\n      cell.dom_coords.y + this.dimensions.char.height * this._middle_of_em\n    );\n    const fg_colour = this.graphics_builder.getUnscaledFGPixelAt(\n      offset_x,\n      offset_y\n    );\n    const bg_colour = this.graphics_builder.getUnscaledBGPixelAt(\n      offset_x,\n      offset_y\n    );\n    return [fg_colour, bg_colour];\n  }\n\n  // This is the value to reach the middle of a uni-glyph font character in order to\n  // sample its colour. Obviosuly it is better to reach for the middle in case there are\n  // vagaries of rendering, it increases our chances of actually getting the characters\n  // own colour and not some other colour nearby.\n  //\n  // However during testing, we use very small self-generated pixel arrays which makes\n  // the snapped values rather unintuitive. So we just encourage the snaped values to\n  // snap lower which just lends itself to more readable test values.\n  _setMiddleOfEm() {\n    this._middle_of_em = TEST ? 0.49 : 0.5;\n  }\n\n  // This is somewhat of a, hopefully elegant, hack. So, imagine that situation where you're\n  // browsing a web page and a popup appears; perhaps just a select box, or menu, or worst\n  // of all a dreaded full-page overlay. Now, DOM rectangles don't take into account whether\n  // they are the uppermost visible element, so we're left in a bit of a pickle. The only JS\n  // way to know if an element is visible is to use `Document.elementFromPoint(x, y)`, where\n  // you compare the returned element with the element whose visibility you're checking.\n  // This is has a number of problems. Firstly, it only checks one coordinate in the element\n  // for visibility, which of course isn't going to 100% reliably speak for all the\n  // characters in the element. Secondly, even ignoring the first caveat, running\n  // `elementFromPoint()` for every character is very expensive, around 25ms for an average\n  // DOM. So it's basically a no-go. So instead we take advantage of the fact that we're\n  // working with a snapshot of the the webpage's pixels. It's pretty good assumption that if\n  // you make the text transparent and a pixel's colour doesn't change then that character\n  // must be obscured by something.\n  //\n  // There are of course some potential edge cases with this. What if we get a false\n  // positive, where a character is obscured _by another character_? Hopefully in such a\n  // case we can work with `z-index` so that characters justifiably overwrite each other in\n  // the TTY grid.\n  _isCharObscured(colours) {\n    if (!this.config.browsh.use_experimental_text_visibility) {\n      return false;\n    }\n    return (\n      colours[0][0] === colours[1][0] &&\n      colours[0][1] === colours[1][1] &&\n      colours[0][2] === colours[1][2]\n    );\n  }\n}\n"
  },
  {
    "path": "webext/src/utils.js",
    "content": "export default {\n  mixins: function (...mixins) {\n    return mixins.reduce((base, mixin) => {\n      return mixin(base);\n    }, class {});\n  },\n\n  ttyCell: function (\n    fg_colour = [255, 255, 255],\n    bg_colour = [0, 0, 0],\n    character\n  ) {\n    let cell = fg_colour.concat(bg_colour);\n    cell.push(character);\n    return cell;\n  },\n\n  ttyPlainCell: function (character) {\n    return this.ttyCell(null, null, character);\n  },\n\n  snap: function (number) {\n    return parseInt(Math.round(number));\n  },\n\n  ensureEven: function (number) {\n    number = this.snap(number);\n    if (number % 2) {\n      number++;\n    }\n    return number;\n  },\n\n  rebuildArgsToSingleArg: function (args) {\n    return args.slice(1).join(\",\");\n  },\n\n  uuidv4: function () {\n    return \"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx\".replace(\n      /[xy]/g,\n      function (c) {\n        var r = (Math.random() * 16) | 0,\n          v = c == \"x\" ? r : (r & 0x3) | 0x8;\n        return v.toString(16);\n      }\n    );\n  },\n};\n"
  },
  {
    "path": "webext/test/fixtures/canvas_pixels.js",
    "content": "// Generate fake pixel data, as if sreenshotting a canvas element.\n//\n// The RGB channels are repurposed to indicate the function they were created by\n// and their position in their 1 dimensionsal array structure.\n//\n// If a [R, G, B] contains a non-zero value the following applies;\n//   R: The pixel's colour is due to text and was generated by withText()\n//   G: The pixel's colour is due to background and was generated by withText()\n//   B: The pixel's colour is due to background and was generated by withoutText()\n//\n//   * The values express the pixel's position in the array.\n//   * G and B should always have the same values, but should never appear in the same\n//     [R, G, B] array.\nexport default class CanvasPixels {\n  constructor(dimensions) {\n    this.width = dimensions.dom.sub.width;\n    this.height = dimensions.dom.sub.height;\n    this.pixel_count = this.width * this.height * 4;\n  }\n\n  // Putting the index in the red channel indicates text. An index value in the green\n  // channel indicates that this is the withText() function yet the colour is being\n  // picked up from the background (as if it were the withoutText() function).\n  with_text() {\n    let x, y, index;\n    let pixels = [];\n    for (let i = 0; i < this.pixel_count; i += 4) {\n      x = (i / 4) % this.width;\n      y = Math.floor(i / 2 / 4 / this.width);\n      index = this._getIndexValue(i);\n      if (this._checkForCharacter(x, y)) {\n        pixels.push(index);\n        pixels.push(0);\n      } else {\n        pixels.push(0);\n        pixels.push(index);\n      }\n      pixels.push(0);\n      pixels.push(0);\n    }\n    return { data: pixels };\n  }\n\n  _getIndexValue(i) {\n    // Add 1 to distinguish the zero value. Because zero values mean that nothing was\n    // found.\n    return i / 4 + 1;\n  }\n\n  _checkForCharacter(x, y) {\n    const char = global.mock_DOM_text[y].charAt(x);\n    const mask_char = global.mock_DOM_template[y].charAt(x);\n    const isCharThere = !this._isNullOrWhiteSpace(char);\n    const isMaskThere = mask_char === \"!\";\n    return isCharThere && !isMaskThere;\n  }\n\n  _isNullOrWhiteSpace(str) {\n    return !str || str.length === 0 || /^\\s*$/.test(str);\n  }\n\n  // Using the blue channel indicates that this sample was taken from the withoutText()\n  // function.\n  without_text() {\n    let pixels = [];\n    for (let i = 0; i < this.pixel_count; i += 4) {\n      pixels.push(0);\n      pixels.push(0);\n      pixels.push(this._getIndexValue(i));\n      pixels.push(0);\n    }\n    return { data: pixels };\n  }\n\n  scaled() {\n    return this.without_text();\n  }\n}\n"
  },
  {
    "path": "webext/test/fixtures/text_nodes.js",
    "content": "// Create DOM-compatible DOM Rectangles from a simple array of strings\nexport default class TextNodes {\n  constructor() {\n    this.offset = 0.0;\n    this.char_width = global.dimensions.char.width;\n    this.char_height = global.dimensions.char.height;\n    this.total_width = global.mock_DOM_template[0].length * this.char_width;\n    this.total_height = global.mock_DOM_template.length * this.char_height;\n    this.dom_rects = [];\n  }\n\n  build() {\n    for (let line of global.mock_DOM_text) {\n      this.addDomRect(line);\n    }\n    return [\n      {\n        textContent: global.mock_DOM_text.join(\"\"),\n        parentElement: {\n          style: {},\n        },\n        bounding_box: this.boundingBox(),\n        dom_rects: this.dom_rects,\n      },\n    ];\n  }\n\n  boundingBox() {\n    return {\n      top: this.offset,\n      bottom: this.total_height + this.offset,\n      left: this.offset,\n      right: this.total_width + this.offset,\n      width: this.total_width,\n      height: this.total_height,\n    };\n  }\n\n  addDomRect(line) {\n    const width = line.length * this.char_width;\n    const height = this.char_height;\n    const top = this.dom_rects.length * this.char_height + this.offset;\n    this.dom_rects.push({\n      top: top,\n      bottom: top + height,\n      left: this.offset,\n      right: width + this.offset,\n      width: width,\n      height: height,\n    });\n  }\n}\n"
  },
  {
    "path": "webext/test/graphics_builder_spec.js",
    "content": "import helper from \"helper\";\nimport { expect } from \"chai\";\n\ndescribe(\"Graphics Builder\", () => {\n  let graphics_builder;\n\n  describe(\"Non-offsetted frames\", () => {\n    beforeEach(() => {\n      global.mock_DOM_template = [\"    \", \"    \"];\n      global.frame_type = \"small\";\n      global.tty = {\n        width: 4,\n        height: 2,\n        x_scroll: 0,\n        y_scroll: 0,\n      };\n      graphics_builder = helper.runGraphicsBuilder();\n    });\n\n    it(\"should serialise a scaled frame\", () => {\n      const colours = graphics_builder.frame.colours;\n      expect(colours.length).to.equal(48);\n      expect(colours[0]).to.equal(0);\n      expect(colours[2]).to.equal(1);\n      expect(colours[46]).to.equal(0);\n      expect(colours[47]).to.equal(16);\n    });\n\n    it(\"should populate the frame's meta\", () => {\n      const meta = graphics_builder.frame.meta;\n      expect(meta).to.deep.equal({\n        sub_left: 0,\n        sub_top: 0,\n        sub_width: 4,\n        sub_height: 4,\n        total_width: 4,\n        total_height: 4,\n        id: 1,\n      });\n    });\n  });\n\n  describe(\"Offset frames\", () => {\n    beforeEach(() => {\n      global.tty = {\n        width: 2,\n        height: 2,\n        x_scroll: 2,\n        y_scroll: 1,\n      };\n      global.frame_type = \"small\";\n      global.mock_DOM_template = [\"    \", \"    \", \"    \", \"    \"];\n      graphics_builder = helper.runGraphicsBuilder();\n    });\n\n    it(\"should serialise a scaled frame\", () => {\n      const colours = graphics_builder.frame.colours;\n      expect(colours.length).to.equal(24);\n      expect(colours[0]).to.equal(0);\n      expect(colours[2]).to.equal(1);\n      expect(colours[22]).to.equal(0);\n      expect(colours[23]).to.equal(8);\n    });\n\n    it(\"should populate the frame's meta\", () => {\n      const meta = graphics_builder.frame.meta;\n      expect(meta).to.deep.equal({\n        sub_left: 2,\n        sub_top: 1,\n        sub_width: 2,\n        sub_height: 4,\n        total_width: 4,\n        total_height: 8,\n        id: 1,\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "webext/test/helper.js",
    "content": "import sinon from \"sinon\";\n\nimport Dimensions from \"dom/dimensions\";\nimport GraphicsBuilder from \"dom/graphics_builder\";\nimport TextBuilder from \"dom/text_builder\";\nimport TTYCell from \"dom/tty_cell\";\n\nimport MockRange from \"mocks/range\";\nimport TextNodes from \"fixtures/text_nodes\";\nimport CanvasPixels from \"fixtures/canvas_pixels\";\n\nvar sandbox = sinon.createSandbox();\nlet getPixelsStub;\nlet channel = { name: 1 };\n\nbeforeEach(() => {\n  sandbox\n    .stub(Dimensions.prototype, \"_getOrCreateMeasuringBox\")\n    .returns(element);\n  sandbox.stub(Dimensions.prototype, \"sendMessage\").returns(true);\n  sandbox.stub(GraphicsBuilder.prototype, \"hideText\").returns(true);\n  sandbox.stub(GraphicsBuilder.prototype, \"showText\").returns(true);\n  sandbox.stub(GraphicsBuilder.prototype, \"_scaleCanvas\").returns(true);\n  sandbox.stub(GraphicsBuilder.prototype, \"_unScaleCanvas\").returns(true);\n  sandbox.stub(TextBuilder.prototype, \"_getAllInputBoxes\").returns([]);\n  sandbox.stub(TTYCell.prototype, \"isHighestLayer\").returns(true);\n  getPixelsStub = sandbox.stub(GraphicsBuilder.prototype, \"_getPixelData\");\n});\n\nafterEach(() => {\n  sandbox.restore();\n});\n\nglobal.dimensions = {\n  char: {\n    width: 1,\n    height: 2,\n  },\n};\n\nglobal.document = {\n  addEventListener: () => {},\n  body: {\n    contains: () => {\n      return true;\n    },\n  },\n  getElementById: () => {},\n  getElementsByTagName: () => {\n    return [\n      {\n        innerHTML: \"Google\",\n      },\n    ];\n  },\n  createRange: () => {\n    return new MockRange();\n  },\n  createElement: () => {\n    return {\n      getContext: () => {},\n    };\n  },\n  documentElement: {\n    scrollWidth: null,\n    scrollHeight: null,\n  },\n  location: {\n    href: \"https://www.google.com\",\n  },\n  scrollX: 0,\n  scrollY: 0,\n\n  innerWidth: null,\n  innerHeight: null,\n};\n\nglobal.DEVELOPMENT = false;\nglobal.PRODUCTION = false;\nglobal.TEST = true;\nglobal.window = global.document;\nglobal.performance = {\n  now: () => {},\n};\n\nlet element = {\n  getBoundingClientRect: () => {\n    return {\n      width: global.dimensions.char.width,\n      height: global.dimensions.char.height,\n    };\n  },\n};\n\nfunction _setupMockDOMSize() {\n  const width = global.mock_DOM_template[0].length;\n  const height = global.mock_DOM_template.length * 2;\n  global.document.documentElement.scrollWidth = width;\n  global.document.documentElement.scrollHeight = height;\n  global.document.innerWidth = width;\n  global.document.innerHeight = height;\n}\n\nfunction _setupDimensions() {\n  let dimensions = new Dimensions();\n  _setupMockDOMSize();\n  dimensions.tty.width = global.tty.width;\n  dimensions.tty.height = global.tty.height;\n  dimensions.frame.x_scroll = global.tty.x_scroll;\n  dimensions.frame.y_scroll = global.tty.y_scroll;\n  dimensions.update();\n  dimensions.setSubFrameDimensions(global.frame_type);\n  return dimensions;\n}\n\nfunction _setupGraphicsBuilder(type) {\n  let dimensions = _setupDimensions();\n  let canvas_pixels = new CanvasPixels(dimensions);\n  if (type === \"with_text\") {\n    getPixelsStub.onCall(0).returns(canvas_pixels.with_text());\n    getPixelsStub.onCall(1).returns(canvas_pixels.without_text());\n    getPixelsStub.onCall(2).returns(canvas_pixels.scaled());\n  } else {\n    getPixelsStub.onCall(0).returns(canvas_pixels.scaled());\n  }\n  let config = {\n    \"http-server\": {\n      \"jpeg-compression\": 0.9,\n      render_delay: 0,\n    },\n  };\n  let graphics_builder = new GraphicsBuilder(channel, dimensions, config);\n  return graphics_builder;\n}\n\nlet functions = {\n  runTextBuilder: (callback) => {\n    let text_nodes = new TextNodes();\n    let graphics_builder = _setupGraphicsBuilder(\"with_text\");\n    let text_builder = new TextBuilder(\n      channel,\n      graphics_builder.dimensions,\n      graphics_builder,\n      {\n        browsh: {\n          use_experimental_text_visibility: true,\n        },\n      }\n    );\n    graphics_builder._getScreenshotWithText(() => {\n      graphics_builder._getScreenshotWithoutText();\n      graphics_builder.__getScaledScreenshot();\n      text_builder._text_nodes = text_nodes.build();\n      text_builder._updateState();\n      text_builder._positionTextNodes();\n      callback(text_builder);\n    });\n  },\n\n  runGraphicsBuilder: () => {\n    let graphics_builder = _setupGraphicsBuilder();\n    graphics_builder.__getScaledScreenshot();\n    graphics_builder._serialiseFrame();\n    return graphics_builder;\n  },\n};\n\nexport default functions;\n"
  },
  {
    "path": "webext/test/mocks/range.js",
    "content": "export default class MockRange {\n  selectNode(node) {\n    this.node = node;\n  }\n  getBoundingClientRect() {\n    return this.node.bounding_box;\n  }\n  getClientRects() {\n    return this.node.dom_rects;\n  }\n}\n"
  },
  {
    "path": "webext/test/text_builder_spec.js",
    "content": "import { expect } from \"chai\";\nimport helper from \"helper\";\n\nlet text_builder, grid;\n\ndescribe(\"Text Builder\", () => {\n  beforeEach((done) => {\n    global.mock_DOM_template = [\n      \"                \",\n      \"                \",\n      \"                \",\n      \"                \",\n      \"                \",\n      \"       !!!      \",\n      \"       !!!      \",\n    ];\n\n    // We can't simulate anything that uses groups of spaces, as TextBuilder collapses all spaces\n    // to a single space in order to sync with how the DOM renders monospaced text.\n    //\n    // TODO: That being said, I can surely imagine that multiple spaces within a single DOM rect\n    // would not be collapsed by the DOM, so maybe that's something to take into account for\n    // the TextBuilder code?\n    global.mock_DOM_text = [\n      \"Testing nodes. \",\n      \"Max 15 chars \",\n      \"wide. \",\n      \"Diff kinds of \",\n      \"Whitespace. \",\n      \"Also we need to \",\n      \"test subframes.\",\n    ];\n\n    global.tty = {\n      width: 5,\n      height: 3,\n      x_scroll: 0,\n      y_scroll: 0,\n    };\n    global.frame_type = \"small\";\n    helper.runTextBuilder((returned_text_builder) => {\n      text_builder = returned_text_builder;\n      grid = text_builder.tty_grid.cells;\n      done();\n    });\n  });\n\n  it(\"should convert text nodes to a grid of cell objects\", () => {\n    expect(grid.length).to.equal(37);\n    expect(grid[0]).to.deep.equal({\n      index: 0,\n      rune: \"T\",\n      fg_colour: [6, 0, 0],\n      bg_colour: [0, 0, 6],\n      parent_element: {\n        style: {\n          textAlign: \"left\",\n        },\n      },\n      tty_coords: {\n        x: 0,\n        y: 0,\n      },\n      dom_coords: {\n        x: 0,\n        y: 0,\n      },\n    });\n    expect(grid[5]).to.equal(undefined);\n    expect(grid[16]).to.deep.equal({\n      index: 16,\n      rune: \"M\",\n      fg_colour: [16, 0, 0],\n      bg_colour: [0, 0, 16],\n      parent_element: {\n        style: {\n          textAlign: \"left\",\n        },\n      },\n      tty_coords: {\n        x: 0,\n        y: 1,\n      },\n      dom_coords: {\n        x: 0,\n        y: 2,\n      },\n    });\n    expect(grid[36]).to.deep.equal({\n      index: 36,\n      rune: \".\",\n      fg_colour: [30, 0, 0],\n      bg_colour: [0, 0, 30],\n      parent_element: {\n        style: {\n          textAlign: \"left\",\n        },\n      },\n      tty_coords: {\n        x: 4,\n        y: 2,\n      },\n      dom_coords: {\n        x: 4,\n        y: 4,\n      },\n    });\n    expect(grid[37]).to.equal(undefined);\n  });\n\n  it(\"should not detect the colour of whitespace characters\", () => {\n    expect(grid[19].rune).to.equal(\" \");\n    expect(grid[19].fg_colour).to.deep.equal([0, 19, 0]);\n  });\n\n  it(\"should serialise a frame\", () => {\n    text_builder._serialiseFrame();\n    expect(text_builder.frame.meta).to.deep.equal({\n      sub_left: 0,\n      sub_top: 0,\n      sub_width: 5,\n      sub_height: 6,\n      total_width: 16,\n      total_height: 14,\n      id: 1,\n    });\n    expect(text_builder.frame.text).to.deep.equal([\n      \"T\",\n      \"e\",\n      \"s\",\n      \"t\",\n      \"i\",\n      \"M\",\n      \"a\",\n      \"x\",\n      \" \",\n      \"1\",\n      \"w\",\n      \"i\",\n      \"d\",\n      \"e\",\n      \".\",\n    ]);\n    expect(text_builder.frame.colours).to.deep.equal([\n      6, 0, 0, 7, 0, 0, 8, 0, 0, 9, 0, 0, 10, 0, 0, 16, 0, 0, 17, 0, 0, 18, 0,\n      0, 0, 19, 0, 20, 0, 0, 26, 0, 0, 27, 0, 0, 28, 0, 0, 29, 0, 0, 30, 0, 0,\n    ]);\n  });\n});\n"
  },
  {
    "path": "webext/webpack.config.js",
    "content": "import webpack from 'webpack';\nimport path from 'path';\nimport CopyWebpackPlugin from 'copy-webpack-plugin';\nimport fs from 'fs';\n\nconst dirname = process.cwd();\n\nexport default {\n  mode: process.env['BROWSH_ENV'] === 'RELEASE' ? 'production' : 'development',\n  target: 'node',\n  entry: {\n    content: './content.js',\n    background: './background.js'\n  },\n  output: {\n    path: dirname,\n    filename: 'dist/[name].js',\n  },\n  resolve: {\n    modules: [\n      path.resolve(dirname, './src'),\n      'node_modules'\n    ],\n  },\n  module: {\n    rules: [\n      {\n        test: /\\.m?js/,\n        resolve: {\n          fullySpecified: false,\n        },\n      },\n    ]\n  },\n  devtool: 'source-map',\n  plugins: [\n    new webpack.DefinePlugin({\n      DEVELOPMENT: JSON.stringify(true),\n      TEST: JSON.stringify(false),\n      // TODO: For production use a different webpack.config.js\n      PRODUCTION: JSON.stringify(false)\n    }),\n    new CopyWebpackPlugin({\n      patterns: [\n        { from: 'assets', to: 'dist/assets' },\n        { from: '.web-extension-id', to: 'dist/' },\n        {\n          from: 'manifest.json', to: 'dist/',\n          // Inject the current Browsh version into the manifest JSON\n          transform(manifest, _) {\n            const version_path = '../interfacer/src/browsh/version.go';\n            let buffer = fs.readFileSync(version_path);\n            let version_contents = buffer.toString();\n            const matches = version_contents.match(/\"(.*?)\"/);\n            return manifest.toString().replace('BROWSH_VERSION', matches[1]);\n          }\n        },\n      ]\n    })\n  ]\n}\n"
  }
]