Repository: browsh-org/browsh Branch: master Commit: 499ef386d45c Files: 88 Total size: 297.9 KB Directory structure: gitextract_lahqh_vs/ ├── .dockerignore ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ └── bug_report.md │ └── workflows/ │ ├── lint.yml │ └── main.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── ctl.sh ├── goreleaser.yml ├── interfacer/ │ ├── cmd/ │ │ └── browsh/ │ │ └── main.go │ ├── contrib/ │ │ └── upx_compress_binary.sh │ ├── go.mod │ ├── go.sum │ ├── src/ │ │ └── browsh/ │ │ ├── browsh.go │ │ ├── cells.go │ │ ├── comms.go │ │ ├── config.go │ │ ├── config_sample.go │ │ ├── firefox.go │ │ ├── firefox_unix.go │ │ ├── firefox_windows.go │ │ ├── frame_builder.go │ │ ├── frame_builder_test.go │ │ ├── input_box.go │ │ ├── input_cursor.go │ │ ├── input_multiline.go │ │ ├── input_multiline_test.go │ │ ├── input_scroll.go │ │ ├── raw_text_server.go │ │ ├── raw_text_server_test.go │ │ ├── tab.go │ │ ├── tty.go │ │ ├── ui.go │ │ ├── unit_test.go │ │ └── version.go │ └── test/ │ ├── http-server/ │ │ ├── server_test.go │ │ └── setup.go │ ├── sites/ │ │ └── smorgasbord/ │ │ ├── another.html │ │ ├── css/ │ │ │ ├── main.css │ │ │ └── spinner.css │ │ ├── index.html │ │ └── textarea.html │ └── tty/ │ ├── matchers.go │ ├── setup.go │ └── tty_test.go ├── scripts/ │ ├── bundling.bash │ ├── common.bash │ ├── docker.bash │ ├── misc.bash │ ├── releasing.bash │ └── tests.bash └── webext/ ├── .eslintrc ├── .mocharc.cjs ├── .web-extension-id ├── assets/ │ ├── browsh-schema.json │ └── styles.css ├── background.js ├── content.js ├── contrib/ │ ├── download_xpi.js │ ├── firefoxheadless.sh │ └── font_maker.py ├── manifest.json ├── package.json ├── src/ │ ├── background/ │ │ ├── common_mixin.js │ │ ├── dimensions.js │ │ ├── manager.js │ │ ├── tab.js │ │ ├── tab_commands_mixin.js │ │ └── tty_commands_mixin.js │ ├── dom/ │ │ ├── commands_mixin.js │ │ ├── common_mixin.js │ │ ├── dimensions.js │ │ ├── graphics_builder.js │ │ ├── manager.js │ │ ├── serialise_mixin.js │ │ ├── text_builder.js │ │ ├── tty_cell.js │ │ └── tty_grid.js │ └── utils.js ├── test/ │ ├── fixtures/ │ │ ├── canvas_pixels.js │ │ └── text_nodes.js │ ├── graphics_builder_spec.js │ ├── helper.js │ ├── mocks/ │ │ └── range.js │ └── text_builder_spec.js └── webpack.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ Dockerfile ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms patreon: browsh ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve --- Just a few points to consider before submitting a bug report: * Do a quick search for any existing issues describing your bug * Give a clear and concise description of what the bug is * Include the contents of your `./debug.log` generated with `browsh --debug` * Include your OS and terminal name ================================================ FILE: .github/workflows/lint.yml ================================================ name: Lint on: [push, pull_request] jobs: lint: runs-on: ubuntu-latest env: GOPATH: ${{ github.workspace }} GOBIN: ${{ github.workspace }}/bin steps: - name: Checkout uses: actions/checkout@v3 with: fetch-depth: 0 - name: Setup go uses: actions/setup-go@v5 with: go-version: 1.21.x - name: Setup node uses: actions/setup-node@v4 with: node-version: 16 - run: npm ci working-directory: ./webext - name: Is web extension 'pretty'? run: npm run lint working-directory: ./webext - name: Is Golang interfacer formatted? run: ./ctl.sh golang_lint_check ================================================ FILE: .github/workflows/main.yml ================================================ name: Test-Release on: [push, pull_request] jobs: tests: name: "Tests (webextension, interfacer: unit, tty, http-server)" runs-on: ubuntu-latest env: GOPATH: ${{ github.workspace }} GOBIN: ${{ github.workspace }}/bin outputs: is_new_version: ${{ steps.check_versions.outputs.is_new_version }} steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup go uses: actions/setup-go@v5 with: go-version-file: 'interfacer/go.mod' - name: Setup node uses: actions/setup-node@v4 - name: Install Firefox uses: browser-actions/setup-firefox@latest with: firefox-version: "140.0" - run: firefox --version # Web extension tests - run: npm ci working-directory: ./webext - name: Web extension tests run: ./ctl.sh test_webextension # Interfacer tests - name: Interfacer tests setup run: ./ctl.sh interfacer_test_setup - name: Unit tests run: ./ctl.sh test_interfacer_units - name: E2E tests run: ./ctl.sh test_tty - name: TTY debug log if: ${{ failure() }} run: cat ./interfacer/test/tty/debug.log || echo "No log file" - name: HTTP Server tests uses: nick-fields/retry@v2 with: max_attempts: 3 retry_on: error timeout_minutes: 15 command: ./ctl.sh test_http_server - name: HTTP Server debug log if: ${{ failure() }} run: cat ./interfacer/test/http-server/debug.log || echo "No log file" - name: Check for new version id: check_versions run: ./ctl.sh github_actions_output_version_status release: name: Release runs-on: ubuntu-latest needs: tests if: github.ref == 'refs/heads/master' && contains(needs.tests.outputs.is_new_version, 'true') env: GOPATH: ${{ github.workspace }} GOBIN: ${{ github.workspace }}/bin MDN_KEY: ${{ secrets.MDN_KEY }} DOCKER_ACCESS_TOKEN: ${{ secrets.DOCKER_ACCESS_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Setup Deploy Keys uses: webfactory/ssh-agent@v0.5.4 with: # Note that these private keys depend on having an ssh-keygen'd comment with the # Git remote URL. This is because Github Actions use the *first* matching private # key and fails if it doesn't match. webfactory/ssh-agent ssh-private-key: | ${{ secrets.HOMEBREW_DEPLOY_KEY }} ${{ secrets.WWW_DEPLOY_KEY }} - name: Checkout uses: actions/checkout@v3 with: fetch-depth: 0 - name: Setup node uses: actions/setup-node@v3 with: node-version-file: '.nvmrc' - name: Setup go uses: actions/setup-go@v3 with: go-version-file: 'interfacer/go.mod' - run: npm ci working-directory: ./webext - name: Binary Release run: ./ctl.sh release - name: Push new tag uses: ad-m/github-push-action@master with: github_token: ${{ secrets.GITHUB_TOKEN }} tags: true - name: Login to Docker Hub uses: docker/login-action@v2 with: username: tombh password: ${{ secrets.DOCKER_ACCESS_TOKEN }} - name: Docker Release run: ./ctl.sh docker_release - name: Update Homebrew Tap run: ./ctl.sh update_homebrew_tap_with_new_version - name: Update Browsh Website run: ./ctl.sh update_browsh_website_with_new_version ================================================ FILE: .gitignore ================================================ build/ *.log *.out node_modules interfacer/target interfacer/vendor interfacer/dist interfacer/interfacer interfacer/browsh webextension.go webext/node_modules webext/dist/* dist *.xpi # This is because of an odd permissions quirk on Github Actions. I can't seem to find a # way to delete these files in CI, so let's just ignore them. Otherwise Goreleaser complains # about a dirty working tree. /pkg /bin # Goreleaser needs to upload the webextension as an extra file in the release. But it doesn't # like Git to be in a dirty state. Also Goreleaser is run at PWD ./interfacer, so we can't # reference the webextension with something like ../webext/... interfacer/browsh-*.xpi ================================================ FILE: Dockerfile ================================================ FROM debian:trixie-slim as build RUN apt update RUN apt install --yes \ curl \ ca-certificates \ git \ autoconf \ automake \ g++ \ protobuf-compiler \ zlib1g-dev \ libncurses5-dev \ libssl-dev \ pkg-config \ libprotobuf-dev \ make \ bzip2 # Helper scripts WORKDIR /build ADD .git .git ADD .github .github ADD scripts scripts ADD ctl.sh . # Install Golang and Browsh ENV GOROOT=/go ENV GOPATH=/go-home ENV PATH=$GOROOT/bin:$GOPATH/bin:$PATH ENV BASE=$GOPATH/src/browsh/interfacer ADD interfacer $BASE WORKDIR $BASE RUN /build/ctl.sh install_golang $BASE RUN /build/ctl.sh build_browsh_binary $BASE ########################### # Actual final Docker image ########################### FROM debian:trixie-slim ENV HOME=/app WORKDIR $HOME COPY --from=build /go-home/src/browsh/interfacer/browsh /app/bin/browsh RUN apt update RUN apt install --yes \ xvfb \ libgtk-3-0 \ curl \ ca-certificates \ libdbus-glib-1-2 \ procps \ libasound2 \ libxtst6 \ firefox-esr # Block ads, etc. This includes porn just because this image is also used on the # public SSH demo: `ssh brow.sh`. RUN curl \ -o /etc/hosts \ https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/fakenews-gambling-porn-social/hosts # Don't use root RUN useradd -m user --home /app RUN chown user:user /app USER user ENV PATH="${HOME}/bin:${HOME}/bin/firefox:${PATH}" # Firefox behaves quite differently to normal on its first run, so by getting # that over and done with here when there's no user to be dissapointed means # that all future runs will be consistent. RUN TERM=xterm script \ --return \ -c "/app/bin/browsh" \ /dev/null \ >/dev/null & \ sleep 10 ENTRYPOINT ["/app/bin/browsh"] ================================================ FILE: LICENSE ================================================ GNU LESSER GENERAL PUBLIC LICENSE Version 2.1, February 1999 Copyright (C) 1991, 1999 Free Software Foundation, Inc. 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. [This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. GNU LESSER GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) "Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. 1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) The modified work must itself be a software library. b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. (For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. This option is useful when you wish to copy part of the code of the Library into a program that is not a library. 4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. 5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. 6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. 7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. 10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. 11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. 14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS ================================================ FILE: README.md ================================================ [![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) ![Browsh Logo](https://www.brow.sh/assets/images/browsh-header.jpg) **A fully interactive, real-time, and modern text-based browser rendered to TTYs and browsers** ![Browsh GIF](https://media.giphy.com/media/bbsmVkYjPdOKHhMXOO/giphy.gif) ## Why use Browsh? Not all the world has good Internet. If you only have a 3kbps internet connection tethered from a phone, then it's good to SSH into a server and browse the web through, say, [elinks](https://github.com/browsh-org/browsh/issues/17). That way the _server_ downloads the web pages and uses the limited bandwidth of an SSH connection to display the result. However, traditional text-based browsers lack JS and all other modern HTML5 support. Browsh is different in that it's backed by a real browser, namely headless Firefox, to create a purely text-based version of web pages and web apps. These can be easily rendered 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. Why not VNC? Well VNC is certainly one solution but it doesn't quite have the same ability to deal with extremely bad Internet. Terminal Browsh can also use MoSH to further reduce bandwidth and increase stability of the connection. Mosh offers features like automatic reconnection of dropped or roamed connections and diff-only screen updates. Furthermore, other than SSH or MoSH, terminal Browsh doesn't require a client like VNC. One final reason to use terminal Browsh could be to offload the battery-drain of a modern browser from your laptop or low-powered device like a Raspberry Pi. If you're a CLI-native, then you could potentially get a few more hours of life if your CPU-hungry browser is running somewhere else on mains electricity. ## Installation Download a binary from the [releases](https://github.com/browsh-org/browsh/releases) (~11MB). You will need to have [Firefox](https://www.mozilla.org/en-US/firefox/new/) already installed. Or download and run the Docker image (~230MB) with: `docker run --rm -it browsh/browsh` ## Usage Most keys and mouse gestures should work as you'd expect on a desktop browser. For full documentation click [here](https://www.brow.sh/docs/introduction/). ## Development ### The Firefox Web Extension This is needed to run essential JS inside web pages so that they render in a way that Browsh can consume. You 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. Then in the `webext` directory * `npm install` * `npx webpack --watch` ### The `browsh` Golang code You 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. Then in the `interfacer` directory * `go run ./cmd/browsh --debug` Logs will be available in `interfacer/debug.log` ## Tests For the webextension: in `webext/` folder, `npm test` For CLI unit tests: in `/interfacer` run `go test src/browsh/*.go` For CLI E2E tests: in `/interfacer` run `go test test/tty/*.go` For HTTP Service tests: in `/interfacer` run `go test test/http-server/*.go` ## Special Thanks * [@tobimensch](https://github.com/tobimensch) For essential early feedback and user testing. * [@arasatasaygin](https://github.com/arasatasaygin) For the Browsh logo. ## Donating Please consider donating: https://www.brow.sh/donate ## License GNU Lesser General Public License v2.1 ================================================ FILE: ctl.sh ================================================ #!/usr/bin/env bash set -e function_to_run=$1 export PROJECT_ROOT export GORELEASER_VERSION=1.10.2 PROJECT_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) function _includes_path { echo "$PROJECT_ROOT"/scripts } function _load_includes { for file in "$(_includes_path)"/*.bash; do # shellcheck disable=1090 source "$file" done } _load_includes if [[ $(type -t "$function_to_run") != function ]]; then echo "Subcommand: '$function_to_run' not found." exit 1 fi shift pushd "$PROJECT_ROOT" || _panic "$function_to_run" "$@" popd || _panic ================================================ FILE: goreleaser.yml ================================================ # Run with `ctl.sh release` to get ENV vars project_name: browsh builds: - binary: browsh env: - CGO_ENABLED=0 main: cmd/browsh/main.go goos: - windows - darwin - linux - freebsd - openbsd goarch: - 386 - amd64 - arm - arm64 goarm: - 6 - 7 ignore: - goos: darwin goarch: 386 - goarch: arm64 goos: windows ldflags: -s -w archives: - format_overrides: - goos: windows format: binary - goos: linux format: binary - goos: freebsd format: binary - goos: openbsd format: binary nfpms: - vendor: Browsh homepage: https://www.brow.sh maintainer: Thomas Buckley-Houston description: The modern, text-based browser license: GPL v3 formats: - deb - rpm dependencies: - firefox overrides: deb: dependencies: - 'firefox | firefox-esr' brews: - name: browsh tap: name: homebrew-browsh homepage: "https://www.brow.sh" description: "The modern, text-based browser" caveats: "You need Firefox 57 or newer to run Browsh" # We do the upload manually because Goreleaser doesn't support Deploy Keys and Github # doesn't support repo-specific Access Tokens 🙄 skip_upload: true release: extra_files: - glob: ./browsh-*.xpi ================================================ FILE: interfacer/cmd/browsh/main.go ================================================ package main import "github.com/browsh-org/browsh/interfacer/src/browsh" func main() { browsh.MainEntry() } ================================================ FILE: interfacer/contrib/upx_compress_binary.sh ================================================ #!/usr/bin/env bash set -ex shopt -s extglob pushd dist upx !(@(freebsd*|openbsd*|darwin*|linux_arm64))/* popd ================================================ FILE: interfacer/go.mod ================================================ module github.com/browsh-org/browsh/interfacer go 1.24.4 require ( github.com/NYTimes/gziphandler v1.1.1 github.com/gdamore/tcell v1.4.0 github.com/go-errors/errors v1.5.1 github.com/gorilla/websocket v1.5.1 github.com/onsi/ginkgo v1.16.5 github.com/onsi/gomega v1.30.0 github.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.18.1 github.com/ulule/limiter v2.2.2+incompatible golang.org/x/sys v0.15.0 ) require ( github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gdamore/encoding v1.0.0 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/nxadm/tail v1.4.11 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb // indirect golang.org/x/net v0.19.0 // indirect golang.org/x/text v0.14.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: interfacer/go.sum ================================================ github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= github.com/gdamore/tcell v1.4.0 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU= github.com/gdamore/tcell v1.4.0/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8= github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0 h1:Xuk8ma/ibJ1fOy4Ee11vHhUFHQNpHhrBneOCNHVXS5w= github.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0/go.mod h1:7AwjWCpdPhkSmNAgUv5C7EJ4AbmjEB3r047r3DXWu3Y= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.1 h1:rmuU42rScKWlhhJDyXZRKJQHXFX02chSVW1IvkPGiVM= github.com/spf13/viper v1.18.1/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/ulule/limiter v2.2.2+incompatible h1:1lk9jesmps1ziYHHb4doL7l5hFkYYYA3T8dkNyw7ffY= github.com/ulule/limiter v2.2.2+incompatible/go.mod h1:VJx/ZNGmClQDS5F6EmsGqK8j3jz1qJYZ6D9+MdAD+kw= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8= golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM= golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: interfacer/src/browsh/browsh.go ================================================ package browsh import ( "encoding/base64" "fmt" "io" "log/slog" "net/url" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" // TCell seems to be one of the best projects in any language for handling terminal // standards across the major OSs. "github.com/gdamore/tcell" "github.com/go-errors/errors" "github.com/spf13/pflag" "github.com/spf13/viper" ) var ( logo = ` //// //// / / / / // // // // ,,,,,,,, //////// ..,,,,,,,,, // // .., ,,, .,. //////// .., ,,,,,.. //////// ..,,,,,,,,, //////// ........... ////////// ****/////////////////// ********/////////////// ***********************` // IsTesting is used in tests, so it needs to be exported IsTesting = false IsHTTPServerMode = false logfile string _ = pflag.Bool("version", false, "Output current Browsh version") ) func setupLogging() { out := io.Discard if *isDebug { dir, err := os.Getwd() if err != nil { Shutdown(err) } logfile = fmt.Sprintf("%s", filepath.Join(dir, "debug.log")) if out, err = os.OpenFile(logfile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644); err != nil { Shutdown(err) } } slog.SetDefault(slog.New(slog.NewTextHandler(out, nil))) } // Initialise browsh func Initialise() { if IsTesting { *isDebug = true } setupLogging() loadConfig() } // Shutdown tries its best to cleanly shutdown browsh and the associated browser func Shutdown(err error) { msg := "shutting down" var e *errors.Error if errors.As(err, &e) { slog.Error(msg, "errorStack", e.ErrorStack()) } else { slog.Error(msg, "error", err) } if screen != nil { screen.Fini() } exitCode := 0 if !errors.Is(err, errNormalExit) { exitCode = 1 } os.Exit(exitCode) } func Log(message string) { } func saveScreenshot(base64String string) { dec, err := base64.StdEncoding.DecodeString(base64String) if err != nil { Shutdown(err) } file, err := os.CreateTemp("", "browsh-screenshot") if err != nil { Shutdown(err) } defer file.Close() if _, err := file.Write(dec); err != nil { Shutdown(err) } if err := file.Sync(); err != nil { Shutdown(err) } fullPath := file.Name() + ".jpg" if err := os.Rename(file.Name(), fullPath); err != nil { Shutdown(err) } message := "Screenshot saved to " + fullPath sendMessageToWebExtension("/status," + message) } // Shell provides nice and easy shell commands func Shell(command string) string { parts := strings.Fields(command) head := parts[0] parts = parts[1:] out, err := exec.Command(head, parts...).CombinedOutput() if err != nil { err := fmt.Errorf( "Browsh tried to run `%s` but failed with: %s, err: %w", command, string(out), err, ) Shutdown(err) } return strings.TrimSpace(string(out)) } // TTYStart starts Browsh func TTYStart(injectedScreen tcell.Screen) { screen = injectedScreen setupTcell() writeString(1, 0, logo, tcell.StyleDefault) writeString( 0, 15, "Starting Browsh v"+browshVersion+", the modern text-based web browser.", tcell.StyleDefault, ) StartFirefox() slog.Info("Starting Browsh CLI client") go readStdin() startWebSocketServer() } func toInt(char string) int { i, err := strconv.ParseInt(char, 10, 16) if err != nil { Shutdown(err) } return int(i) } func toInt32(char string) int32 { i, err := strconv.ParseInt(char, 10, 32) if err != nil { Shutdown(err) } return int32(i) } func ttyEntry() { // Hack to force true colours // Follow: https://github.com/gdamore/tcell/pull/183 if runtime.GOOS != "windows" { // On windows this generates a "character set not supported" error. The error comes // from tcell. os.Setenv("TERM", "xterm-truecolor") } realScreen, err := tcell.NewScreen() if err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) os.Exit(1) } TTYStart(realScreen) } // MainEntry decides between running Browsh as a CLI app or as an HTTP web server func MainEntry() { pflag.Parse() // validURL contains array of valid user inputted links. var validURL []string if pflag.NArg() != 0 { for i := 0; i < len(pflag.Args()); i++ { u, _ := url.ParseRequestURI(pflag.Args()[i]) if u != nil { validURL = append(validURL, pflag.Args()[i]) } } } viper.SetDefault("validURL", validURL) Initialise() // Print version if asked and exit if viper.GetBool("version") || viper.GetBool("v") { println(browshVersion) os.Exit(0) } // Print name if asked and exit if viper.GetBool("name") || viper.GetBool("n") { println("Browsh") os.Exit(0) } // Decide whether to run in http-server-mode or CLI app if viper.GetBool("http-server-mode") { HTTPServerStart() } else { ttyEntry() } } ================================================ FILE: interfacer/src/browsh/cells.go ================================================ package browsh import ( "sync" "github.com/gdamore/tcell" ) // A cell represents an individual TTY cell. An entire representation of the browser // DOM is stored in a local in-memory "frame". The TTY can then quickly render a region // of this frame for fast scrolling. type cell struct { character []rune fgColour tcell.Color bgColour tcell.Color } // Both updating a frame and scrolling a frame can happen at the same time, so we need // to use mutexes. type threadSafeCellsMap struct { sync.RWMutex internal map[int]cell } func newCellsMap() *threadSafeCellsMap { return &threadSafeCellsMap{ internal: make(map[int]cell), } } func (m *threadSafeCellsMap) load(key int) (value cell, ok bool) { m.RLock() result, ok := m.internal[key] m.RUnlock() return result, ok } func (m *threadSafeCellsMap) store(key int, value cell) { m.Lock() m.internal[key] = value m.Unlock() } ================================================ FILE: interfacer/src/browsh/comms.go ================================================ package browsh import ( "encoding/json" "fmt" "log/slog" "net/http" "strings" "github.com/go-errors/errors" "github.com/gorilla/websocket" "github.com/spf13/viper" ) var ( upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, ReadBufferSize: 1024, WriteBufferSize: 1024, } stdinChannel = make(chan string) IsConnectedToWebExtension = false ) type incomingRawText struct { RequestID string `json:"request_id"` RawJSON string `json:"json"` } func startWebSocketServer() { serverMux := http.NewServeMux() serverMux.HandleFunc("/", webSocketServer) port := viper.GetString("browsh.websocket-port") slog.Info("Starting websocket server...") if netErr := http.ListenAndServe(":"+port, serverMux); netErr != nil { Shutdown(fmt.Errorf("Error starting websocket server: %w", netErr)) } } func sendMessageToWebExtension(message string) { if !IsConnectedToWebExtension { slog.Info("Webextension not connected. Message not sent", "message", message) return } stdinChannel <- message } // Listen to all messages coming from the webextension // TODO: It seems this *also* receives sent to the webextention!? func webSocketReader(ws *websocket.Conn) { defer ws.Close() for { _, message, err := ws.ReadMessage() handleWebextensionCommand(message) if err != nil { if websocket.IsCloseError(err, websocket.CloseGoingAway) { slog.Info("Socket reader detected that the browser closed the websocket") triggerSocketWriterClose() return } if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) { slog.Error("Socket reader detected that the connection unexpectedly dissapeared") triggerSocketWriterClose() return } Shutdown(err) } } } func handleWebextensionCommand(message []byte) { parts := strings.Split(string(message), ",") command := parts[0] if viper.GetBool("http-server-mode") { handleRawFrameTextCommands(parts) return } switch command { case "/frame_text": parseJSONFrameText(strings.Join(parts[1:], ",")) renderCurrentTabWindow() case "/frame_pixels": parseJSONFramePixels(strings.Join(parts[1:], ",")) renderCurrentTabWindow() case "/tab_state": parseJSONTabState(strings.Join(parts[1:], ",")) if CurrentTab != nil { renderUI() } case "/screenshot": saveScreenshot(parts[1]) default: slog.Info("WEBEXT", "message", string(message)) } } func handleRawFrameTextCommands(parts []string) { var incoming incomingRawText command := parts[0] if command == "/raw_text" { jsonBytes := []byte(strings.Join(parts[1:], ",")) if err := json.Unmarshal(jsonBytes, &incoming); err != nil { Shutdown(err) } if incoming.RequestID != "" { slog.Info("Raw text for", "RequestID", incoming.RequestID) rawTextRequests.store(incoming.RequestID, incoming.RawJSON) } else { slog.Info("Raw text but no associated request ID") } } else { slog.Info("WEBEXT", "command", strings.Join(parts[0:], ",")) } } // When the socket reader attempts to read from a closed websocket it quickly and // simply closes its associated Go routine. However the socket writer won't // automatically notice until it actually needs to send something. So we force that // by sending this NOOP text. // TODO: There's a potential race condition because new connections share the same // // Go channel. So we need to setup a new channel for every connection. func triggerSocketWriterClose() { stdinChannel <- "BROWSH CLIENT FORCING CLOSE OF WEBSOCKET WRITER" } // Send a message to the webextension func webSocketWriter(ws *websocket.Conn) { var message string defer ws.Close() for { message = <-stdinChannel slog.Info("TTY sending", "message", message) if err := ws.WriteMessage(websocket.TextMessage, []byte(message)); err != nil { if errors.Is(err, websocket.ErrCloseSent) { slog.Info("Socket writer detected that the browser closed the websocket") } else { slog.Error("Socket writer detected unexpected closure of websocket", "error", err) } return } } } func webSocketServer(w http.ResponseWriter, r *http.Request) { slog.Info("Incoming web request from browser") ws, err := upgrader.Upgrade(w, r, nil) if err != nil { Shutdown(err) } IsConnectedToWebExtension = true go webSocketWriter(ws) go webSocketReader(ws) sendConfigToWebExtension() setDefaultFirefoxPreferences() if !viper.GetBool("http-server-mode") { sendTtySize() } // For some reason, using Firefox's CLI arg `--url https://google.com` doesn't consistently // work. So we do it here instead. validURL := viper.GetStringSlice("validURL") if len(validURL) == 0 { if !IsHTTPServerMode { sendMessageToWebExtension("/new_tab," + viper.GetString("startup-url")) } } else { for i := 0; i < len(validURL); i++ { sendMessageToWebExtension("/new_tab," + validURL[i]) } } } func sendConfigToWebExtension() { configJSON, _ := json.Marshal(viper.AllSettings()) sendMessageToWebExtension("/config," + string(configJSON)) } ================================================ FILE: interfacer/src/browsh/config.go ================================================ package browsh import ( "bytes" "fmt" "log/slog" "os" "path/filepath" "strings" "github.com/shibukawa/configdir" "github.com/spf13/pflag" "github.com/spf13/viper" ) var ( configFilename = "config.toml" isDebug = pflag.Bool("debug", false, "slog.Info to ./debug.log") timeLimit = pflag.Int("time-limit", 0, "Kill Browsh after the specified number of seconds") _ = pflag.Bool("http-server-mode", false, "Run as an HTTP service") _ = pflag.String("startup-url", "https://www.brow.sh", "URL to launch at startup") _ = pflag.String("firefox.path", "firefox", "Path to Firefox executable") _ = pflag.Bool("firefox.with-gui", false, "Don't use headless Firefox") _ = pflag.Bool("firefox.use-existing", false, "Whether Browsh should launch Firefox or not") _ = pflag.Bool("monochrome", false, "Start browsh in monochrome mode") _ = pflag.Bool("name", false, "Print out the name: Browsh") ) func getConfigNamespace() string { if IsTesting { return "browsh-testing" } return "browsh" } // Gets a cross-platform path to a folder containing Browsh config func getConfigDir() string { marker := "browsh-settings" // configdir has no other option but to have a nested folder configDirs := configdir.New(getConfigNamespace(), marker) folders := configDirs.QueryFolders(configdir.Global) // Delete the previously enforced nested folder path := strings.Trim(folders[0].Path, marker) os.MkdirAll(path, os.ModePerm) ensureConfigFile(path) return path } // Copy the sample config file if the user doesn't already have a config file func ensureConfigFile(path string) { fullPath := filepath.Join(path, configFilename) if _, err := os.Stat(fullPath); os.IsNotExist(err) { file, err := os.Create(fullPath) if err != nil { Shutdown(err) } defer file.Close() _, err = file.WriteString(configSample) if err != nil { Shutdown(err) } } } // Gets a cross-platform path to store a Browsh-specific Firefox profile func getFirefoxProfilePath() string { configDirs := configdir.New(getConfigNamespace(), "firefox_profile") folders := configDirs.QueryFolders(configdir.Global) folders[0].MkdirAll() return folders[0].Path } func setDefaults() { // Temporary experimental configurable keybindings viper.SetDefault("tty.keys.next-tab", []string{"\u001c", "28", "2"}) } func loadConfig() { dir := getConfigDir() fullPath := filepath.Join(dir, configFilename) slog.Info("Looking in " + fullPath + " for config.") viper.SetConfigType("toml") viper.SetConfigName(strings.Trim(configFilename, ".toml")) viper.AddConfigPath(dir) viper.AddConfigPath(".") setDefaults() // First load the sample config in case the user hasn't updated any new fields if err := viper.ReadConfig(bytes.NewBuffer([]byte(configSample))); err != nil { panic(fmt.Errorf("Config file error: %s \n", err)) } // Then load the users own config file, overwriting the sample config if err := viper.MergeInConfig(); err != nil { panic(fmt.Errorf("Config file error: %s \n", err)) } viper.BindPFlags(pflag.CommandLine) } ================================================ FILE: interfacer/src/browsh/config_sample.go ================================================ package browsh var configSample = ` # See; https://www.brow.sh/donate/ # By showing your support you can disable the app's branding and nags to donate. browsh_supporter = "♥" # The page to show at startup. Browsh will fail to boot if this URL is not accessible startup-url = "http://www.brow.sh" # The base query when a non-URL is entered into the URL bar default_search_engine_base = "https://www.google.com/search?q=" # The mobile user agent for forcing web pages to use their mobile layout mobile_user_agent = "Mozilla/5.0 (Android 7.0; Mobile; rv:54.0) Gecko/58.0 Firefox/58.0" [browsh] # Browsh internals websocket-port = 3334 # 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. use_experimental_text_visibility = false # Custom CSS to apply to all loaded tabs, eg; # custom_css = """ # body { # background-colour: black; # } # """ custom_css = "" [firefox] # The path to your Firefox binary path = "firefox" # Browsh has its own profile, seperate from the normal user's. But you can change that. profile = "browsh-default" # 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. use-existing = false # Launch Firefox in with its visible GUI window. Useful for setting up the Browsh profile. with-gui = false # Config that you might usually set through Firefox's 'about:config' page # Note that string must be wrapped in quotes # preferences = [ # "privacy.resistFingerprinting=true", # "network.proxy.http='localhost'", # "network.proxy.ssl='localhost'", # "network.proxy.http_port=8118", # "network.proxy.ssl_port=8118", # "network.proxy.type=1" # ] [tty] # 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. small_pixel_frame_rate = 250 [http-server] port = 4333 bind = "0.0.0.0" # 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 unecessarily. render_delay = 100 # The length of time in seconds to wait before aborting the page load timeout = 30 # The dimensions of a char-based window onto a webpage. # The columns are ultimately the width of the final text. 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. columns = 100 rows = 30 # The amount of lossy JPG compression to apply to the background image of HTML # pages. jpeg_compression = 0.9 # Rate limit. For syntax, see: https://github.com/ulule/limiter rate-limit = "100000000-M" # Blocking is useful if the HTTP server is made public. All values are evaluated as # regular expressions. blocked-domains = [ ] blocked-user-agents = [ ] # HTML snippets to show at top and bottom of final page. header = "" footer = "" ` ================================================ FILE: interfacer/src/browsh/firefox.go ================================================ package browsh import ( "bufio" "embed" "encoding/json" "fmt" "io/fs" "log/slog" "net" "os" "os/exec" "os/signal" "path" "regexp" "runtime" "strings" "syscall" "time" "github.com/gdamore/tcell" "github.com/go-errors/errors" "github.com/spf13/viper" ) //go:embed browsh.xpi var browshXpi embed.FS var ( marionette net.Conn ffCommandCount = 0 defaultFFPrefs = map[string]string{ "startup.homepage_welcome_url.additional": "''", "devtools.errorconsole.enabled": "true", "devtools.chrome.enabled": "true", // Send Browser Console (different from Devtools console) output to // STDOUT. "browser.dom.window.dump.enabled": "true", // From: // http://hg.mozilla.org/mozilla-central/file/1dd81c324ac7/build/automation.py.in//l388 // Make url-classifier updates so rare that they won"t affect tests. "urlclassifier.updateinterval": "172800", // Point the url-classifier to a nonexistent local URL for fast failures. "browser.safebrowsing.provider.0.gethashURL": "'http://localhost/safebrowsing-dummy/gethash'", "browser.safebrowsing.provider.0.keyURL": "'http://localhost/safebrowsing-dummy/newkey'", "browser.safebrowsing.provider.0.updateURL": "'http://localhost/safebrowsing-dummy/update'", // Disable self repair/SHIELD "browser.selfsupport.url": "'https://localhost/selfrepair'", // Disable Reader Mode UI tour "browser.reader.detectedFirstArticle": "true", // Set the policy firstURL to an empty string to prevent // the privacy info page to be opened on every "web-ext run". // (See #1114 for rationale) "datareporting.policy.firstRunURL": "''", } ) func startHeadlessFirefox() { slog.Info("Starting Firefox in headless mode") checkIfFirefoxIsAlreadyRunning() firefoxPath := ensureFirefoxBinary() ensureFirefoxVersion(firefoxPath) args := []string{"--marionette"} if !viper.GetBool("firefox.with-gui") { args = append(args, "--headless") } profile := viper.GetString("firefox.profile") if profile != "browsh-default" { slog.Info("Using Firefox profile", "profile", profile) args = append(args, "-P", profile) } else { profilePath := getFirefoxProfilePath() slog.Info("Using default profile", "path", profilePath) args = append(args, "--profile", profilePath) } firefoxProcess := exec.Command(firefoxPath, args...) defer firefoxProcess.Process.Kill() stdout, err := firefoxProcess.StdoutPipe() if err != nil { Shutdown(err) } if err := firefoxProcess.Start(); err != nil { Shutdown(err) } in := bufio.NewScanner(stdout) for in.Scan() { slog.Info("FF-CONSOLE", "stdout", in.Text()) } } func checkIfFirefoxIsAlreadyRunning() { if runtime.GOOS == "windows" { return } processes := Shell("ps aux") r, _ := regexp.Compile("firefox.*--headless") if r.MatchString(processes) { Shutdown(errors.New("A headless Firefox is already running")) } } func ensureFirefoxBinary() string { path := viper.GetString("firefox.path") if path == "firefox" { switch runtime.GOOS { case "windows": path = getFirefoxPath() case "darwin": path = "/Applications/Firefox.app/Contents/MacOS/firefox" default: path = getFirefoxPath() } } if _, err := os.Stat(path); err != nil { if errors.Is(err, fs.ErrNotExist) { err = errors.New("Firefox binary not found: " + path) } Shutdown(err) } slog.Info("Using Firefox", "path", path) return path } // Taken from https://stackoverflow.com/a/18411978/575773 func versionOrdinal(version string) string { // ISO/IEC 14651:2011 const maxByte = 1<<8 - 1 vo := make([]byte, 0, len(version)+8) j := -1 for i := 0; i < len(version); i++ { b := version[i] if '0' > b || b > '9' { vo = append(vo, b) j = -1 continue } if j == -1 { vo = append(vo, 0x00) j = len(vo) - 1 } if vo[j] == 1 && vo[j+1] == '0' { vo[j+1] = b continue } if vo[j]+1 > maxByte { panic("VersionOrdinal: invalid version") } vo = append(vo, b) vo[j]++ } return string(vo) } // Start Firefox via the `web-ext` CLI tool. This is for development and testing, // because I haven't been able to recreate the way `web-ext` injects an unsigned // extension. func startWERFirefox() { slog.Info("Attempting to start headless Firefox with `web-ext`") if IsConnectedToWebExtension { Shutdown(errors.New("There appears to already be an existing Web Extension connection")) } checkIfFirefoxIsAlreadyRunning() rootDir := Shell("git rev-parse --show-toplevel") args := []string{ "run", "--firefox=" + rootDir + "/webext/contrib/firefoxheadless.sh", "--verbose", "--no-reload", } firefoxProcess := exec.Command(rootDir+"/webext/node_modules/.bin/web-ext", args...) firefoxProcess.Dir = rootDir + "/webext/dist/" stdout, err := firefoxProcess.StdoutPipe() if err != nil { Shutdown(err) } if err := firefoxProcess.Start(); err != nil { Shutdown(err) } in := bufio.NewScanner(stdout) for in.Scan() { if strings.Contains(in.Text(), "Connected to the remote Firefox debugger") { } if strings.Contains(in.Text(), "JavaScript strict") || strings.Contains(in.Text(), "D-BUS") || strings.Contains(in.Text(), "dbus") { continue } slog.Info("FF-CONSOLE", "stdout", in.Text()) } slog.Info("WER Firefox unexpectedly closed") } // Connect to Firefox's Marionette service. // RANT: Firefox's remote control tools are so confusing. There seem to be 2 // services that come with your Firefox binary; Marionette and the Remote // Debugger. The latter you would expect to follow the widely supported // Chrome standard, but no, it's merely on the roadmap. There is very little // documentation on either. I have the impression, but I'm not sure why, that // the Remote Debugger is better, seemingly more API methods, and as mentioned // is on the roadmap to follow the Chrome standard. // I've used Marionette here, simply because it was easier to reverse engineer // from the Python Marionette package. func firefoxMarionette() { var ( err error conn net.Conn ) connected := false slog.Info("Attempting to connect to Firefox Marionette") start := time.Now() for time.Since(start) < 30*time.Second { conn, err = net.Dial("tcp", "127.0.0.1:2828") if err != nil { if !strings.Contains(err.Error(), "refused") { Shutdown(err) } else { time.Sleep(10 * time.Millisecond) continue } } else { connected = true break } } if !connected { Shutdown(errors.New("Failed to connect to Firefox's Marionette within 30 seconds")) } marionette = conn go readMarionette() sendFirefoxCommand("WebDriver:NewSession", map[string]interface{}{}) } func installWebextension() { data, err := browshXpi.ReadFile("browsh.xpi") if err != nil { Shutdown(err) } path := path.Join(os.TempDir(), "browsh-webext-addon") if err := os.WriteFile(path, []byte(data), 0644); err != nil { Shutdown(err) } args := map[string]interface{}{"path": path} sendFirefoxCommand("Addon:Install", args) } // Set a Firefox preference as you would in `about:config` // `value` needs to be supplied with quotes if it's to be used as a JS string func setFFPreference(key string, value string) { var args map[string]interface{} var script string sendFirefoxCommand("Marionette:SetContext", map[string]interface{}{"value": "chrome"}) script = fmt.Sprintf(` Components.utils.import("resource://gre/modules/Preferences.jsm"); prefs = new Preferences({defaultBranch: "root"}); prefs.set("%s", %s);`, key, value) args = map[string]interface{}{"script": script} sendFirefoxCommand("WebDriver:ExecuteScript", args) sendFirefoxCommand("Marionette:SetContext", map[string]interface{}{"value": "content"}) } // Consume output from Marionette, we don't do anything with it. It"s just // useful to have it in the logs. func readMarionette() { buffer := make([]byte, 4096) count, err := marionette.Read(buffer) if err != nil { slog.Error("Error reading from Marionette connection", "error", err) return } slog.Info("FF-MRNT", "buffer", string(buffer[:count])) } func sendFirefoxCommand(command string, args map[string]interface{}) { slog.Info("Sending command to Firefox Marionette", "command", command, "args", args) fullCommand := []interface{}{0, ffCommandCount, command, args} marshalled, _ := json.Marshal(fullCommand) message := fmt.Sprintf("%d:%s", len(marshalled), marshalled) fmt.Fprintf(marionette, "%s", message) ffCommandCount++ go readMarionette() } func setDefaultFirefoxPreferences() { for key, value := range defaultFFPrefs { setFFPreference(key, value) } for _, pref := range viper.GetStringSlice("firefox.preferences") { parts := strings.SplitN(pref, "=", 2) setFFPreference(parts[0], parts[1]) } } func beginTimeLimit() { warningLength := 10 warningLimit := time.Duration(*timeLimit - warningLength) time.Sleep(warningLimit * time.Second) message := fmt.Sprintf("Browsh will close in %d seconds...", warningLength) sendMessageToWebExtension("/status," + message) time.Sleep(time.Duration(warningLength) * time.Second) quitBrowsh() } // Careful what you change here as it isn't tested during CI func setupFirefox() { go startHeadlessFirefox() if *timeLimit > 0 { go beginTimeLimit() } sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) go func() { <-sigs quitBrowsh() }() firefoxMarionette() installWebextension() } func StartFirefox() { if !viper.GetBool("firefox.use-existing") { writeString(0, 16, "Waiting for Firefox to connect...", tcell.StyleDefault) if IsTesting { writeString(0, 17, "TEST MODE", tcell.StyleDefault) go startWERFirefox() firefoxMarionette() } else { setupFirefox() } } else { firefoxMarionette() writeString(0, 16, "Waiting for a user-initiated Firefox instance to connect...", tcell.StyleDefault) } } func quitFirefox() { sendFirefoxCommand("Marionette:Quit", map[string]interface{}{}) } ================================================ FILE: interfacer/src/browsh/firefox_unix.go ================================================ //go:build darwin || dragonfly || freebsd || linux || nacl || netbsd || openbsd || solaris package browsh import ( "strings" "github.com/go-errors/errors" ) func getFirefoxPath() string { return Shell("which firefox") } func ensureFirefoxVersion(path string) { output := Shell(path + " --version") pieces := strings.Split(output, " ") version := pieces[len(pieces)-1] if versionOrdinal(version) < versionOrdinal("57") { message := "Installed Firefox version " + version + " is too old. " + "Firefox 57 or newer is needed." Shutdown(errors.New(message)) } } ================================================ FILE: interfacer/src/browsh/firefox_windows.go ================================================ //go:build windows package browsh import ( "fmt" "log/slog" "strings" "github.com/go-errors/errors" "golang.org/x/sys/windows/registry" ) func getFirefoxPath() string { versionString := getWindowsFirefoxVersionString() flavor := getFirefoxFlavor() k, err := registry.OpenKey( registry.LOCAL_MACHINE, `Software\Mozilla\`+flavor+`\`+versionString+`\Main`, registry.QUERY_VALUE) if err != nil { Shutdown(fmt.Errorf("Error reading Windows registry: %w", err)) } defer k.Close() path, _, err := k.GetStringValue("PathToExe") if err != nil { Shutdown(fmt.Errorf("Error reading Windows registry: %w", err)) } return path } func getWindowsFirefoxVersionString() string { flavor := getFirefoxFlavor() k, err := registry.OpenKey( registry.LOCAL_MACHINE, `Software\Mozilla\`+flavor, registry.QUERY_VALUE) if err != nil { Shutdown(fmt.Errorf("Error reading Windows registry: %w", err)) } defer k.Close() versionString, _, err := k.GetStringValue("CurrentVersion") if err != nil { Shutdown(fmt.Errorf("Error reading Windows registry: %w", err)) } slog.Info("Windows registry Firefox", "version", versionString) return versionString } func getFirefoxFlavor() string { flavor := "null" k, err := registry.OpenKey( registry.LOCAL_MACHINE, `Software\Mozilla\Mozilla Firefox`, registry.QUERY_VALUE) if err == nil { flavor = "Mozilla Firefox" } defer k.Close() if flavor == "null" { k, err := registry.OpenKey( registry.LOCAL_MACHINE, `Software\Mozilla\Firefox Developer Edition`, registry.QUERY_VALUE) if err == nil { flavor = "Firefox Developer Edition" } defer k.Close() } if flavor == "null" { k, err := registry.OpenKey( registry.LOCAL_MACHINE, `Software\Mozilla\Nightly`, registry.QUERY_VALUE) if err == nil { flavor = "Nightly" } defer k.Close() } if flavor == "null" { Shutdown(errors.New("Could not find Firefox on your registry")) } return flavor } func ensureFirefoxVersion(path string) { versionString := getWindowsFirefoxVersionString() pieces := strings.Split(versionString, " ") version := pieces[0] if versionOrdinal(version) < versionOrdinal("57") { message := "Installed Firefox version " + version + " is too old. " + "Firefox 57 or newer is needed." Shutdown(errors.New(message)) } } ================================================ FILE: interfacer/src/browsh/frame_builder.go ================================================ package browsh import ( "encoding/json" "fmt" "log/slog" "unicode" "github.com/gdamore/tcell" ) // A frame is a single snapshot of the DOM. The TTY is merely a window onto a // region of this frame. type frame struct { // Dimensions of the frame's real data. Can be less than the DOM dimensions because // we cannot sync frames of unlimited size from the browser. subWidth int subHeight int // If the frame is smaller than the DOM, then this is the frame's position // within the overall DOM. subLeft int subTop int // The total DOM dimensions. These are measured in the same units of the frame totalWidth int totalHeight int // The current position of the scroll in the TTY. Should be synced with the real // browser. xScroll int yScroll int // Usually we want to just overlay new data. But if the DOM changes then all bets are off // and we need to start from scratch again. It's just too unpredictable how data for a DOM // of a different size and shape will interact with data from another DOM. isDOMSizeChanged bool // Raw data used to build a single, usable frame pixels map[int][2]tcell.Color text map[int][]rune textColours map[int]tcell.Color // The actual built frame, can be used to render cells to the TTY cells *threadSafeCellsMap // Input boxes, like for entering passwords, sending emails etc inputBoxes map[string]*inputBox } type jsonFrameBase struct { TabID int `json:"id"` SubWidth int `json:"sub_width"` SubHeight int `json:"sub_height"` SubLeft int `json:"sub_left"` SubTop int `json:"sub_top"` TotalWidth int `json:"total_width"` TotalHeight int `json:"total_height"` } type incomingFrameText struct { Meta jsonFrameBase `json:"meta"` Text []string `json:"text"` Colours []int32 `json:"colours"` InputBoxes map[string]inputBox `json:"input_boxes"` } // TODO: Can these be sent as binary blobs? type incomingFramePixels struct { Meta jsonFrameBase `json:"meta"` Colours []int32 `json:"colours"` } func (f *frame) domRowCount() int { return f.totalHeight / 2 } func (f *frame) subRowCount() int { return f.subHeight / 2 } func parseJSONFrameText(jsonString string) { var incoming incomingFrameText jsonBytes := []byte(jsonString) if err := json.Unmarshal(jsonBytes, &incoming); err != nil { Shutdown(err) } if !isTabPresent(incoming.Meta.TabID) { slog.Info( fmt.Sprintf("Not building frame for non-existent tab ID: %d", incoming.Meta.TabID), ) return } Tabs[incoming.Meta.TabID].frame.buildFrameText(incoming) } func (f *frame) buildFrameText(incoming incomingFrameText) { f.setup(incoming.Meta) if !f.isIncomingFrameTextValid(incoming) { return } f.updateInputBoxes(incoming) f.populateFrameText(incoming) } func parseJSONFramePixels(jsonString string) { var incoming incomingFramePixels jsonBytes := []byte(jsonString) if err := json.Unmarshal(jsonBytes, &incoming); err != nil { Shutdown(err) } if !isTabPresent(incoming.Meta.TabID) { slog.Warn("Not building frame for non-existent tab ID", "TabID", incoming.Meta.TabID) return } if len(Tabs[incoming.Meta.TabID].frame.text) == 0 { return } Tabs[incoming.Meta.TabID].frame.buildFramePixels(incoming) } func (f *frame) buildFramePixels(incoming incomingFramePixels) { f.setup(incoming.Meta) if !f.isIncomingFramePixelsValid(incoming) { return } f.populateFramePixels(incoming) } func (f *frame) setup(meta jsonFrameBase) { f.isDOMSizeChanged = meta.TotalWidth != f.totalWidth || meta.TotalHeight != f.totalHeight if f.isDOMSizeChanged || f.cells == nil { f.resetCells() } if f.inputBoxes == nil { f.inputBoxes = make(map[string]*inputBox) } f.subWidth = meta.SubWidth f.subHeight = meta.SubHeight f.totalWidth = meta.TotalWidth f.totalHeight = meta.TotalHeight f.subLeft = meta.SubLeft f.subTop = meta.SubTop } func (f *frame) resetCells() { f.cells = newCellsMap() } func (f *frame) isIncomingFrameTextValid(incoming incomingFrameText) bool { if len(incoming.Text) == 0 { slog.Warn("Not parsing zero-size text frame") return false } return true } // TODO: There must be a more idiomatic way of doing this? func (f *frame) updateInputBoxes(incoming incomingFrameText) { for _, existingInputBox := range f.inputBoxes { if _, ok := incoming.InputBoxes[existingInputBox.ID]; !ok { // TODO: Does this also delete the memory pointed to by the reference? delete(f.inputBoxes, existingInputBox.ID) } } for _, incomingInputBox := range incoming.InputBoxes { if _, ok := f.inputBoxes[incomingInputBox.ID]; !ok { f.inputBoxes[incomingInputBox.ID] = newInputBox(incomingInputBox.ID) } inputBox := f.inputBoxes[incomingInputBox.ID] inputBox.X = incomingInputBox.X // TODO: Why do we have to add the 1 to the y coord?? inputBox.Y = (incomingInputBox.Y + 1) / 2 inputBox.Width = incomingInputBox.Width inputBox.Height = incomingInputBox.Height / 2 inputBox.FgColour = incomingInputBox.FgColour inputBox.TagName = incomingInputBox.TagName inputBox.Type = incomingInputBox.Type } } func (f *frame) populateFrameText(incoming incomingFrameText) { var cellIndex, frameIndex, colourIndex int if f.isDOMSizeChanged || f.text == nil { f.text = make(map[int][]rune, (f.domRowCount())*f.totalWidth) f.textColours = make(map[int]tcell.Color, (f.domRowCount())*f.totalWidth) } for y := 0; y < f.subRowCount(); y++ { for x := 0; x < f.subWidth; x++ { cellIndex = f.getCellIndexFromSubCoords(x, y*2) frameIndex = (y * f.subWidth) + x colourIndex = frameIndex * 3 f.textColours[cellIndex] = tcell.NewRGBColor( incoming.Colours[colourIndex+0], incoming.Colours[colourIndex+1], incoming.Colours[colourIndex+2], ) f.text[cellIndex] = []rune(incoming.Text[frameIndex]) f.buildCell(f.subLeft+x, (f.subTop/2)+y) } } } func (f *frame) populateFramePixels(incoming incomingFramePixels) { var cellIndex, frameIndexFg, frameIndexBg, pixelIndexFg, pixelIndexBg int if f.isDOMSizeChanged || f.pixels == nil { f.pixels = make(map[int][2]tcell.Color, f.totalHeight*f.totalWidth) } data := incoming.Colours for y := 0; y < f.subHeight; y += 2 { for x := 0; x < f.subWidth; x++ { cellIndex = f.getCellIndexFromSubCoords(x, y) frameIndexBg = (y * f.subWidth) + x frameIndexFg = ((y + 1) * f.subWidth) + x pixelIndexBg = frameIndexBg * 3 pixelIndexFg = frameIndexFg * 3 pixels := [2]tcell.Color{ tcell.NewRGBColor( data[pixelIndexBg+0], data[pixelIndexBg+1], data[pixelIndexBg+2], ), tcell.NewRGBColor( data[pixelIndexFg+0], data[pixelIndexFg+1], data[pixelIndexFg+2], ), } f.pixels[cellIndex] = pixels f.buildCell(f.subLeft+x, (f.subTop+y)/2) } } } func (f *frame) isIncomingFramePixelsValid(incoming incomingFramePixels) bool { if len(incoming.Colours) == 0 { slog.Warn("Not parsing zero-size text frame") return false } return true } // This is where we implement the UTF8 half-block trick. // This a half-block: "▄", notice how it takes up precisely half a text cell. This // means that we can get 2 pixel colours from it, the top pixel comes from setting // the background colour and the bottom pixel comes from setting the foreground // colour, namely the colour of the text. func (f *frame) buildCell(x int, y int) { index := (y * f.totalWidth) + x character, fgColour := f.getCharacterAt(index) pixelFg, bgColour := f.getPixelColoursAt(index) if isCharacterTransparent(character) { character = []rune("▄") fgColour = pixelFg } f.addCell(index, fgColour, bgColour, character) } func (f *frame) getCharacterAt(index int) ([]rune, tcell.Color) { var colour tcell.Color var character []rune if result, ok := f.text[index]; ok { character = result colour = f.textColours[index] } else { character = []rune(" ") colour = tcell.ColorBlack } return character, colour } func (f *frame) getPixelColoursAt(index int) (tcell.Color, tcell.Color) { var fgColour, bgColour tcell.Color if result, ok := f.pixels[index]; ok { bgColour = result[0] fgColour = result[1] } else { x := index % f.subWidth fgColour, bgColour = getHatchedCellColours(x) } return fgColour, bgColour } func isCharacterTransparent(character []rune) bool { return string(character) == "" || unicode.IsSpace(character[0]) } func (f *frame) addCell(index int, fgColour, bgColour tcell.Color, character []rune) { newCell := cell{ fgColour: fgColour, bgColour: bgColour, character: character, } f.cells.store(index, newCell) } // When iterating over a sub frame we still need to place the resulting data into the // overall frame grid. So here we're essentially mapping relative coordinates to // absolute ones. Also note that the y coord is converted from the frame pixels value // to the TTY row value. func (f *frame) getCellIndexFromSubCoords(x, y int) int { yInAbsoluteFrameTTY := (y + f.subTop) / 2 return (yInAbsoluteFrameTTY * f.totalWidth) + (x + f.subLeft) } func (f *frame) limitScroll(height int) { maxYScroll := f.domRowCount() - height if f.yScroll > maxYScroll { f.yScroll = maxYScroll } if f.yScroll < 0 { f.yScroll = 0 } } func (f *frame) maybeFocusInputBox(x, y int) { activeInputBox = nil for _, inputBox := range f.inputBoxes { inputBox.isActive = false top := inputBox.Y bottom := inputBox.Y + inputBox.Height left := inputBox.X right := inputBox.X + inputBox.Width if x >= left && x < right && y >= top && y < bottom { urlBarFocus(false) inputBox.isActive = true activeInputBox = inputBox } } } func (f *frame) overlayInputBoxContent() { for _, inputBox := range f.inputBoxes { inputBox.setCells() } } ================================================ FILE: interfacer/src/browsh/frame_builder_test.go ================================================ package browsh import ( "fmt" "testing" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) func TestFrameBuilder(t *testing.T) { RegisterFailHandler(Fail) } var testFrame *frame func testGetCell(index int) cell { result, _ := Tabs[1].frame.cells.load(index) return result } func testGetCellChar(index int) string { result, _ := Tabs[1].frame.cells.load(index) return string(result.character[0]) } func debugCells() { fmt.Printf("\n") for i := 0; i < 20; i++ { if result, ok := Tabs[1].frame.cells.load(i); ok { fmt.Printf("%d:%s ", i, string(result.character[0])) } } } var _ = Describe("Frame struct", func() { BeforeEach(func() { newTab(1) }) Describe("No Offset", func() { var frameJSONText = `{ "meta": { "id": 1, "sub_left": 0, "sub_top": 0, "sub_width": 2, "sub_height": 4, "total_width": 2, "total_height": 8 }, "text": ["A", "b", "c", ""], "colours": [ 77, 77, 77, 101, 101, 101, 102, 102, 102, 103, 103, 103 ] }` var frameJSONPixels = `{ "meta": { "id": 1, "sub_left": 0, "sub_top": 0, "sub_width": 2, "sub_height": 4, "total_width": 2, "total_height": 8 }, "colours": [ 254, 254, 254, 111, 111, 111, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 123, 123, 123, 200, 200, 200 ] }` BeforeEach(func() { parseJSONFrameText(frameJSONText) }) It("should parse JSON frame text", func() { Expect(testGetCell(0).character[0]).To(Equal('A')) Expect(testGetCell(1).character[0]).To(Equal('b')) Expect(testGetCell(2).character[0]).To(Equal('c')) Expect(testGetCell(3).character[0]).To(Equal('▄')) }) It("should parse JSON pixels (for text-less cells)", func() { var r, g, b int32 parseJSONFramePixels(frameJSONPixels) r, g, b = testGetCell(3).fgColour.RGB() Expect([3]int32{r, g, b}).To(Equal([3]int32{200, 200, 200})) r, g, b = testGetCell(3).bgColour.RGB() Expect([3]int32{r, g, b}).To(Equal([3]int32{4, 4, 4})) }) It("should parse JSON pixels (using text for foreground)", func() { var r, g, b int32 parseJSONFramePixels(frameJSONPixels) r, g, b = testGetCell(0).fgColour.RGB() Expect([3]int32{r, g, b}).To(Equal([3]int32{77, 77, 77})) r, g, b = testGetCell(0).bgColour.RGB() Expect([3]int32{r, g, b}).To(Equal([3]int32{254, 254, 254})) }) }) Describe("With Offset", func() { var subFrameJSONText = `{ "meta": { "id": 1, "sub_left": 1, "sub_top": 2, "sub_width": 2, "sub_height": 4, "total_width": 3, "total_height": 8 }, "text": ["A", "b", "c", ""], "colours": [ 77, 77, 77, 101, 101, 101, 102, 102, 102, 103, 103, 103 ] }` var subFrameJSONPixels = `{ "meta": { "id": 1, "sub_left": 1, "sub_top": 2, "sub_width": 2, "sub_height": 4, "total_width": 3, "total_height": 8 }, "colours": [ 254, 254, 254, 111, 111, 111, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 123, 123, 123, 200, 200, 200 ] }` BeforeEach(func() { parseJSONFrameText(subFrameJSONText) }) It("should parse text for an offset sub-frame", func() { Expect(testGetCell(4).character[0]).To(Equal('A')) Expect(testGetCell(5).character[0]).To(Equal('b')) Expect(testGetCell(7).character[0]).To(Equal('c')) Expect(testGetCell(8).character[0]).To(Equal('▄')) }) It("should parse offset JSON pixels (for text-less cells)", func() { var r, g, b int32 parseJSONFramePixels(subFrameJSONPixels) r, g, b = testGetCell(8).fgColour.RGB() Expect([3]int32{r, g, b}).To(Equal([3]int32{200, 200, 200})) r, g, b = testGetCell(8).bgColour.RGB() Expect([3]int32{r, g, b}).To(Equal([3]int32{4, 4, 4})) }) It("should parse offset JSON pixels (using text for foreground)", func() { var r, g, b int32 parseJSONFramePixels(subFrameJSONPixels) r, g, b = testGetCell(4).fgColour.RGB() Expect([3]int32{r, g, b}).To(Equal([3]int32{77, 77, 77})) r, g, b = testGetCell(4).bgColour.RGB() Expect([3]int32{r, g, b}).To(Equal([3]int32{254, 254, 254})) }) Describe("Partially overwriting previous cells", func() { var overSubFrameJSONText = `{ "meta": { "id": 1, "sub_left": 1, "sub_top": 4, "sub_width": 2, "sub_height": 4, "total_width": 3, "total_height": 8 }, "text": ["D", "", "f", ""], "colours": [ 78, 78, 78, 111, 111, 111, 112, 112, 112, 113, 113, 113 ] }` var overSubFrameJSONPixels = `{ "meta": { "id": 1, "sub_left": 1, "sub_top": 4, "sub_width": 2, "sub_height": 4, "total_width": 3, "total_height": 8 }, "colours": [ 154, 154, 154, 211, 211, 211, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 223, 223, 223, 100, 100, 100 ] }` It("should partially overwrite text", func() { parseJSONFrameText(overSubFrameJSONText) // Pre-existing cells Expect(testGetCellChar(4)).To(Equal("A")) Expect(testGetCellChar(5)).To(Equal("b")) // Overwritten cells Expect(testGetCellChar(7)).To(Equal("D")) Expect(testGetCellChar(8)).To(Equal("▄")) Expect(testGetCellChar(10)).To(Equal("f")) Expect(testGetCellChar(11)).To(Equal("▄")) }) It("should overwrite colours in text-less cells", func() { var r, g, b int32 parseJSONFramePixels(subFrameJSONPixels) parseJSONFrameText(overSubFrameJSONText) parseJSONFramePixels(overSubFrameJSONPixels) overwrittenCell := 8 r, g, b = testGetCell(overwrittenCell).fgColour.RGB() Expect([3]int32{r, g, b}).To(Equal([3]int32{12, 12, 12})) r, g, b = testGetCell(overwrittenCell).bgColour.RGB() Expect([3]int32{r, g, b}).To(Equal([3]int32{211, 211, 211})) }) It("should partially overwrite text colours", func() { var r, g, b int32 parseJSONFramePixels(subFrameJSONPixels) parseJSONFrameText(overSubFrameJSONText) parseJSONFramePixels(overSubFrameJSONPixels) preExistingCell := 4 r, g, b = testGetCell(preExistingCell).fgColour.RGB() Expect([3]int32{r, g, b}).To(Equal([3]int32{77, 77, 77})) r, g, b = testGetCell(preExistingCell).bgColour.RGB() Expect([3]int32{r, g, b}).To(Equal([3]int32{254, 254, 254})) overwrittenCell := 7 r, g, b = testGetCell(overwrittenCell).fgColour.RGB() Expect([3]int32{r, g, b}).To(Equal([3]int32{78, 78, 78})) r, g, b = testGetCell(overwrittenCell).bgColour.RGB() Expect([3]int32{r, g, b}).To(Equal([3]int32{154, 154, 154})) }) }) }) }) ================================================ FILE: interfacer/src/browsh/input_box.go ================================================ package browsh import ( "encoding/json" "unicode/utf8" "github.com/gdamore/tcell" ) var activeInputBox *inputBox // A box into which you can enter text. Generally will be forwarded to a standard // HTML input box in the real browser. // // Note that tcell alreay has some ready-made code in its 'views' concept for // dealing with input areas. However, at the time of writing it wasn't well documented, // so it was unclear how easy it would be to integrate the requirements of Browsh's // input boxes - namely overlaying them onto the existing graphics and having them // scroll in sync. type inputBox struct { ID string `json:"id"` X int `json:"x"` Y int `json:"y"` Width int `json:"width"` Height int `json:"height"` TagName string `json:"tag_name"` Type string `json:"type"` FgColour [3]int32 `json:"colour"` bgColour [3]int32 isActive bool multiLiner multiLine text []rune xCursor int yCursor int textCursor int xScroll int yScroll int selectionStart int selectionEnd int } func newInputBox(id string) *inputBox { newInputBox := &inputBox{ ID: id, } // TODO: Circular reference, what's the proper Golang way to do this? newInputBox.multiLiner.inputBox = newInputBox return newInputBox } // This is used only for the URL input box func (i *inputBox) renderURLBox() { bgRGB := tcell.ColorDefault fgRGB := tcell.NewRGBColor(i.FgColour[0], i.FgColour[1], i.FgColour[2]) style := tcell.StyleDefault style = style.Foreground(fgRGB).Background(bgRGB) x := i.X for _, c := range i.textToDisplay() { screen.SetContent(x, i.Y, c, nil, style) x++ } i.renderCursor() screen.Show() } // This is used for all input boxes in the frame func (i *inputBox) setCells() { if i == nil { return } i.resetCells() x := i.X y := i.Y lineCount := 0 for index, c := range i.textToDisplay() { if i.isMultiLine() && lineCount < i.yScroll { if isLineBreak(string(c)) { lineCount++ } continue } if i.Type == "password" && index != len(i.text) { c = '●' } i.addCharacterToFrame(x, y, c) x++ if i.isMultiLine() && isLineBreak(string(c)) { x = i.X y++ lineCount++ if lineCount-i.yScroll > i.Height { break } } } screen.Show() } func (i *inputBox) resetCells() { for y := i.Y; y < i.Height; y++ { for x := i.X; x < i.Width; x++ { i.addCharacterToFrame(x, y, ' ') } } } func (i *inputBox) addCharacterToFrame(x int, y int, c rune) { var ( index int inputBoxCell, existingCell cell cellFGColour, cellBGColour tcell.Color ok bool ) cellFGColour = tcell.NewRGBColor(i.FgColour[0], i.FgColour[1], i.FgColour[2]) index = (y * CurrentTab.frame.totalWidth) + x if existingCell, ok = CurrentTab.frame.cells.load(index); ok { cellBGColour = existingCell.bgColour } else { return } inputBoxCell = cell{ character: []rune{c}, fgColour: cellFGColour, bgColour: cellBGColour, } CurrentTab.frame.cells.store(index, inputBoxCell) } // Different methods are used for containing and displaying overflowed text depending on the // size of the input box. func (i *inputBox) isMultiLine() bool { if urlInputBox.isActive { return false } return i.TagName == "TEXTAREA" || i.Type == "textbox" } func (i *inputBox) textToDisplay() []rune { if i.isMultiLine() { return i.multiLiner.convert() } return i.textToDisplayForSingleLine() } func (i *inputBox) textToDisplayForSingleLine() []rune { var textToDisplay string index := 0 for _, c := range append(i.text, ' ') { if index >= i.xScroll { textToDisplay += string(c) } if utf8.RuneCountInString(textToDisplay) >= i.Width { break } index++ } return []rune(textToDisplay) } func (i *inputBox) lineCount() int { return len(i.multiLiner.finalText) } func isLineBreak(character string) bool { return character == "\n" || character == "\r" } func (i *inputBox) sendInputBoxToBrowser() { inputBoxMap := map[string]interface{}{ "id": i.ID, "text": string(i.text), } marshalled, _ := json.Marshal(inputBoxMap) sendMessageToWebExtension("/tab_command,/input_box," + string(marshalled)) } func (i *inputBox) handleEnterKey(modifier tcell.ModMask) { if urlInputBox.isActive { if isNewEmptyTabActive() { sendMessageToWebExtension("/new_tab," + string(i.text)) } else { sendMessageToWebExtension("/url_bar," + string(i.text)) } urlBarFocus(false) } if i.isMultiLine() && modifier != tcell.ModAlt { i.cursorInsertRune([]rune("\n")[0]) } else { i.isActive = false } if i.isMultiLine() && modifier == tcell.ModAlt { i.text = nil i.isActive = true } i.updateAllCursors() } func (i *inputBox) selectionOff() { i.selectionStart = 0 i.selectionEnd = 0 } func (i *inputBox) selectAll() { urlInputBox.selectionStart = 0 urlInputBox.selectionEnd = len(urlInputBox.text) } func (i *inputBox) removeSelectedText() { if i.selectionEnd-i.selectionStart <= 0 { return } start := i.text[:i.selectionStart] end := i.text[i.selectionEnd:] i.text = append(start, end...) i.textCursor = i.selectionStart i.updateXYCursors() activeInputBox.selectionOff() } func handleInputBoxInput(ev *tcell.EventKey) { switch ev.Key() { case tcell.KeyLeft: activeInputBox.selectionOff() activeInputBox.cursorLeft() case tcell.KeyRight: activeInputBox.selectionOff() activeInputBox.cursorRight() case tcell.KeyDown: activeInputBox.selectionOff() activeInputBox.cursorDown() case tcell.KeyUp: activeInputBox.selectionOff() activeInputBox.cursorUp() case tcell.KeyBackspace, tcell.KeyBackspace2: activeInputBox.removeSelectedText() activeInputBox.cursorBackspace() case tcell.KeyEnter: activeInputBox.removeSelectedText() activeInputBox.handleEnterKey(ev.Modifiers()) case tcell.KeyRune: activeInputBox.removeSelectedText() activeInputBox.cursorInsertRune(ev.Rune()) } if urlInputBox.isActive { renderURLBar() } else { renderCurrentTabWindow() } } ================================================ FILE: interfacer/src/browsh/input_cursor.go ================================================ package browsh func (i *inputBox) renderCursor() { if !i.isActive { return } if i.isSelection() { i.renderSelectionCursor() } else { i.renderSingleCursor() } } func (i *inputBox) isSelection() bool { return i.selectionStart > 0 || i.selectionEnd > 0 } func (i *inputBox) renderSingleCursor() { x, y := i.getCoordsOfCursor() reverseCellColour(x, y) } func (i *inputBox) renderSelectionCursor() { var x, y int textLength := len(i.text) for index := 0; index < textLength; index++ { x, y = i.getCoordsOfIndex(index) if x >= i.selectionStart && x < i.selectionEnd { reverseCellColour(x, y) } } } func (i *inputBox) getCoordsOfCursor() (int, int) { var index int if i.isMultiLine() { index = i.xCursor } else { index = i.textCursor } return i.getCoordsOfIndex(index) } func (i *inputBox) getCoordsOfIndex(index int) (int, int) { xFrameOffset := CurrentTab.frame.xScroll yFrameOffset := CurrentTab.frame.yScroll - uiHeight if urlInputBox.isActive { xFrameOffset = 0 yFrameOffset = 0 } x := (i.X + index) - i.xScroll - xFrameOffset y := (i.Y + i.yCursor) - i.yScroll - yFrameOffset return x, y } func (i *inputBox) cursorLeft() { i.xCursor-- i.textCursor-- i.updateAllCursors() } func (i *inputBox) cursorRight() { i.xCursor++ i.textCursor++ i.updateAllCursors() } func (i *inputBox) cursorUp() { i.multiLiner.moveYCursorBy(-1) i.updateAllCursors() } func (i *inputBox) cursorDown() { i.multiLiner.moveYCursorBy(1) i.updateAllCursors() } func (i *inputBox) cursorBackspace() { if len(i.text) == 0 { return } if i.textCursor == 0 { return } start := i.text[:i.textCursor-1] end := i.text[i.textCursor:] i.text = append(start, end...) i.cursorLeft() i.sendInputBoxToBrowser() } func (i *inputBox) cursorInsertRune(theRune rune) { start := i.text[:i.textCursor] end := i.text[i.textCursor:] endWithRune := append([]rune{theRune}, end...) i.text = append(start, endWithRune...) i.cursorRight() i.sendInputBoxToBrowser() } func (i *inputBox) isCursorOverRightEdge() bool { return i.textCursor-i.xScroll >= i.Width } func (i *inputBox) isCursorOverLeftEdge() bool { return i.textCursor-i.xScroll <= -1 } func (i *inputBox) isCursorOverTopEdge() bool { return i.yCursor-i.yScroll <= -1 } func (i *inputBox) isCursorOverBottomEdge() bool { return i.yCursor-i.yScroll > i.Height } func (i *inputBox) putCursorAtEnd() { i.textCursor = len(urlInputBox.text) // TODO: Do for multiline } func (i *inputBox) updateAllCursors() { i.updateXYCursors() if i.isCursorOverLeftEdge() || !i.isBestFit() { i.xScrollBy(-1) } if i.isCursorOverTopEdge() { i.yScrollBy(-1) } if i.isCursorOverRightEdge() { i.xScrollBy(1) } if i.isCursorOverBottomEdge() { i.yScrollBy(1) } i.limitTextCursor() i.updateXYCursors() } func (i *inputBox) limitTextCursor() { if i.textCursor < 0 { i.textCursor = 0 } if i.textCursor > len(i.text) { i.textCursor = len(i.text) } } func (i *inputBox) updateXYCursors() { if !i.isMultiLine() { return } i.multiLiner.updateCursor() i.renderCursor() } ================================================ FILE: interfacer/src/browsh/input_multiline.go ================================================ package browsh import ( "strings" "unicode" "unicode/utf8" ) type multiLine struct { inputBox *inputBox index int finalText []string previousCharacter string currentCharacter string currentWordish string currentLine string userAddedLines []int } func (m *multiLine) convert() []rune { var aRune rune m.reset() for m.index, aRune = range append(m.inputBox.text, ' ') { m.previousCharacter = m.currentCharacter m.currentCharacter = string(aRune) if m.isWordishReady() { m.addWordish() } if m.isInsideWord() { // TODO: This sometimes causes a panic :/ m.currentWordish += m.currentCharacter } else { m.addWhitespace() } if m.isFinalCharacter() { m.finish() } } finalText := []rune(strings.Join(m.finalText, "\n")) return finalText } func (m *multiLine) reset() { m.finalText = nil m.previousCharacter = "" m.currentCharacter = "" m.currentWordish = "" m.currentLine = "" m.userAddedLines = nil } func (m *multiLine) isInsideWord() bool { return !m.isCurrentCharacterWhitespace() } func (m *multiLine) isPreviousCharacterWhitespace() bool { // TODO: Not certain returning `true` for emptiness is best if m.previousCharacter == "" { return true } runes := []rune(m.previousCharacter) if len(runes) == 0 { return true } return unicode.IsSpace(runes[0]) } func (m *multiLine) isCurrentCharacterWhitespace() bool { if len([]rune(m.currentCharacter)) == 0 { return false } return unicode.IsSpace([]rune(m.currentCharacter)[0]) } func (m *multiLine) isWordishReady() bool { return m.isNaturalWordEnding() || m.isProjectedLineFull() } func (m *multiLine) isNaturalWordEnding() bool { return !m.isPreviousCharacterWhitespace() && m.isCurrentCharacterWhitespace() } func (m *multiLine) isForcedWordEnding() bool { return m.isCurrentWordishFillingLine() && m.isProjectedLineFull() } func (m *multiLine) isCurrentWordishFillingLine() bool { return m.currentWordishLength() == m.inputBox.Width } func (m *multiLine) currentWordishLength() int { return utf8.RuneCountInString(m.currentWordish) } func (m *multiLine) currentLineLength() int { return utf8.RuneCountInString(m.currentLine) } func (m *multiLine) isProjectedLineFull() bool { return m.currentLineLength()+m.currentWordishLength() >= m.inputBox.Width } func (m *multiLine) addWordish() { if m.isProjectedLineFull() { if m.isForcedWordEnding() { m.addLineWithTruncatedWordish() } else { m.addLineButWrapWord() } } else { m.appendWordToLine() } } func (m *multiLine) addLineWithTruncatedWordish() { m.currentLine += m.currentWordish m.currentWordish = "" m.addLine() } func (m *multiLine) addLineButWrapWord() { m.addLine() if m.isNaturalWordEnding() { m.appendWordToLine() } } func (m *multiLine) noteUserAddedLineIndex() { m.userAddedLines = append(m.userAddedLines, m.lineCount()-1) } func (m *multiLine) appendWordToLine() { m.currentLine += m.currentWordish m.currentWordish = "" } func (m *multiLine) addLine() { m.finalText = append(m.finalText, m.currentLine) m.currentLine = "" } func (m *multiLine) addWhitespace() { if m.isNaturalLineBreak() { m.addLine() m.noteUserAddedLineIndex() } else { m.currentLine += string(m.currentCharacter) } } func (m *multiLine) isNaturalLineBreak() bool { return isLineBreak(m.currentCharacter) } func (m *multiLine) isFinalCharacter() bool { return m.index+1 == len(m.inputBox.text)+1 } func (m *multiLine) lineCount() int { return len(m.finalText) } func (m *multiLine) finish() { m.finalText = append(m.finalText, m.currentLine) } func (m *multiLine) updateCursor() { xCursor := 0 yCursor := 0 index := 0 m.convert() for lineIndex, line := range m.finalText { for range line + " " { if index == m.inputBox.textCursor { m.inputBox.xCursor = xCursor m.inputBox.yCursor = yCursor } xCursor++ index++ } if !m.isUserAddedLine(lineIndex) { index-- } xCursor = 0 yCursor++ } } func (m *multiLine) moveYCursorBy(magnitude int) { if !m.inputBox.isMultiLine() { return } m.convert() m.updateCursor() lastLineIndex := m.lineCount() - 1 m.inputBox.yCursor += magnitude if m.inputBox.yCursor < 0 { m.inputBox.yCursor = 0 } if m.inputBox.yCursor > lastLineIndex { m.inputBox.yCursor = lastLineIndex } targetLineLength := utf8.RuneCountInString(m.finalText[m.inputBox.yCursor]) if m.inputBox.xCursor > targetLineLength-1 { m.inputBox.xCursor = targetLineLength if !m.isUserAddedLine(m.inputBox.yCursor) { m.inputBox.xCursor-- } if m.inputBox.xCursor < 0 { m.inputBox.xCursor = 0 } } m.convertXYCursorToTextCursor() } func (m *multiLine) convertXYCursorToTextCursor() { newTextCursor := 0 for i := 0; i < m.inputBox.yCursor; i++ { newTextCursor += utf8.RuneCountInString(m.finalText[i]) if m.isUserAddedLine(i) { newTextCursor++ } } newTextCursor += m.inputBox.xCursor m.inputBox.textCursor = newTextCursor m.updateCursor() } func (m *multiLine) isUserAddedLine(index int) bool { for i := 0; i < len(m.userAddedLines); i++ { if m.userAddedLines[i] == index { return true } } return false } ================================================ FILE: interfacer/src/browsh/input_multiline_test.go ================================================ package browsh import ( "strings" "testing" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) func TestMultiLineTextBuilder(t *testing.T) { RegisterFailHandler(Fail) } var input *inputBox func toMulti(text string, width int) string { input = newInputBox("0") input.text = []rune(text) input.Width = width input.TagName = "TEXTAREA" textRunes := input.multiLiner.convert() raw := string(textRunes) raw = visualiseWhitespace(raw) return raw } func visualiseWhitespace(text string) string { text = strings.Replace(text, " ", "_", -1) text = strings.Replace(text, "\n", "\\n\n", -1) return text } func showWhitespace(textArray []string) string { text := strings.Join(textArray, "\n") return visualiseWhitespace(text) } var _ = Describe("Multiline text", func() { It("should wrap basic text", func() { actual := toMulti("a ab 12 qw 34", 3) expected := showWhitespace([]string{ "a ", "ab ", "12 ", "qw ", "34 ", }) Expect(actual).To(Equal(expected)) }) It("should wrap text with a word longer than the width limit", func() { actual := toMulti("a looooong 12 qw 34", 3) expected := showWhitespace([]string{ "a ", "loo", "ooo", "ng ", "12 ", "qw ", "34 ", }) Expect(actual).To(Equal(expected)) }) It("should wrap text lines with multiple words", func() { actual := toMulti("some words to make a long sentence with many words on each line", 20) expected := showWhitespace([]string{ "some words to make ", "a long sentence ", "with many words on ", "each line ", }) Expect(actual).To(Equal(expected)) }) Describe("Moving the Y cursor", func() { It("should move to a line of greater width", func() { toMulti( `some words !o make `+ `a long sent+nce `+ `with many words on `+ `each line `, 20) input.textCursor = 11 input.multiLiner.moveYCursorBy(1) Expect(input.textCursor).To(Equal(30)) Expect(input.xCursor).To(Equal(11)) Expect(input.yCursor).To(Equal(1)) }) It("should move to a line of smaller width", func() { toMulti( `some words to make `+ `a long sentence `+ `with many w!rds on `+ `each line+`, 20) input.textCursor = 47 input.multiLiner.moveYCursorBy(1) Expect(input.textCursor).To(Equal(64)) Expect(input.xCursor).To(Equal(10)) Expect(input.yCursor).To(Equal(3)) }) Describe("In text that has user-added line breaks", func() { It("should move to a line of smaller width", func() { toMulti( `some words to make `+ "a long \n"+ `sentence with man! `+ `words+`, 20) input.textCursor = 45 input.multiLiner.moveYCursorBy(1) Expect(input.textCursor).To(Equal(52)) Expect(input.xCursor).To(Equal(6)) Expect(input.yCursor).To(Equal(3)) }) }) }) }) ================================================ FILE: interfacer/src/browsh/input_scroll.go ================================================ package browsh func (i *inputBox) xScrollBy(magnitude int) { if !i.isMultiLine() { i.handleSingleLineScroll(magnitude) } i.limitScroll() } func (i *inputBox) yScrollBy(magnitude int) { if i.isMultiLine() { i.yScroll += magnitude } i.limitScroll() } func (i *inputBox) handleSingleLineScroll(magnitude int) { detectionTextWidth := len(i.text) detectionBoxWidth := i.Width if magnitude < 0 { detectionTextWidth++ detectionBoxWidth -= 2 } isOverflowing := detectionTextWidth >= i.Width if isOverflowing { if i.isCursorAtEdgeOfBox(detectionBoxWidth) || !i.isBestFit() { i.xScroll += magnitude } } } func (i *inputBox) isCursorAtEdgeOfBox(detectionBoxWidth int) bool { isCursorAtStartOfBox := i.textCursor-i.xScroll < 0 isCursorAtEndOfBox := i.textCursor-i.xScroll >= detectionBoxWidth return isCursorAtStartOfBox || isCursorAtEndOfBox } func (i *inputBox) isBestFit() bool { lengthOfVisibleText := len(i.text) - i.xScroll return lengthOfVisibleText >= i.Width } // Note that distinct methods are used for single line and multiline overflow, so their // respective limit checks never encroach on each other. func (i *inputBox) limitScroll() { if i.xScroll < 0 { i.xScroll = 0 } if i.xScroll > len(i.text) { i.xScroll = len(i.text) } if i.isMultiLine() { if i.yScroll < 0 { i.yScroll = 0 } if i.yScroll > i.lineCount()-1 { i.yScroll = (i.lineCount() - 1) - i.Height } } } ================================================ FILE: interfacer/src/browsh/raw_text_server.go ================================================ package browsh import ( "crypto/rand" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/url" "regexp" "strings" "sync" "time" "github.com/NYTimes/gziphandler" "github.com/spf13/viper" "github.com/ulule/limiter" "github.com/ulule/limiter/drivers/middleware/stdlib" "github.com/ulule/limiter/drivers/store/memory" ) // In order to communicate between the incoming HTTP request and the websocket request to the // real browser to render the webpage, we keep track of requests in a map. var rawTextRequests = newRequestsMap() type threadSafeRequestsMap struct { sync.RWMutex internal map[string]string } func newRequestsMap() *threadSafeRequestsMap { return &threadSafeRequestsMap{ internal: make(map[string]string), } } func (m *threadSafeRequestsMap) load(key string) (value string, ok bool) { m.RLock() result, ok := m.internal[key] m.RUnlock() return result, ok } func (m *threadSafeRequestsMap) store(key string, value string) { m.Lock() m.internal[key] = value m.Unlock() } func (m *threadSafeRequestsMap) remove(key string) { m.Lock() delete(m.internal, key) m.Unlock() } type rawTextResponse struct { PageloadDuration int `json:"page_load_duration"` ParsingDuration int `json:"parsing_duration"` Text string `json:"body"` } // HTTPServerStart starts the HTTP server is a seperate service from the usual interactive TTY // app. It accepts normal HTTP requests and uses the path portion of the URL as the entry to the // Browsh URL bar. It then returns a simple line-broken text version of whatever the browser // loads. So for example, if you request `curl browsh-http-service.com/http://something.com`, // it will return: // `Something ` func HTTPServerStart() { IsHTTPServerMode = true StartFirefox() go startWebSocketServer() slog.Info("Starting Browsh HTTP server") bind := viper.GetString("http-server.bind") port := viper.GetString("http-server.port") serverMux := http.NewServeMux() uncompressed := http.HandlerFunc(handleHTTPServerRequest) limiterMiddleware := setupRateLimiter() serverMux.Handle("/", limiterMiddleware.Handler(gziphandler.GzipHandler(uncompressed))) if err := http.ListenAndServe(bind+":"+port, &slashFix{serverMux}); err != nil { Shutdown(err) } } func setupRateLimiter() *stdlib.Middleware { rate, err := limiter.NewRateFromFormatted(viper.GetString("http-server.rate-limit")) if err != nil { Shutdown(err) } // TODO: Centralise store amongst instances with Redis store := memory.NewStore() middleware := stdlib.NewMiddleware(limiter.New(store, rate), stdlib.WithForwardHeader(true)) return middleware } func pseudoUUID() (uuid string) { b := make([]byte, 16) _, err := rand.Read(b) if err != nil { fmt.Println("Error: ", err) return } uuid = fmt.Sprintf("%X-%X-%X-%X-%X", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) return uuid } type slashFix struct { mux http.Handler } // The default router from net/http collapses double slashes to a single slash in URL paths. // This is obviously a problem for putting URLs in the path part of a URL, eg; // https://domain.com/http://anotherdomain.com // So here is a little hack that simply escapes the entire path portion to make sure it gets // through the router unchanged. func (h *slashFix) ServeHTTP(w http.ResponseWriter, r *http.Request) { r.URL.Path = "/" + url.PathEscape(strings.TrimPrefix(r.URL.RequestURI(), "/")) h.mux.ServeHTTP(w, r) } func handleHTTPServerRequest(w http.ResponseWriter, r *http.Request) { var message string var isErrored bool start := time.Now().Format(time.RFC3339) urlForBrowsh, _ := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/")) urlForBrowsh, isErrored = deRecurseURL(urlForBrowsh) if isErrored { message = "Invalid URL" io.WriteString(w, message) return } if isProductionHTTP(r) { http.Redirect(w, r, "https://"+r.Host+"/"+urlForBrowsh, 301) return } if urlForBrowsh == "favicon.ico" { http.Redirect(w, r, "https://www.brow.sh/assets/favicon-16x16.png", 301) return } w.Header().Set("Cache-Control", "public, max-age=600") if isDisallowedDomain(urlForBrowsh) { http.Redirect(w, r, "/", 301) return } if isDisallowedUserAgent(r.Header.Get("User-Agent")) { if urlForBrowsh != "" { http.Redirect(w, r, "/", 403) return } } slog.Info("Handling request", "User-Agent", r.Header.Get("User-Agent")) if isKubeReadinessProbe(r.Header.Get("User-Agent")) { io.WriteString(w, "healthy") return } if strings.TrimSpace(urlForBrowsh) == "" { if strings.Contains(r.Host, "text.") { message = "Welcome to the Browsh plain text client.\n" + "You can use it by appending URLs like this;\n" + "https://text.brow.sh/https://www.brow.sh" io.WriteString(w, message) return } urlForBrowsh = "https://www.brow.sh/html-service-welcome" } if urlForBrowsh == "robots.txt" { message = "User-agent: *\nAllow: /$\nDisallow: /\n" io.WriteString(w, message) return } rawTextRequestID := pseudoUUID() rawTextRequests.store(rawTextRequestID+"-start", start) mode := getRawTextMode(r) sendMessageToWebExtension( "/raw_text_request," + rawTextRequestID + "," + mode + "," + urlForBrowsh) waitForResponse(rawTextRequestID, w) } // Prevent https://html.brow.sh/html.brow.sh/... being recursive func deRecurseURL(urlForBrowsh string) (string, bool) { nestedURL, err := url.Parse(urlForBrowsh) if err != nil { return urlForBrowsh, false } if nestedURL.Host != "html.brow.sh" && nestedURL.Host != "text.brow.sh" { return urlForBrowsh, false } return deRecurseURL(strings.TrimPrefix(nestedURL.RequestURI(), "/")) } func isDisallowedDomain(urlForBrowsh string) bool { for _, domainish := range viper.GetStringSlice("http-server.blocked-domains") { r, _ := regexp.Compile(domainish) if r.MatchString(urlForBrowsh) { return true } } return false } func isDisallowedUserAgent(userAgent string) bool { for _, agentish := range viper.GetStringSlice("http-server.blocked-user-agents") { r, _ := regexp.Compile(agentish) if r.MatchString(userAgent) { return true } } return false } func isKubeReadinessProbe(userAgent string) bool { r, _ := regexp.Compile("GoogleHC") if r.MatchString(userAgent) { return true } return false } func isProductionHTTP(r *http.Request) bool { if strings.Contains(r.Host, "brow.sh") { return r.Header.Get("X-Forwarded-Proto") == "http" } return false } // 'PLAIN' mode returns raw text without any HTML whatsoever. // 'HTML' mode returns some basic HTML tags for things like anchor links. // 'DOM' mode returns a simple dump of the DOM. func getRawTextMode(r *http.Request) string { mode := "HTML" if strings.Contains(r.Host, "text.") { mode = "PLAIN" } if r.Header.Get("X-Browsh-Raw-Mode") == "PLAIN" { mode = "PLAIN" } if r.Header.Get("X-Browsh-Raw-Mode") == "DOM" { mode = "DOM" } return mode } func waitForResponse(rawTextRequestID string, w http.ResponseWriter) { var rawTextRequestResponse string var ok bool isSent := false maxTime := time.Duration(viper.GetInt("http-server.timeout")) * time.Second start := time.Now() for time.Since(start) < maxTime { if rawTextRequestResponse, ok = rawTextRequests.load(rawTextRequestID); ok { sendResponse(rawTextRequestResponse, rawTextRequestID, w) isSent = true break } time.Sleep(1 * time.Millisecond) } rawTextRequests.remove(rawTextRequestID) if !isSent { timeout := viper.GetInt("http-server.timeout") message := fmt.Sprintf("Browsh rendering aborted after %ds timeout.", timeout) io.WriteString(w, message) } } func sendResponse(response, rawTextRequestID string, w http.ResponseWriter) { jsonResponse := unpackResponse(response) requestStart, _ := rawTextRequests.load(rawTextRequestID + "-start") totalTime := getTotalTiming(requestStart) pageLoad := fmt.Sprintf("%d", jsonResponse.PageloadDuration) parsing := fmt.Sprintf("%d", jsonResponse.ParsingDuration) w.Header().Set("X-Browsh-Duration-Total", totalTime) w.Header().Set("X-Browsh-Duration-Pageload", pageLoad) w.Header().Set("X-Browsh-Duration-Parsing", parsing) io.WriteString(w, jsonResponse.Text) } func unpackResponse(jsonString string) rawTextResponse { var response rawTextResponse jsonBytes := []byte(jsonString) if err := json.Unmarshal(jsonBytes, &response); err != nil { } return response } func getTotalTiming(startString string) string { start, _ := time.Parse(time.RFC3339, startString) elapsed := time.Since(start) / time.Millisecond return fmt.Sprintf("%d", elapsed) } ================================================ FILE: interfacer/src/browsh/raw_text_server_test.go ================================================ package browsh import ( "testing" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) func TestRawTextServer(t *testing.T) { RegisterFailHandler(Fail) } var _ = Describe("Raw text server", func() { Describe("De-recursing URLs", func() { It("should not do anything to normal URLs", func() { testURL := "https://google.com/path?q=hey" url, _ := deRecurseURL(testURL) Expect(url).To(Equal(testURL)) }) It("should de-recurse a single level", func() { testURL := "https://html.brow.sh/word" url, _ := deRecurseURL(testURL) Expect(url).To(Equal("word")) }) It("should de-recurse a multi level recurse without a URL ending", func() { testURL := "https://html.brow.sh/https://html.brow.sh" url, _ := deRecurseURL(testURL) Expect(url).To(Equal("")) }) It("should de-recurse a multi level recurse with a URL ending", func() { google := "https://google.com/path?q=hey" testURL := "https://html.brow.sh/https://html.brow.sh/" + google url, _ := deRecurseURL(testURL) Expect(url).To(Equal(google)) }) }) }) ================================================ FILE: interfacer/src/browsh/tab.go ================================================ package browsh import ( "encoding/json" "fmt" ) // Tabs is a map of all tab data var Tabs = make(map[int]*tab) // CurrentTab is the currently active tab in the TTY browser var CurrentTab *tab // Slice of the order in which tabs appear in the tab bar var tabsOrder []int // There can be a race condition between the webext sending a tab state update and the // the tab being deleted, so we need to keep track of all deleted IDs var tabsDeleted []int // A single tab synced from the browser type tab struct { ID int `json:"id"` Active bool `json:"active"` Title string `json:"title"` URI string `json:"uri"` PageState string `json:"page_state"` StatusMessage string `json:"status_message"` frame frame } func ResetTabs() { Tabs = make(map[int]*tab) CurrentTab = nil tabsOrder = nil tabsDeleted = nil } func ensureTabExists(id int) { if _, ok := Tabs[id]; !ok { newTab(id) if isNewEmptyTabActive() { removeTab(-1) } } } func isTabPresent(id int) bool { _, ok := Tabs[id] return ok } func newTab(id int) { tabsOrder = append(tabsOrder, id) Tabs[id] = &tab{ ID: id, frame: frame{ xScroll: 0, yScroll: 0, }, } } func removeTab(id int) { if len(Tabs) == 1 { quitBrowsh() } tabsDeleted = append(tabsDeleted, id) sendMessageToWebExtension(fmt.Sprintf("/remove_tab,%d", id)) nextTab() removeTabIDfromTabsOrder(id) delete(Tabs, id) renderUI() renderCurrentTabWindow() } // A bit complicated! Just want to remove an integer from a slice whilst retaining // order :/ func removeTabIDfromTabsOrder(id int) { for i := 0; i < len(tabsOrder); i++ { if tabsOrder[i] == id { tabsOrder = append(tabsOrder[:i], tabsOrder[i+1:]...) } } } // Creating a new tab in the browser without a URI means it won't register with the // web extension, which means that, come the moment when we actually have a URI for the new // tab then we can't talk to it to tell it navigate. So we need to only create a real new // tab when we actually have a URL. func createNewEmptyTab() { if isNewEmptyTabActive() { return } newTab(-1) tab := Tabs[-1] tab.Title = "New Tab" tab.URI = "" tab.Active = true CurrentTab = tab CurrentTab.frame.resetCells() renderUI() urlBarFocus(true) renderCurrentTabWindow() } func isNewEmptyTabActive() bool { return isTabPresent(-1) } func nextTab() { for i := 0; i < len(tabsOrder); i++ { if tabsOrder[i] == CurrentTab.ID { if i+1 == len(tabsOrder) { i = 0 } else { i++ } sendMessageToWebExtension(fmt.Sprintf("/switch_to_tab,%d", tabsOrder[i])) CurrentTab = Tabs[tabsOrder[i]] renderUI() renderCurrentTabWindow() break } } } func isTabPreviouslyDeleted(id int) bool { for i := 0; i < len(tabsDeleted); i++ { if tabsDeleted[i] == id { return true } } return false } func parseJSONTabState(jsonString string) { var incoming tab jsonBytes := []byte(jsonString) if err := json.Unmarshal(jsonBytes, &incoming); err != nil { Shutdown(err) } if isTabPreviouslyDeleted(incoming.ID) { return } ensureTabExists(incoming.ID) if incoming.Active && !isNewEmptyTabActive() { CurrentTab = Tabs[incoming.ID] } Tabs[incoming.ID].handleStateChange(&incoming) } func (t *tab) handleStateChange(incoming *tab) { if t.PageState != incoming.PageState { // TODO: Take the browser's scroll events as lead if incoming.PageState == "page_init" { t.frame.yScroll = 0 } } // TODO: What's the idiomatic Golang way to do this? t.Title = incoming.Title t.URI = incoming.URI t.PageState = incoming.PageState t.StatusMessage = incoming.StatusMessage } ================================================ FILE: interfacer/src/browsh/tty.go ================================================ package browsh import ( "encoding/json" "fmt" "os" "strconv" "github.com/gdamore/tcell" "github.com/go-errors/errors" "github.com/spf13/viper" ) var ( screen tcell.Screen uiHeight = 2 // IsMonochromeMode decides whether to render the TTY in full colour or monochrome IsMonochromeMode = false errNormalExit = errors.New("normal") ) func setupTcell() { var err error if err = screen.Init(); err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) os.Exit(1) } IsMonochromeMode = viper.GetBool("monochrome") screen.EnableMouse() screen.Clear() } func sendTtySize() { width, height := screen.Size() urlInputBox.Width = width sendMessageToWebExtension(fmt.Sprintf("/tty_size,%d,%d", width, height)) } // This is basically a proxy that listens to STDIN and forwards all relevant input // from the user to the webextension. So keyboard, mouse, terminal resizes, etc. func readStdin() { for { ev := screen.PollEvent() switch ev := ev.(type) { case *tcell.EventKey: handleUserKeyPress(ev) case *tcell.EventResize: handleTTYResize() case *tcell.EventMouse: handleMouseEvent(ev) } } } func handleUserKeyPress(ev *tcell.EventKey) { if CurrentTab == nil { if ev.Key() == tcell.KeyCtrlQ { quitBrowsh() } return } switch ev.Key() { case tcell.KeyCtrlQ: quitBrowsh() case tcell.KeyCtrlL: urlBarFocusToggle() case tcell.KeyCtrlT: createNewEmptyTab() case tcell.KeyCtrlU: if !isNewEmptyTabActive() { sendMessageToWebExtension("/new_tab,view-source:" + CurrentTab.URI) } case tcell.KeyCtrlW: removeTab(CurrentTab.ID) case tcell.KeyBackspace, tcell.KeyBackspace2: if activeInputBox == nil { sendMessageToWebExtension("/tab_command,/history_back") } } if ev.Rune() == 'm' && ev.Modifiers() == 4 { toggleMonochromeMode() } if ev.Key() == 279 && ev.Modifiers() == 0 { // F1 key openHelpTab() } if isKey("tty.keys.next-tab", ev) { nextTab() } if !urlInputBox.isActive { forwardKeyPress(ev) } if activeInputBox != nil { handleInputBoxInput(ev) } else { handleScrolling(ev) // TODO: shouldn't you be able to still use mouse scrolling? } } func isKey(userKey string, ev *tcell.EventKey) bool { key := viper.GetStringSlice(userKey) runeMatch := []rune(key[0])[0] == ev.Rune() intKey, _ := strconv.Atoi(key[1]) keyCodeMatch := intKey == int(ev.Key()) modifierKey, _ := strconv.Atoi(key[2]) modifierMatch := modifierKey == int(ev.Modifiers()) return runeMatch && keyCodeMatch && modifierMatch } func quitBrowsh() { if !viper.GetBool("firefox.use-existing") { quitFirefox() } Shutdown(errNormalExit) } func toggleMonochromeMode() { IsMonochromeMode = !IsMonochromeMode } func openHelpTab() { sendMessageToWebExtension("/new_tab,https://www.brow.sh/docs/introduction/") } func forwardKeyPress(ev *tcell.EventKey) { if isMultiLineEnter(ev) { return } eventMap := map[string]interface{}{ "key": int(ev.Key()), "char": string(ev.Rune()), "mod": int(ev.Modifiers()), } marshalled, _ := json.Marshal(eventMap) sendMessageToWebExtension("/stdin," + string(marshalled)) } // Allow user to use ENTER key without triggering submission on multiline input // boxes. func isMultiLineEnter(ev *tcell.EventKey) bool { if activeInputBox == nil { return false } return activeInputBox.isMultiLine() && ev.Key() == 13 && ev.Modifiers() != 4 } func handleScrolling(ev *tcell.EventKey) { yScrollOriginal := CurrentTab.frame.yScroll _, height := screen.Size() height -= uiHeight if ev.Key() == tcell.KeyUp { CurrentTab.frame.yScroll -= 2 } if ev.Key() == tcell.KeyDown { CurrentTab.frame.yScroll += 2 } if ev.Key() == tcell.KeyPgUp { CurrentTab.frame.yScroll -= height } if ev.Key() == tcell.KeyPgDn { CurrentTab.frame.yScroll += height } CurrentTab.frame.limitScroll(height) sendMessageToWebExtension( fmt.Sprintf( "/tab_command,/scroll_status,%d,%d", CurrentTab.frame.xScroll, CurrentTab.frame.yScroll*2)) if CurrentTab.frame.yScroll != yScrollOriginal { renderCurrentTabWindow() } } func handleMouseEvent(ev *tcell.EventMouse) { if CurrentTab == nil { return } x, y := ev.Position() xInFrame := x + CurrentTab.frame.xScroll yInFrame := y - uiHeight + CurrentTab.frame.yScroll button := ev.Buttons() if button == tcell.WheelUp || button == tcell.WheelDown { handleMouseScroll(button) } if button == 1 { CurrentTab.frame.maybeFocusInputBox(xInFrame, yInFrame) } eventMap := map[string]interface{}{ "button": int(button), "mouse_x": int(xInFrame), "mouse_y": int(yInFrame), "modifiers": int(ev.Modifiers()), } marshalled, _ := json.Marshal(eventMap) sendMessageToWebExtension("/stdin," + string(marshalled)) } func handleMouseScroll(scrollType tcell.ButtonMask) { yScrollOriginal := CurrentTab.frame.yScroll _, height := screen.Size() height -= uiHeight if scrollType == tcell.WheelUp { CurrentTab.frame.yScroll -= 1 } else if scrollType == tcell.WheelDown { CurrentTab.frame.yScroll += 1 } CurrentTab.frame.limitScroll(height) sendMessageToWebExtension( fmt.Sprintf( "/tab_command,/scroll_status,%d,%d", CurrentTab.frame.xScroll, CurrentTab.frame.yScroll*2)) if CurrentTab.frame.yScroll != yScrollOriginal { renderCurrentTabWindow() } } func handleTTYResize() { width, _ := screen.Size() urlInputBox.Width = width screen.Sync() sendTtySize() } // Tcell uses a buffer to collect screen updates on, it only actually sends // ANSI rendering commands to the terminal when we tell it to. And even then it // will try to minimise rendering commands by only rendering parts of the terminal // that have changed. func renderCurrentTabWindow() { var currentCell cell styling := tcell.StyleDefault var runeChars []rune width, height := screen.Size() if CurrentTab == nil || CurrentTab.frame.cells == nil { return } CurrentTab.frame.overlayInputBoxContent() for y := 0; y < height-uiHeight; y++ { for x := 0; x < width; x++ { currentCell = getCell(x, y) runeChars = currentCell.character // TODO: do this is in isCharacterTransparent() if len(runeChars) == 0 { continue } if IsMonochromeMode { styling = styling.Foreground(tcell.ColorWhite) styling = styling.Background(tcell.ColorBlack) if runeChars[0] == '▄' { runeChars[0] = ' ' } } else { styling = styling.Foreground(currentCell.fgColour) styling = styling.Background(currentCell.bgColour) } screen.SetCell(x, y+uiHeight, styling, runeChars[0]) } } if activeInputBox != nil { activeInputBox.renderCursor() } overlayPageStatusMessage() overlayCallToSupport() screen.Show() } func getCell(x, y int) cell { var currentCell cell var ok bool frame := &CurrentTab.frame index := ((y + frame.yScroll) * frame.totalWidth) + (x + frame.xScroll) if currentCell, ok = frame.cells.load(index); !ok { fgColour, bgColour := getHatchedCellColours(x) currentCell = cell{ fgColour: fgColour, bgColour: bgColour, character: []rune("▄"), } } return currentCell } func getHatchedCellColours(x int) (tcell.Color, tcell.Color) { var bgColour, fgColour tcell.Color if x%2 == 0 { bgColour = tcell.NewHexColor(0xa9a9a9) fgColour = tcell.NewHexColor(0x797979) } else { bgColour = tcell.NewHexColor(0x797979) fgColour = tcell.NewHexColor(0xa9a9a9) } return fgColour, bgColour } ================================================ FILE: interfacer/src/browsh/ui.go ================================================ package browsh import ( "log/slog" "github.com/gdamore/tcell" "github.com/spf13/viper" ) var urlInputBox = inputBox{ X: 0, Y: 1, Height: 1, text: nil, FgColour: [3]int32{255, 255, 255}, bgColour: [3]int32{-1, -1, -1}, } // Render tabs, URL bar, status messages, etc func renderUI() { renderTabs() renderURLBar() } // Write a simple text string to the screen. // Not for use in the browser frames themselves. If you want anything to appear in // the browser that must be done through the webextension. func writeString(x, y int, str string, style tcell.Style) { xOriginal := x if viper.GetBool("http-server-mode") { slog.Info(str) return } for _, c := range str { if string(c) == "\n" { y++ x = xOriginal continue } screen.SetCell(x, y, style, c) x++ } } func fillLineToEnd(x, y int) { width, _ := screen.Size() for i := x; i < width-1; i++ { writeString(i, y, " ", tcell.StyleDefault) } } func renderTabs() { var tab *tab var style tcell.Style count := 0 xPosition := 0 tabTitleLength := 20 for _, tabID := range tabsOrder { tab = Tabs[tabID] tabTitle := []rune(tab.Title) tabTitleContent := string(tabTitle[0:tabTitleLength]) style = tcell.StyleDefault if CurrentTab.ID == tabID { style = tcell.StyleDefault.Reverse(true) } writeString(xPosition, 0, tabTitleContent, style) style = tcell.StyleDefault.Reverse(false) count++ xPosition = count * (tabTitleLength + 1) writeString(xPosition-1, 0, "|", style) } fillLineToEnd(xPosition, 0) } func renderURLBar() { var content []rune if urlInputBox.isActive { writeString(0, 1, string(content), tcell.StyleDefault) content = append(urlInputBox.text, ' ') urlInputBox.renderURLBox() } else { content = []rune(CurrentTab.URI) writeString(0, 1, string(content), tcell.StyleDefault) } fillLineToEnd(len(content), 1) } func urlBarFocusToggle() { if urlInputBox.isActive { urlBarFocus(false) } else { urlBarFocus(true) } } func urlBarFocus(on bool) { if !on { activeInputBox = nil urlInputBox.isActive = false urlInputBox.selectionOff() } else { activeInputBox = &urlInputBox urlInputBox.isActive = true urlInputBox.xScroll = 0 urlInputBox.text = []rune(CurrentTab.URI) urlInputBox.putCursorAtEnd() urlInputBox.selectAll() } } func overlayPageStatusMessage() { _, height := screen.Size() writeString(0, height-1, CurrentTab.StatusMessage, tcell.StyleDefault) } func overlayCallToSupport() { var right int var message string if viper.GetString("browsh_supporter") == "I have shown my support for Browsh" { return } width, height := screen.Size() message = " Unsupported version" right = width - len(message) writeString(right, height-2, message, tcell.StyleDefault) message = " See brow.sh/donate" right = width - len(message) writeString(right, height-1, message, tcell.StyleDefault) } func reverseCellColour(x, y int) { mainRune, combiningRunes, style, _ := screen.GetContent(x, y) style = style.Reverse(true) screen.SetContent(x, y, mainRune, combiningRunes, style) } ================================================ FILE: interfacer/src/browsh/unit_test.go ================================================ package browsh import ( "testing" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) func TestBrowshUnits(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Unit test") } ================================================ FILE: interfacer/src/browsh/version.go ================================================ package browsh var browshVersion = "1.8.2" ================================================ FILE: interfacer/test/http-server/server_test.go ================================================ package test import ( "io/ioutil" "net/http" "testing" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" "github.com/spf13/viper" ) func TestHTTPServer(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "HTTP Server tests") } var _ = Describe("HTTP Server", func() { It("should return plain text", func() { response := getPath("/smorgasbord", "plain") Expect(response).To(ContainSubstring("smörgåsbord")) Expect(response).ToNot(ContainSubstring("")) }) It("should return the DOM", func() { response := getPath("/smorgasbord", "dom") Expect(response).To(ContainSubstring( "
")) }) It("should return a background image", func() { response := getPath("/smorgasbord", "html") Expect(response).To(ContainSubstring("background-image: url(data:image/jpeg")) }) It("should block specified domains", func() { viper.Set( "http-server.blocked-domains", []string{"[mail|accounts].google.com", "other"}, ) url := getBrowshServiceBase() + "/mail.google.com" client := &http.Client{} request, _ := http.NewRequest("GET", url, nil) response, _ := client.Do(request) contents, _ := ioutil.ReadAll(response.Body) Expect(string(contents)).To(ContainSubstring("Welcome to the Browsh HTML")) }) It("should block specified user agents", func() { viper.Set( "http-server.blocked-user-agents", []string{"MJ12bot", "other"}, ) url := getBrowshServiceBase() + "/example.com" client := &http.Client{} request, _ := http.NewRequest("GET", url, nil) request.Header.Add("User-Agent", "Blah blah MJ12bot etc") response, _ := client.Do(request) Expect(response.StatusCode).To(Equal(403)) }) It("should allow a blocked user agent to see the home page", func() { viper.Set( "http-server.blocked-user-agents", []string{"MJ12bot", "other"}, ) url := getBrowshServiceBase() client := &http.Client{} request, _ := http.NewRequest("GET", url, nil) request.Header.Add("User-Agent", "Blah blah MJ12bot etc") response, _ := client.Do(request) Expect(response.StatusCode).To(Equal(200)) }) }) ================================================ FILE: interfacer/test/http-server/setup.go ================================================ package test import ( "fmt" "io" "log/slog" "net/http" "time" "github.com/browsh-org/browsh/interfacer/src/browsh" ginkgo "github.com/onsi/ginkgo" "github.com/spf13/viper" ) var ( staticFileServerPort = "4444" rootDir = browsh.Shell("git rev-parse --show-toplevel") ) func startStaticFileServer() { serverMux := http.NewServeMux() serverMux.Handle("/", http.FileServer(http.Dir(rootDir+"/interfacer/test/sites"))) http.ListenAndServe(":"+staticFileServerPort, serverMux) } func initBrowsh() { browsh.IsTesting = true browsh.Initialise() viper.Set("http-server-mode", true) } func waitUntilConnectedToWebExtension(maxTime time.Duration) { start := time.Now() for time.Since(start) < maxTime { if browsh.IsConnectedToWebExtension { return } time.Sleep(50 * time.Millisecond) } panic("Didn't connect to webextension in time") } func getBrowshServiceBase() string { return "http://localhost:" + viper.GetString("http-server.port") } func getPath(path string, mode string) string { browshServiceBase := getBrowshServiceBase() staticFileServerBase := "http://localhost:" + staticFileServerPort fullBase := browshServiceBase + "/" + staticFileServerBase client := &http.Client{} request, err := http.NewRequest("GET", fullBase+path, nil) if mode == "plain" { request.Header.Add("X-Browsh-Raw-Mode", "PLAIN") } if mode == "dom" { request.Header.Add("X-Browsh-Raw-Mode", "DOM") } response, err := client.Do(request) if err != nil { panic(fmt.Sprintf("%s", err)) } else { defer response.Body.Close() contents, err := io.ReadAll(response.Body) if err != nil { fmt.Printf("%s", err) panic(fmt.Sprintf("%s", err)) } return string(contents) } } func stopFirefox() { browsh.IsConnectedToWebExtension = false browsh.Shell(rootDir + "/webext/contrib/firefoxheadless.sh kill") time.Sleep(500 * time.Millisecond) } var _ = ginkgo.BeforeEach(func() { browsh.ResetTabs() waitUntilConnectedToWebExtension(15 * time.Second) browsh.IsMonochromeMode = false slog.Info("\n---------") slog.Info(ginkgo.CurrentGinkgoTestDescription().FullTestText) slog.Info("---------") }) var _ = ginkgo.BeforeSuite(func() { initBrowsh() stopFirefox() go startStaticFileServer() go browsh.HTTPServerStart() time.Sleep(1 * time.Second) }) var _ = ginkgo.AfterSuite(func() { stopFirefox() }) ================================================ FILE: interfacer/test/sites/smorgasbord/another.html ================================================ Another Another webpage ================================================ FILE: interfacer/test/sites/smorgasbord/css/main.css ================================================ #content { width: 500px; margin: auto; } h1 { text-align: center; } .left_col { width: 45%; float: left; } .right_col { width: 45%; float: right; } .big_middle { clear: both; } ================================================ FILE: interfacer/test/sites/smorgasbord/css/spinner.css ================================================ /* Animation */ @-webkit-keyframes spinner { to { -webkit-transform: rotate(360deg); } } @-moz-keyframes spinner { to { -moz-transform: rotate(360deg); } } @-ms-keyframes spinner { to { -ms-transform: rotate(360deg); } } @keyframes spinner { to { transform: rotate(360deg); } } /* Loader (*/ #spinner { margin: auto; width: 100px; height: 100px; border-radius: 50%; background-image: linear-gradient(bottom, #FF00FF 50%, #00FFFF 50%); background-image: -o-linear-gradient(bottom, #FF00FF 50%, #00FFFF 50%); background-image: -moz-linear-gradient(bottom, #FF00FF 50%, #00FFFF 50%); background-image: -webkit-linear-gradient(bottom, #FF00FF 50%, #00FFFF 50%); background-image: -ms-linear-gradient(bottom, #FF00FF 50%, #00FFFF 50%); -webkit-animation: spinner 2s infinite linear; -moz-animation: spinner 2s infinite linear; -ms-animation: spinner 2s infinite linear; animation: spinner 2s infinite linear; } ================================================ FILE: interfacer/test/sites/smorgasbord/index.html ================================================ Smörgåsbord

Smörgåsbord

The Swedish word smörgåsbord consists of the words smörgås (sandwich, usually open-faced) and bord (table). Smörgås in turn consists of the words smör (butter, cognate with English smear) and gås. Gås literally means goose, but later referred to the small pieces of butter that formed and floated to the surface of cream while it was churned.
A special Swedish type of smörgåsbord is the julbord (literally "Christmas table"). The classic Swedish julbord is central to traditional Swedish cuisine, often including bread dipped in ham broth and continuing with a variety of fish (salmon, herring, whitefish and eel), baked ham, meatballs, pork ribs, head cheese, sausages, potato, Janssons frestelse, boiled potatoes, cheeses, beetroot salad, various forms of boiled cabbage, kale and rice pudding. It is customary to eat particular foods together; herring is typically eaten with boiled potatoes and hard-boiled eggs and is frequently accompanied by strong spirits like snaps, brännvin or akvavit with or without spices. Other traditional foods are smoked eel, rollmops, herring salad, baked herring and smoked salmon.
================================================ FILE: interfacer/test/sites/smorgasbord/textarea.html ================================================ Another ================================================ FILE: interfacer/test/tty/matchers.go ================================================ package test import ( "fmt" "time" gomegaTypes "github.com/onsi/gomega/types" ) // BeInFrameAt is a custom matcher that looks for the expected text at the given // coordinates. func BeInFrameAt(x, y int) gomegaTypes.GomegaMatcher { return &textInFrameMatcher{ x: x, y: y, found: "", } } type textInFrameMatcher struct { x int y int found string } func (matcher *textInFrameMatcher) Match(actual interface{}) (success bool, err error) { text, _ := actual.(string) start := time.Now() for time.Since(start) < perTestTimeout { matcher.found = GetText(matcher.x, matcher.y, runeCount(text)) if matcher.found == text { return true, nil } time.Sleep(100 * time.Millisecond) } return false, fmt.Errorf("Timeout. Expected\n\t%#v\nto be in the Browsh frame, but found\n\t%#v", text, matcher.found) } func (matcher *textInFrameMatcher) FailureMessage(text interface{}) (message string) { return fmt.Sprintf("Expected\n\t%#v\nto equal\n\t%#v", text, matcher.found) } func (matcher *textInFrameMatcher) NegatedFailureMessage(text interface{}) (message string) { return fmt.Sprintf("Expected\n\t%#v\nnot to equal of\n\t%#v", text, matcher.found) } ================================================ FILE: interfacer/test/tty/setup.go ================================================ package test import ( "fmt" "log/slog" "net/http" "os" "path/filepath" "strconv" "time" "unicode/utf8" "github.com/browsh-org/browsh/interfacer/src/browsh" "github.com/gdamore/tcell" "github.com/gdamore/tcell/terminfo" ginkgo "github.com/onsi/ginkgo" gomega "github.com/onsi/gomega" "github.com/spf13/viper" ) var ( staticFileServerPort = "4444" simScreen tcell.SimulationScreen startupWait = 60 * time.Second perTestTimeout = 2000 * time.Millisecond rootDir = browsh.Shell("git rev-parse --show-toplevel") testSiteURL = "http://localhost:" + staticFileServerPort ti *terminfo.Terminfo framesLogFileName string frameLogger *slog.Logger ) func init() { dir, err := os.Getwd() if err != nil { panic(err) } framesLogFileName = fmt.Sprintf("%s", filepath.Join(dir, "frames.log")) framesLogFile, err := os.OpenFile(framesLogFileName, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644, ) if err != nil { panic(err) } frameLogger = slog.New(slog.NewTextHandler(framesLogFile, nil)) } func initTerm() { // The tests check for true colour RGB values. The only downside to forcing true colour // in tests is that snapshots of frames with true colour ANSI codes are output to logs. // Some people may not have true colour terminals, for example like on Travis, so cat'ing // logs may appear corrupt. ti, _ = terminfo.LookupTerminfo("xterm-truecolor") } // GetFrame returns the current Browsh frame's text func GetFrame() string { var frame, log string line := 0 styleDefault := ti.TParm(ti.SetFgBg, int(tcell.ColorWhite), int(tcell.ColorBlack)) width, _ := simScreen.Size() cells, _, _ := simScreen.GetContents() for _, element := range cells { line++ frame += string(element.Runes) log += elementColourForTTY(element) + string(element.Runes) if line == width { frame += "\n" log += styleDefault + "\n" line = 0 } } frameLogger.Info("================================================") frameLogger.Info(ginkgo.CurrentGinkgoTestDescription().FullTestText) frameLogger.Info("================================================\n") return frame } // Trigger the key definition specified by name func triggerUserKeyFor(name string) { key := viper.GetStringSlice(name) intKey, _ := strconv.Atoi(key[1]) modifierKey, _ := strconv.Atoi(key[2]) simScreen.InjectKey(tcell.Key(intKey), []rune(key[0])[0], tcell.ModMask(modifierKey)) } // SpecialKey injects a special key into the TTY. See Tcell's `keys.go` file for all // the available special keys. func SpecialKey(key tcell.Key) { simScreen.InjectKey(key, 0, tcell.ModNone) time.Sleep(100 * time.Millisecond) } // Keyboard types a string of keys into the TTY, as if a user would func Keyboard(keys string) { for _, char := range keys { simScreen.InjectKey(tcell.KeyRune, char, tcell.ModNone) time.Sleep(10 * time.Millisecond) } } // SpecialMouse injects a special mouse event into the TTY. See Tcell's `mouse.go` file for all // the available special mouse values. func SpecialMouse(mouse tcell.ButtonMask) { simScreen.InjectMouse(0, 0, mouse, tcell.ModNone) time.Sleep(100 * time.Millisecond) } func waitForNextFrame() { // Need to wait so long because the frame rate is currently so slow // TODO: Reduce the wait when the FPS is higher time.Sleep(250 * time.Millisecond) } // WaitForText waits for a particular string at particular position in the frame func WaitForText(text string, x, y int) { var found string start := time.Now() for time.Since(start) < perTestTimeout { found = GetText(x, y, runeCount(text)) if found == text { return } time.Sleep(100 * time.Millisecond) } panic("Waiting for '" + text + "' to appear but it didn't") } // WaitForPageLoad waits for the page to load func WaitForPageLoad() { sleepUntilPageLoad(perTestTimeout) } func sleepUntilPageLoad(maxTime time.Duration) { start := time.Now() time.Sleep(1000 * time.Millisecond) for time.Since(start) < maxTime { if browsh.CurrentTab != nil { if browsh.CurrentTab.PageState == "parsing_complete" { time.Sleep(200 * time.Millisecond) return } } time.Sleep(50 * time.Millisecond) } panic("Page didn't load within timeout") } // GotoURL sends the browsh browser to the specified URL func GotoURL(url string) { SpecialKey(tcell.KeyCtrlL) Keyboard(url) SpecialKey(tcell.KeyEnter) WaitForPageLoad() // TODO: Looking for the URL isn't optimal because it could be the same URL // as the previous test. gomega.Expect(url).To(BeInFrameAt(0, 1)) // TODO: hack to work around bug where text sometimes doesn't render on page load. // Clicking with the mouse triggers a reparse by the web extension mouseClick(3, 6) time.Sleep(100 * time.Millisecond) mouseClick(3, 6) time.Sleep(500 * time.Millisecond) } func mouseClick(x, y int) { simScreen.InjectMouse(x, y, 1, tcell.ModNone) simScreen.InjectMouse(x, y, 0, tcell.ModNone) } func elementColourForTTY(element tcell.SimCell) string { var fg, bg tcell.Color fg, bg, _ = element.Style.Decompose() r1, g1, b1 := fg.RGB() r2, g2, b2 := bg.RGB() return ti.TParm(ti.SetFgBgRGB, int(r1), int(g1), int(b1), int(r2), int(g2), int(b2)) } // GetText retruns an individual piece of a frame func GetText(x, y, length int) string { var text string frame := []rune(GetFrame()) width, _ := simScreen.Size() index := ((width + 1) * y) + x for { text += string(frame[index]) index++ if runeCount(text) == length { break } } return text } // GetFgColour returns the foreground colour of a single cell func GetFgColour(x, y int) [3]int32 { GetFrame() cells, _, _ := simScreen.GetContents() width, _ := simScreen.Size() index := (width * y) + x fg, _, _ := cells[index].Style.Decompose() r1, g1, b1 := fg.RGB() return [3]int32{r1, g1, b1} } // GetBgColour returns the background colour of a single cell func GetBgColour(x, y int) [3]int32 { GetFrame() cells, _, _ := simScreen.GetContents() width, _ := simScreen.Size() index := (width * y) + x _, bg, _ := cells[index].Style.Decompose() r1, g1, b1 := bg.RGB() return [3]int32{r1, g1, b1} } func ensureOnlyOneTab() { if len(browsh.Tabs) > 1 { SpecialKey(tcell.KeyCtrlW) } } func startStaticFileServer() { serverMux := http.NewServeMux() serverMux.Handle("/", http.FileServer(http.Dir(rootDir+"/interfacer/test/sites"))) http.ListenAndServe(":"+staticFileServerPort, serverMux) } func initBrowsh() { browsh.IsTesting = true simScreen = tcell.NewSimulationScreen("UTF-8") browsh.Initialise() } func stopFirefox() { slog.Info("Attempting to kill all firefox processes") browsh.IsConnectedToWebExtension = false browsh.Shell(rootDir + "/webext/contrib/firefoxheadless.sh kill") time.Sleep(500 * time.Millisecond) } func runeCount(text string) int { return utf8.RuneCountInString(text) } var _ = ginkgo.BeforeEach(func() { slog.Info("Attempting to restart WER Firefox...") stopFirefox() browsh.ResetTabs() browsh.StartFirefox() sleepUntilPageLoad(startupWait) browsh.IsMonochromeMode = false slog.Info("\n---------") slog.Info(ginkgo.CurrentGinkgoTestDescription().FullTestText) slog.Info("---------") }) var _ = ginkgo.BeforeSuite(func() { os.Truncate(framesLogFileName, 0) initTerm() initBrowsh() stopFirefox() go startStaticFileServer() go browsh.TTYStart(simScreen) // Firefox seems to take longer to die after its first run time.Sleep(500 * time.Millisecond) stopFirefox() time.Sleep(5000 * time.Millisecond) }) var _ = ginkgo.AfterSuite(func() { stopFirefox() }) ================================================ FILE: interfacer/test/tty/tty_test.go ================================================ package test import ( "testing" "github.com/gdamore/tcell" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) func TestIntegration(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Integration tests") } var _ = Describe("Showing a basic webpage", func() { BeforeEach(func() { GotoURL(testSiteURL + "/smorgasbord/") }) Describe("Browser UI", func() { It("should have the page title and current URL", func() { Expect("Smörgåsbord").To(BeInFrameAt(0, 0)) URL := testSiteURL + "/smorgasbord/" Expect(URL).To(BeInFrameAt(0, 1)) }) Describe("Interaction", func() { It("should navigate to a new page by using the URL bar", func() { SpecialKey(tcell.KeyCtrlL) Keyboard(testSiteURL + "/smorgasbord/another.html") SpecialKey(tcell.KeyEnter) Expect("Another").To(BeInFrameAt(0, 0)) }) It("should navigate to a new page by clicking a link", func() { Expect("Another▄page").To(BeInFrameAt(12, 18)) mouseClick(12, 18) Expect("Another").To(BeInFrameAt(0, 0)) }) It("should scroll the page by one line using the mouse", func() { SpecialMouse(tcell.WheelDown) SpecialMouse(tcell.WheelDown) Expect("meal,▄originating▄in▄").To(BeInFrameAt(12, 11)) }) It("should scroll the page by one line", func() { SpecialKey(tcell.KeyDown) Expect("meal,▄originating▄in▄").To(BeInFrameAt(12, 11)) }) It("should scroll the page by one page", func() { SpecialKey(tcell.KeyPgDn) Expect("continuing▄with▄a▄variety▄of▄fish").To(BeInFrameAt(12, 13)) }) Describe("Text Input", func() { Describe("Single line", func() { BeforeEach(func() { SpecialKey(tcell.KeyDown) SpecialKey(tcell.KeyDown) simScreen.InjectMouse(12, 16, tcell.Button1, tcell.ModNone) }) It("should have basic cursor movement", func() { Keyboard("|||") SpecialKey(tcell.KeyLeft) Keyboard("2") SpecialKey(tcell.KeyLeft) SpecialKey(tcell.KeyLeft) Keyboard("1") Expect("|1|2|").To(BeInFrameAt(12, 16)) }) It("should scroll single line boxes on overflow", func() { Keyboard("12345678901234567890") Expect("5678901234567890 ").To(BeInFrameAt(12, 16)) }) It("should scroll overflowed boxes to the left and right", func() { Keyboard("12345678901234567890") for i := 0; i < 19; i++ { SpecialKey(tcell.KeyLeft) } Expect("23456789012345678").To(BeInFrameAt(12, 16)) for i := 0; i < 19; i++ { SpecialKey(tcell.KeyRight) } Expect("5678901234567890 ").To(BeInFrameAt(12, 16)) }) It("should submit text into an input box", func() { Expect("Unsubmitted").To(BeInFrameAt(12, 19)) Keyboard("Reverse Me!") SpecialKey(tcell.KeyEnter) Skip("'Unsubmitted' remains. Is form submission broken?") Expect("!eM▄esreveR").To(BeInFrameAt(12, 19)) }) }) Describe("Multi line", func() { BeforeEach(func() { GotoURL(testSiteURL + "/smorgasbord/textarea.html") mouseClick(2, 3) }) It("should enter multiple lines of text", func() { Keyboard(`So here is a lot of text that will hopefully split across lines`) Expect("So here is a lot of").To(BeInFrameAt(1, 3)) Expect("text that will").To(BeInFrameAt(1, 4)) Expect("hopefully split across").To(BeInFrameAt(1, 5)) Expect("lines").To(BeInFrameAt(1, 6)) }) It("should scroll multiple lines of text", func() { Skip("Maybe the ENTER key just isn't working?") Keyboard(`So here is a lot of text that will hopefully split across lines`) SpecialKey(tcell.KeyEnter) Keyboard(`And here is even more filler, it's endless!`) Expect("filler, it's endless!").To(BeInFrameAt(1, 6)) for i := 1; i <= 6; i++ { SpecialKey(tcell.KeyUp) } Expect("lines").To(BeInFrameAt(1, 6)) }) }) }) Describe("Tabs", func() { BeforeEach(func() { SpecialKey(tcell.KeyCtrlT) }) AfterEach(func() { ensureOnlyOneTab() }) It("should create a new tab", func() { Expect("New Tab").To(BeInFrameAt(21, 0)) // HACK to prevent URL bar being focussed at the start of the next test. // TODO: Find a more consistent and abstracted way to ensure that the URL // bar is not focussed at the beginning of new tests. SpecialKey(tcell.KeyCtrlL) }) It("should be able to goto a new URL", func() { Keyboard(testSiteURL + "/smorgasbord/another.html") SpecialKey(tcell.KeyEnter) Expect("Another").To(BeInFrameAt(21, 0)) }) It("should cycle to the next tab", func() { Expect(" ").To(BeInFrameAt(0, 1)) SpecialKey(tcell.KeyCtrlL) GotoURL(testSiteURL + "/smorgasbord/another.html") triggerUserKeyFor("tty.keys.next-tab") URL := testSiteURL + "/smorgasbord/ " Expect(URL).To(BeInFrameAt(0, 1)) }) }) }) }) Describe("Rendering", func() { It("should reset page scroll to zero on page load", func() { SpecialKey(tcell.KeyPgDn) Expect("continuing▄with▄a▄variety▄of▄fish").To(BeInFrameAt(12, 13)) GotoURL(testSiteURL + "/smorgasbord/another.html") Expect("Another▄webpage").To(BeInFrameAt(1, 3)) }) It("should render dynamic content", func() { var greens, pinks int var colours [10][3]int32 for i := 0; i < 10; i++ { colours[i] = GetFgColour(39, 3) waitForNextFrame() } for i := 0; i < 10; i++ { if colours[i] == [3]int32{0, 255, 255} { greens++ } if colours[i] == [3]int32{255, 0, 255} { pinks++ } } Expect(greens).To(BeNumerically(">=", 1)) Expect(pinks).To(BeNumerically(">=", 1)) }) It("should switch to monochrome mode", func() { simScreen.InjectKey(tcell.KeyRune, 'm', tcell.ModAlt) waitForNextFrame() Expect([3]int32{0, 0, 0}).To(Equal(GetBgColour(0, 2))) Expect([3]int32{255, 255, 255}).To(Equal(GetFgColour(12, 11))) }) Describe("Text positioning", func() { It("should position the left/right-aligned coloumns", func() { Expect("Smörgåsbord▄(Swedish:").To(BeInFrameAt(12, 10)) Expect("The▄Swedish▄word").To(BeInFrameAt(42, 10)) }) }) }) }) ================================================ FILE: scripts/bundling.bash ================================================ #!/usr/bin/env bash export XPI_PATH="$PROJECT_ROOT"/interfacer/src/browsh/browsh.xpi export XPI_SOURCE_DIR=$PROJECT_ROOT/webext/dist/web-ext-artifacts export NODE_BIN=$PROJECT_ROOT/webext/node_modules/.bin MDN_USER="user:13243312:78" function versioned_xpi_file() { echo "$XPI_SOURCE_DIR/browsh-$(browsh_version).xpi" } # You'll want to use this with `go run ./cmd/browsh --debug --firefox.use-existing` function build_webextension_watch() { "$NODE_BIN"/web-ext run \ --firefox contrib/firefoxheadless.sh \ --verbose } function build_webextension_production() { local version && version=$(browsh_version) cd "$PROJECT_ROOT"/webext && "$NODE_BIN"/webpack cd "$PROJECT_ROOT"/webext/dist && rm ./*.map if [ -f core ]; then # Is this a core dump for some failed process? rm core fi ls -alh . "$NODE_BIN"/web-ext build --overwrite-dest ls -alh web-ext-artifacts webextension_sign local source_file && source_file=$(versioned_xpi_file) echo "Bundling $source_file to $XPI_PATH" cp -f "$source_file" "$XPI_PATH" echo "Making extra copy for Goreleaser to put in Github release:" local goreleaser_pwd="$PROJECT_ROOT"/interfacer/ cp -a "$source_file" "$goreleaser_pwd" ls -alh "$goreleaser_pwd" } # It is possible to use unsigned webextensions in Firefox but it requires that Firefox # uses problematically insecure config. I know it's a hassle having to jump through all # these signing hoops, but I think it's better to use a standard Firefox configuration. # Moving away from the webextension alltogether is another story, but something I'm still # thinking about. # # NB: There can only be one canonical XPI for each semantic version. # # shellcheck disable=2120 function webextension_sign() { local use_existing=$1 if [ "$use_existing" == "" ]; then "$NODE_BIN"/web-ext sign --api-key "$MDN_USER" --api-secret "$MDN_KEY" _rename_built_xpi else echo "Skipping signing, downloading existing webextension" local base="https://github.com/browsh-org/browsh/releases/download" curl -L \ -o "$(versioned_xpi_file)" \ "$base/v$LATEST_TAGGED_VERSION/browsh-$LATEST_TAGGED_VERSION.xpi" fi } function _rename_built_xpi() { pushd "$XPI_SOURCE_DIR" || _panic local xpi_file xpi_file="$( find ./*.xpi \ -printf "%T@ %f\n" | sort | cut -d' ' -f2 | tail -n1 )" cp -a "$xpi_file" "$(versioned_xpi_file)" popd || _panic } function bundle_production_webextension() { local version && version=$(browsh_version) local base='https://github.com/browsh-org/browsh/releases/download' local release_url="$base/v$version/browsh-$version.xpi" echo "Downloading webextension from: $release_url" curl -L -o "$XPI_PATH" "$release_url" local size && size=$(wc -c <"$XPI_PATH") if [ "$size" -lt 500 ]; then echo "XPI size seems too small: $size" _panic "Problem downloading latest webextension XPI" fi cp -a "$XPI_PATH" "$(versioned_xpi_file)" } ================================================ FILE: scripts/common.bash ================================================ #!/usr/bin/env bash # shellcheck disable=2120 function _panic() { local message=$1 echo >&2 "$message" exit 1 } function _md5() { local path=$1 md5sum "$path" | cut -d' ' -f1 } function pushd() { # shellcheck disable=2119 command pushd "$@" >/dev/null || _panic } function popd() { # shellcheck disable=2119 command popd "$@" >/dev/null || _panic } ================================================ FILE: scripts/docker.bash ================================================ #!/usr/bin/env bash function docker_image_name() { _export_versions echo browsh/browsh:v"$BROWSH_VERSION" } function docker_build() { local og_xpi && og_xpi=$(versioned_xpi_file) [ ! -f "$og_xpi" ] && _panic "Can't find latest webextension build: $og_xpi" [ ! -f "$XPI_PATH" ] && _panic "Can't find bundleable browsh.xpi: $XPI_PATH" if [ "$(_md5 "$og_xpi")" != "$(_md5 "$XPI_PATH")" ]; then _panic "XPI file's MD5 does not match original XPI file's MD5" fi docker build -t "$(docker_image_name)" . } function is_docker_logged_in() { docker system info | grep -E 'Username|Registry' } function docker_login() { docker login docker.io \ -u tombh \ -p "$DOCKER_ACCESS_TOKEN" } function docker_tag_latest() { local latest=browsh/browsh:latest docker tag "$(docker_image_name)" "$latest" docker push "$latest" } function docker_release() { ! is_docker_logged_in && try_docker_login docker_build docker push "$(docker_image_name)" docker_tag_latest } ================================================ FILE: scripts/misc.bash ================================================ #!/usr/bin/env bash function golang_lint_check() { pushd "$PROJECT_ROOT"/interfacer || _panic diff -u <(echo -n) <(gofmt -d ./) popd || _panic } function golang_lint_fix() { gofmt -w ./interfacer } function prettier_fix() { pushd "$PROJECT_ROOT"/webext || _panic prettier --write '{src,test}/**/*.js' popd || _panic } function parse_firefox_version_from_ci_config() { local line && line=$(grep 'firefox-version:' <"$PROJECT_ROOT"/.github/workflows/main.yml) local version && version=$(echo "$line" | tr -s ' ' | cut -d ' ' -f 3) [ "$version" = "" ] && _panic "Couldn't parse Firefox version" echo -n "$version" } function install_firefox() { local version && version=$(parse_firefox_version_from_ci_config) local destination=/tmp echo "Installing Firefox v$version to $destination..." mkdir -p "$destination" pushd "$destination" || _panic curl -L -o firefox.tar.bz2 \ "https://ftp.mozilla.org/pub/firefox/releases/$version/linux-x86_64/en-US/firefox-$version.tar.bz2" bzip2 -d firefox.tar.bz2 tar xf firefox.tar popd || _panic } function parse_golang_version_from_go_mod() { local path=$1 [ "$path" = "" ] && _panic "Path to Golang interfacer code not passed" local line && line=$(grep '^go ' <"$path"/go.mod) local version && version=$(echo "$line" | tr -s ' ' | cut -d ' ' -f 2) [ "$(echo "$version" | tr -s ' ')" == "" ] && _panic "Couldn't parse Golang version" echo -n "$version" } function install_golang() { local path=$1 [ "$path" = "" ] && _panic "Path to Golang interfacer code not passed" local version && version=$(parse_golang_version_from_go_mod "$path") [ "$GOPATH" = "" ] && _panic "GOPATH not set" [ "$GOROOT" = "" ] && _panic "GOROOT not set" GOARCH=$(uname -m) [[ $GOARCH == aarch64 ]] && GOARCH=arm64 [[ $GOARCH == x86_64 ]] && GOARCH=amd64 url=https://dl.google.com/go/go"$version".linux-"$GOARCH".tar.gz echo "Installing Golang ($url)... to $GOROOT" curl -L \ -o go.tar.gz \ "$url" mkdir -p "$GOPATH"/bin mkdir -p "$GOROOT" tar -C "$GOROOT/.." -xzf go.tar.gz go version } ================================================ FILE: scripts/releasing.bash ================================================ #!/usr/bin/env bash export BROWSH_VERSION export LATEST_TAGGED_VERSION function _goreleaser_production() { if ! command -v goreleaser &>/dev/null; then echo "Installing \`goreleaser'..." go install github.com/goreleaser/goreleaser@v"$GORELEASER_VERSION" fi pushd "$PROJECT_ROOT"/interfacer || _panic _export_versions [ "$BROWSH_VERSION" = "" ] && _panic "BROWSH_VERSION unset (goreleaser needs it)" goreleaser release \ --config "$PROJECT_ROOT"/goreleaser.yml \ --rm-dist popd || _panic } function _export_versions() { BROWSH_VERSION=$(_parse_browsh_version) LATEST_TAGGED_VERSION=$( git tag --sort=v:refname --list 'v*.*.*' | tail -n1 | sed -e "s/^v//" ) } function _parse_browsh_version() { local version_file=$PROJECT_ROOT/interfacer/src/browsh/version.go local line && line=$(grep 'browshVersion' <"$version_file") local version && version=$(echo "$line" | grep -o '".*"' | sed 's/"//g') echo -n "$version" } function _is_new_version() { _export_versions [ "$BROWSH_VERSION" = "" ] && _panic "BROWSH_VERSION unset" [ "$LATEST_TAGGED_VERSION" = "" ] && _panic "LATEST_TAGGED_VERSION unset" [[ "$BROWSH_VERSION" != "$LATEST_TAGGED_VERSION" ]] } function _tag_on_version_change() { _export_versions echo_versions if ! _is_new_version; then echo "Not tagging as there's no new version." exit 0 fi git tag v"$BROWSH_VERSION" git show v"$BROWSH_VERSION" --quiet git config --global user.email "ci@github.com" git config --global user.name "Github Actions" git add --all git reset --hard v"$BROWSH_VERSION" } function echo_versions() { _export_versions echo "Browsh binary version: $BROWSH_VERSION" echo "Git latest tag: $LATEST_TAGGED_VERSION" } function browsh_version() { _export_versions echo -n "$BROWSH_VERSION" } function github_actions_output_version_status() { local status="false" if _is_new_version; then status="true" fi echo "::set-output name=is_new_version::$status" } function webext_build_release() { pushd "$PROJECT_ROOT"/webext || _panic build_webextension_production popd || _panic } function update_browsh_website_with_new_version() { _export_versions local remote="git@github.com:browsh-org/www.brow.sh.git" pushd /tmp || _panic git clone "$remote" cd www.brow.sh || _panic echo "latest_version: $BROWSH_VERSION" >_data/browsh.yml git add _data/browsh.yml git commit -m "Github Actions: updated Browsh version to $BROWSH_VERSION" git push "$remote" popd || _panic } function update_homebrew_tap_with_new_version() { _export_versions local remote="git@github.com:browsh-org/homebrew-browsh.git" pushd /tmp || _panic git clone "$remote" cd homebrew-browsh || _panic cp -f "$PROJECT_ROOT"/interfacer/dist/browsh.rb browsh.rb git add browsh.rb git commit -m "Github Actions: updated to $BROWSH_VERSION" git push "$remote" popd || _panic } function goreleaser_local_only() { pushd "$PROJECT_ROOT"/interfacer || _panic goreleaser release \ --config "$PROJECT_ROOT"/goreleaser.yml \ --snapshot \ --rm-dist popd || _panic } function build_browsh_binary() { # Requires $path argument because it's used in the Dockerfile where the GOROOT is # outside .git/ local path=$1 pushd "$path" || _panic local webextension="src/browsh/browsh.xpi" [ ! -f "$webextension" ] && _panic "browsh.xpi not present" md5sum "$webextension" go build ./cmd/browsh echo "Freshly built \`browsh' version: $(./browsh --version 2>&1)" popd || _panic } function release() { [ "$(git rev-parse --abbrev-ref HEAD)" != "master" ] && _panic "Not releasing unless on the master branch" webext_build_release build_browsh_binary "$PROJECT_ROOT"/interfacer _tag_on_version_change _goreleaser_production } ================================================ FILE: scripts/tests.bash ================================================ # For the webextension: in `webext/` folder, `npm test` # For CLI unit tests: in `/interfacer` run `go test src/browsh/*.go` # For CLI E2E tests: in `/interfacer` run `go test test/tty/*.go` # For HTTP Service tests: in `/interfacer` run `go test test/http-server/*.go` function test_all { test_webextension interfacer_test_setup test_interfacer_units test_http_server test_tty } function test_webextension { pushd $PROJECT_ROOT/webext npm test } function interfacer_test_setup { pushd $PROJECT_ROOT/webext touch "$PROJECT_ROOT/interfacer/src/browsh/browsh.xpi" npm run build:dev } function test_interfacer_units { pushd $PROJECT_ROOT/interfacer go test -v $(find src/browsh -name '*.go' | grep -v windows) } function test_tty { pushd $PROJECT_ROOT/interfacer go test test/tty/*.go -v -ginkgo.slowSpecThreshold=30 -ginkgo.flakeAttempts=3 } function test_http_server { pushd $PROJECT_ROOT/interfacer go test test/http-server/*.go -v -ginkgo.slowSpecThreshold=30 -ginkgo.flakeAttempts=3 } ================================================ FILE: webext/.eslintrc ================================================ { "env" : { "es6": true, "node" : true, "browser" : true, "webextensions": true, "mocha": true }, "globals": { "DEVELOPMENT": true, "PRODUCTION": true, "TEST": true }, "parser": "babel-eslint", "parserOptions": { "ecmaVersion": 6, "sourceType": "module" }, "extends": "eslint:recommended", "rules": { "no-unused-vars": [2, {"args": "all", "argsIgnorePattern": "^_"}] } } ================================================ FILE: webext/.mocharc.cjs ================================================ 'use strict'; module.exports = { require: 'babel-register', recursive: true, timeout: '60000' }; ================================================ FILE: webext/.web-extension-id ================================================ # This file was created by https://github.com/mozilla/web-ext # Your auto-generated extension ID for addons.mozilla.org is: {8ff2d753-2dc8-46de-a837-fa28331d9fcf} ================================================ FILE: webext/assets/browsh-schema.json ================================================ { "$id": "https://json.schemastore.org/browsh-schema.json", "$schema": "http://json-schema.org/draft-07/schema#", "$comment": "https://www.brow.sh/docs/config/", "properties": { "browsh_supporter": { "default": "♥", "enum": ["I have shown my support for Browsh", "♥"], "description": "By showing your support you can disable the app's branding and nags to donate", "type": "string" }, "startup-url": { "description": "The page to show at startup. Browsh will fail to boot if this URL is not accessible", "type": "string" }, "default_search_engine_base": { "default": "https://www.google.com/search?q=", "description": "The base query when a non-URL is entered into the URL bar", "type": "string" }, "mobile_user_agent": { "default": "Mozilla/5.0 (Android 7.0; Mobile; rv:54.0) Gecko/58.0 Firefox/58.0", "description": "The mobile user agent for forcing web pages to use their mobile layout", "type": "string" }, "browsh": { "description": "Browsh internals", "properties": { "websocket-port": { "default": 3334, "type": "integer" }, "use_experimental_text_visibility": { "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", "default": false, "type": "boolean" }, "custom_css": { "description": "Custom CSS to apply to all loaded tabs", "type": "string" } }, "type": "object" }, "firefox": { "properties": { "path": { "default": "firefox", "description": "The path to your Firefox binary", "type": "string" }, "profile": { "default": "browsh-default", "description": "Browsh has its own profile, separate from the normal user's. But you can change that", "type": "string" }, "use-existing": { "default": false, "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", "type": "boolean" }, "with-gui": { "default": "with-gui", "description": "Launch Firefox in with its visible GUI window. Useful for setting up the Browsh profile.", "type": "string" }, "preferences": { "items": { "type": "string" }, "description": "Config that you might usually set through Firefox's 'about:config' page Note that string must be wrapped in quotes", "type": "array" } }, "tty": { "properties": { "small_pixel_frame_rate": { "default": "250", "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", "type": "integer" } }, "type": "object" }, "http-server": { "properties": { "port": { "default": 4333, "type": "integer" }, "bind": { "default": "0.0.0.0", "type": "string" }, "render_delay": { "default": 100, "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", "type": "integer" }, "timeout": { "default": 30, "description": "The length of time in seconds to wait before aborting the page load", "type": "integer" }, "columns": { "default": 100, "description": "The dimensions of a char-based window onto a webpage. The columns are ultimately the width of the final text", "type": "string" }, "rows": { "default": 30, "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", "type": "string" }, "jpeg_compression": { "default": 0.9, "description": "The amount of lossy JPG compression to apply to the background image of HTML pages", "type": "string" }, "rate-limit": { "default": "100000000-M", "description": "Rate limit. For syntax, see: https://github.com/ulule/limiter", "type": "string" }, "blocked-domains": { "items": { "type": "string" }, "description": "Blocking is useful if the HTTP server is made public. All values are evaluated as regular expressions", "type": "array" }, "blocked-user-agents": { "items": { "type": "string" }, "description": "Blocking is useful if the HTTP server is made public. All values are evaluated as regular expressions", "type": "array" }, "header": { "description": "HTML snippets to show at top and bottom of final page", "type": "string" }, "footer": { "description": "HTML snippets to show at top and bottom of final page", "type": "string" } } }, "type": "object" } }, "title": "JSON schema for browsh", "type": "object" } ================================================ FILE: webext/assets/styles.css ================================================ @font-face { /* A special font that only has unicode full blocks in it, so we can detect */ /* font colors and text visibility more easily. */ font-family: 'BlockCharMono'; src: url('/assets/BlockCharMono.ttf') format('truetype'); } @font-face { font-family: 'BlankMono'; src: url('/assets/BlankMono.ttf') format('truetype'); } /* Force text into a reliable grid */ html * { font-size: 15px !important; line-height: 20px !important; letter-spacing: 0px !important; font-style: normal !important; font-weight: normal !important; font-family: 'BlockCharMono' !important; } a { text-decoration: none !important; } .browsh-hide-text, .browsh-hide-text *{ font-family: 'BlankMono' !important; } .browsh-show-text, .browsh-show-text * { font-family: 'BlockCharMono' !important; } sup, sub { vertical-align: baseline !important; } /* Prevents duplicated text caused by the rendering of the DOM's input box content * and the CLI app's input box content */ input, textarea { color: transparent !important; } /** * Site-specific fixes * * TODO: This is going to need to be much more formally organised */ /* Stackoverflow cookie banner */ #js-gdpr-consent-banner { display: none; } ================================================ FILE: webext/background.js ================================================ import BackgroundManager from 'background/manager' new BackgroundManager(); ================================================ FILE: webext/content.js ================================================ import DOMManager from 'dom/manager'; new DOMManager(); ================================================ FILE: webext/contrib/download_xpi.js ================================================ // `npm install -g jsonwebtoken` var jwt = require('jsonwebtoken'); var key = 'user:13243312:78'; var secret = process.env.MDN_KEY; var issuedAt = Math.floor(Date.now() / 1000); var payload = { iss: key, jti: Math.random().toString(), iat: issuedAt, exp: issuedAt + 60, }; var token = jwt.sign(payload, secret, { algorithm: 'HS256', // HMAC-SHA256 signing algorithm }); var auth = 'JWT ' + token; var path = '848208/browsh-0.2.3-an+fx.xpi'; var base = 'https://addons.mozilla.org/api/v3/file/'; var uri = base + path; process.stdout.write('curl -H "Authorization: ' + auth + '" ' + uri); ================================================ FILE: webext/contrib/firefoxheadless.sh ================================================ #!/usr/bin/env bash if [[ "$1" = "kill" ]]; then pkill --full 'firefox.*headless.*profile' sleep 1 if [[ "$CI" == "true" ]]; then pkill -9 firefox || true fi else FIREFOX_BIN=${FIREFOX:-firefox} "$FIREFOX_BIN" --headless --marionette "$@" fi ================================================ FILE: webext/contrib/font_maker.py ================================================ # TODO: # Look into using: https://github.com/adobe-fonts/adobe-blank # It should both reduce the size of the font and support all possible UTF8 chars import fontforge def generate(name, block): print("Generating " + name) # TODO: # This needs to reach 0x9FCF to complete the CJK Ideographs # But above around 0x7f00, we get this error: # `Internal Error: Attempt to output 81854 into a 16-bit field. It will be # truncated and the file may not be useful.` for i in range(0x0000, 0x7F00): if i == codepoint: continue glyph = blocks.createChar(i) glyph.width = 600 glyph.addReference(block) print(blocks[codepoint].foreground) blocks.fontname = name blocks.fullname = name blocks.familyname = name # Fontforge's WOFF output doesn't seem to work. No matter, this isn't for an actual # remote production website. The font is served locally from the extension and doesn't # even need to look good. blocks.generate(name + '.ttf') # A font with just the █ (0x2588) for all unicode characters blocks = fontforge.font() blocks.encoding = 'UnicodeFull' codepoint = 0x2588 glyph = blocks.createChar(codepoint) glyph.width = 600 pen = blocks[codepoint].glyphPen() pen.moveTo((0, -200)) pen.lineTo((0, 800)) pen.lineTo((600, 800)) pen.lineTo((600, -200)) pen.closePath() generate('BlockCharMono', blocks[codepoint].glyphname) # A font with just the space character, used to hide all text blocks = fontforge.font() blocks.encoding = 'UnicodeFull' codepoint = 0x2003 glyph = blocks.createChar(codepoint) glyph.width = 600 pen = blocks[codepoint].glyphPen() pen.moveTo((0, 0)) pen.lineTo((0, 0)) pen.closePath() generate('BlankMono', blocks[codepoint].glyphname) ================================================ FILE: webext/manifest.json ================================================ { "manifest_version": 2, "name": "Browsh", "version": "BROWSH_VERSION", "description": "Renders the browser as realtime, interactive, TTY-compatible text", "icons": { "48": "assets/icons/browsh-48.png", "96": "assets/icons/browsh-96.png" }, "background": { "scripts": ["background.js"] }, "content_scripts": [ { "matches": ["*://*/*"], "js": ["content.js"], "css": ["assets/styles.css"], "run_at": "document_start" } ], "web_accessible_resources": [ "assets/BlockCharMono.ttf", "assets/BlankMono.ttf" ], "permissions": [ "", "webRequest", "webRequestBlocking", "tabs" ] } ================================================ FILE: webext/package.json ================================================ { "type": "module", "scripts": { "build:dev": "webpack", "build:watch": "webpack --watch", "lint": "prettier --list-different '{src,test}/**/*.js'", "test": "NODE_PATH=src:test mocha" }, "babel": { "presets": [ "es2015" ] }, "devDependencies": { "babel-eslint": "^8.2.6", "babel-loader": "^8.2.5", "babel-preset-es2015": "^6.24.1", "babel-register": "^6.26.0", "chai": "^4.3.6", "copy-webpack-plugin": "^11.0.0", "eslint": "^8.20.0", "mocha": "^10.0.0", "prettier": "2.7.1", "sinon": "^14.0.0", "strip-ansi": "^7.0.1", "web-ext": "^7.5.0", "webpack": "^5.73.0", "webpack-cli": "^4.10.0" }, "dependencies": { "lodash": "^4.17.21", "string-width": "^5.1.2" } } ================================================ FILE: webext/src/background/common_mixin.js ================================================ import stripAnsi from "strip-ansi"; // Here we keep the public functions used to mediate communications between // the background process, tabs and the terminal. export default (MixinBase) => class extends MixinBase { sendToCurrentTab(message) { if (this.currentTab().channel === undefined) { this.log(`Attempt to send "${message}" to tab without a channel`); } else { this.currentTab().channel.postMessage(message); } } sendToTerminal(message) { if (this.terminal === undefined) { return; } if (this.terminal.readyState === 1) { this.terminal.send(message); } } log(...messages) { if (messages === undefined) { messages = "undefined"; } if (messages.length === 1) { messages = messages[0].toString(); messages = stripAnsi(messages); messages = messages.replace(/\u001b\[/g, "ESC"); } this.sendToTerminal(messages); } currentTab() { return this.tabs[this.active_tab_id]; } }; ================================================ FILE: webext/src/background/dimensions.js ================================================ import _ from "lodash"; import utils from "utils"; import CommonMixin from "background/common_mixin"; export default class extends utils.mixins(CommonMixin) { constructor() { super(); this.tty = {}; this.char = {}; // I *think* this extra height is needed because the browser window height is not the same // as the actual viewport height. But then if that is the case, then we'll also have a // similar issue with the scroll bars. // TODO: Also if this hypothesis is correct, it needs to be applied as an original browser- // relative pixel unit, not as a TTY unit. If you look on Google Maps I think you can // actually see a little bit of white at the bottom perhaps from where the screen capture // goes over the bottom of the viewport. this._window_ui_magic_number = 3; } postConfigSetup(config) { this.config = config; this._setRawTextTTYSize(); } setCharValues(incoming) { if ( this.char.width != incoming.width || this.char.height != incoming.height ) { this.log( `Requesting browser resize for new char dimensions: ` + `${incoming.width}x${incoming.height} (old: ${this.char.width}x${this.char.height})` ); this.char = _.clone(incoming); this.resizeBrowserWindow(); } } // The Browsh HTTP Server service doesn't load a TTY, so we need to supply the size. // Strictly it shouldn't even be needed if the code was completely refactored. Although // it should be worth taking into consideration how the size of the TTY and therefore the // resized browser window affects the rendering of a web page, for instance images outside // of the viewport can sometimes not be loaded. So is it practical to set the TTY size to // the size of the entire DOM? _setRawTextTTYSize() { this.raw_text_tty_size = { width: this.config["http-server"].columns, height: this.config["http-server"].rows, }; } resizeBrowserWindow() { if ( !this.tty.width || !this.char.width || !this.tty.height || !this.char.height ) { this.log( "Not resizing browser window without all of the TTY and character dimensions" ); return; } // Does this include scrollbars??? const window_width = parseInt(Math.round(this.tty.width * this.char.width)); // Leave room for tabs and URL bar const tty_dom_height = this.tty.height - 2; const window_height = parseInt( Math.round( (tty_dom_height + this._window_ui_magic_number) * this.char.height ) ); const current_window = browser.windows.getCurrent(); current_window.then( (active_window) => { this._sendWindowResizeRequest( active_window, window_width, window_height ); }, (error) => { this.log("Error getting current browser window", error); } ); } _sendWindowResizeRequest(active_window, width, height) { const tag = "Resizing browser window"; const updating = browser.windows.update(active_window.id, { width: width, height: height, focused: false, }); updating.then( (info) => this.log(`${tag} successful (${info.width}x${info.height})`), (error) => this.log(tag + " error: ", error) ); } } ================================================ FILE: webext/src/background/manager.js ================================================ import _ from "lodash"; import utils from "utils"; import CommonMixin from "background/common_mixin"; import TTYCommandsMixin from "background/tty_commands_mixin"; import Tab from "background/tab"; import Dimensions from "background/dimensions"; // Boots the background process. Mainly involves connecting to the websocket server // launched by the Browsh CLI client and setting up listeners for new tabs that // have our webextension content script inside them. export default class extends utils.mixins(CommonMixin, TTYCommandsMixin) { constructor() { super(); this.dimensions = new Dimensions(); // All of the tabs open in the real browser this.tabs = {}; // The ID of the tab currently opened tab this.active_tab_id = null; // When the real GUI browser first launches it's sized to the same size as the desktop this._is_initial_window_size_pending = true; // Used so that reconnections to the terminal don't also attempt to reconnect to the // browser DOM. this._is_connected_to_browser_dom = false; // Raw text mode is for when Browsh is running as an HTTP server that serves single // pages as entire DOMs, in plain text. this._is_raw_text_mode = false; // Toggle user agent this._is_using_mobile_user_agent = false; this._addUserAgentListener(); // Listen to HTTP requests. This allows us to display some helpful status messages at the // bottom of the page, eg; "Loading https://coolwebsite.com..." this._addWebRequestListener(); // The manager is the hub between tabs and the terminal. First we connect to the // terminal, as that is the process that would have initially booted the browser and // this very code that now runs. this._connectToTerminal(); } _connectToTerminal() { // This is the websocket server run by the CLI client this.terminal = new WebSocket("ws://localhost:3334"); this.terminal.addEventListener("open", (_event) => { this.log("Webextension connected to the terminal's websocket server"); this.dimensions.terminal = this.terminal; this._listenForTerminalMessages(); this._connectToBrowserDOM(); }); this.terminal.addEventListener("close", (_event) => { this._reconnectToTerminal(); }); } // If we've disconnected from the terminal, but we're still running, then that likely // means the terminal crashed, so we wait to see if the user restarts the terminal. _reconnectToTerminal() { try { this._connectToTerminal(); } catch (_e) { _.debounce(() => this._reconnectToTerminal(), 50); } } // Mostly listening for forwarded STDIN from the terminal. Therefore, the user // pressing the arrow keys, typing, moving the mouse, etc, etc. But we also listen // to TTY resize events too. _listenForTerminalMessages() { this.log("Starting to listen to TTY"); this.terminal.addEventListener("message", (event) => { this.log("Message from terminal: " + event.data); this.handleTerminalMessage(event.data); }); } _connectToBrowserDOM() { if (!this._is_connected_to_browser_dom) { this._initialDOMConnection(); } else { this._reconnectToDOM(); } } _initialDOMConnection() { this._listenForNewTab(); this._listenForTabUpdates(); this._listenForTabChannelOpen(); this._listenForFocussedTab(); } _reconnectToDOM() { this.log("Attempting to resend browser state to terminal..."); this.currentTab().sendStateToTerminal(); if (!this._is_raw_text_mode) { this.sendToCurrentTab("/rebuild_text"); } } // For when a tab's content script, triggered by `onDOMContentLoaded`, // phone's home. // Curiously `browser.runtime.onMessage` receives the tab's ID, whereas // `browser.runtime.onConnect` doesn't. So we have to have 2 tab listeners: // 1. to get the tab ID so we can talk to it later with 2. // 2. to maintain a long-lived connection to continuously pass messages // back and forth. _listenForNewTab() { browser.runtime.onMessage.addListener(this._newTabHandler.bind(this)); } // There's what seems to be a bug: tabs can exist and be processed without // triggering any `browser.tabs.onUpdated` events. Therefore we need to // manually poll :/ // TODO: Detect deleted tabs to remove the key from `this.tabs[]` _listenForTabUpdates() { setInterval(() => { this._pollAllTabs((native_tab_object) => { let tab = this._applyUpdates(native_tab_object); tab.ensureConnectionToBackground(); }); }, 100); } _maybeNewTab(tabish_object) { const tab_id = parseInt(tabish_object.id); if (this.tabs[tab_id] === undefined) { let new_tab = new Tab(tabish_object); this.tabs[tab_id] = new_tab; } return this.tabs[tab_id]; } _handleTabUpdate(_tab_id, changes, native_tab_object) { this.log( `Tab ${native_tab_object.id} detected chages: ${JSON.stringify(changes)}` ); let tab = this.tabs[native_tab_object.id]; tab.native_last_change = changes; tab.ensureConnectionToBackground(); tab.sendGlobalConfig(this.config); } // Note that although this callback signifies that the tab now exists, it is not fully // booted and functional until it has opened a communication channel. It can't do that // until it knows its internally represented ID. _newTabHandler(_request, sender, sendResponse) { this.log( `Tab ${sender.tab.id} (${sender.tab.title}) registered with background process` ); // Send the tab back to itself, such that it can be enlightened unto its own nature sendResponse(sender.tab); this._acknowledgeNewTab(sender.tab); } _acknowledgeNewTab(native_tab_object) { let tab = this._applyUpdates(native_tab_object); tab._is_raw_text_mode = this._is_raw_text_mode; tab.postDOMLoadInit(this.terminal, this.dimensions); } _applyUpdates(tabish_object) { let tab = this._maybeNewTab({ id: tabish_object.id, }); [ "id", "title", "url", "active", "request_id", "raw_text_mode_type", "start_time", ].map((key) => { if (tabish_object.hasOwnProperty(key)) { tab[key] = tabish_object[key]; } }); if (tabish_object.active) { this.active_tab_id = tab.id; } return tab; } // This is the main communication channel for all back and forth messages to tabs _listenForTabChannelOpen() { browser.runtime.onConnect.addListener( this._tabChannelOpenHandler.bind(this) ); } _tabChannelOpenHandler(channel) { this.log( `Tab ${channel.name} connected for communication with background process` ); let tab = this.tabs[parseInt(channel.name)]; tab.postConnectionInit(channel, this.config); if (!this._is_connected_to_browser_dom) { this._startFrameRequestLoop(); } this._is_connected_to_browser_dom = true; } _listenForFocussedTab() { browser.tabs.onActivated.addListener(this._focussedTabHandler.bind(this)); } _focussedTabHandler(tab) { this.log(`Tab ${tab.id} received new focus`); this.active_tab_id = tab.id; } _getTabsOnSuccess(windowInfoArray, callback) { for (let windowInfo of windowInfoArray) { windowInfo.tabs.map((tab) => { callback(tab); }); } } _getTabsOnError(error) { this.log(`Error: ${error}`); } _pollAllTabs(callback) { var getting = browser.windows.getAll({ populate: true, windowTypes: ["normal"], }); getting.then( (windowInfoArray) => this._getTabsOnSuccess(windowInfoArray, callback), () => this._getTabsOnError(callback) ); } // The browser window can only be resized once we have both the character dimensions from // the browser tab _and the TTY dimensions from the terminal. There's probably a more // efficient way of triggering this initial window resize, than just waiting for the data // on every frame tick. _initialWindowResize() { if (!this._is_initial_window_size_pending) return; this.dimensions.resizeBrowserWindow(); this._is_initial_window_size_pending = false; } // Instead of having each tab manage its own frame rate, just keep this single, centralised // heartbeat in the background process that switches automatically to the current active // tab. // // Note that by "frame rate" here we justs mean the rate at which a TTY-sized frame of // graphics pixles are sent. Larger frames are sent in response to scroll events and // TTY-sized text frames are sent in response to DOM mutation events. _startFrameRequestLoop() { this.log( "BACKGROUND: Frame loop starting at " + this.config.tty.small_pixel_frame_rate + "ms intervals" ); setInterval(() => { if (this._is_initial_window_size_pending) this._initialWindowResize(); if (this._isAbleToRequestFrame()) { this.sendToCurrentTab("/request_frame"); } }, this.config.tty.small_pixel_frame_rate); } _isAbleToRequestFrame() { if (this._is_raw_text_mode) { return false; } if (!this.dimensions.tty.width || !this.dimensions.tty.height) { this.log("Not sending frame to TTY without TTY size"); return false; } if (!this.tabs.hasOwnProperty(this.active_tab_id)) { this.log("No active tab, so not requesting a frame"); return false; } if (this.currentTab().channel === undefined) { this.log( `Active tab ${this.active_tab_id} does not have a channel, so not requesting a frame` ); return false; } return true; } // Listen for HTTP activity so we can notify the user that something is loading in the background _addWebRequestListener() { browser.webRequest.onBeforeRequest.addListener( (e) => { let message; if (e.type == "main_frame") { message = `Loading ${e.url}`; if (this.currentTab() !== undefined) { this.currentTab().updateStatus("info", message); } } }, { urls: ["*://*/*"], }, ["blocking"] ); } } ================================================ FILE: webext/src/background/tab.js ================================================ import utils from "utils"; import CommonMixin from "background/common_mixin"; import TabCommandsMixin from "background/tab_commands_mixin"; export default class extends utils.mixins(CommonMixin, TabCommandsMixin) { constructor() { super(); // Keep track of automatic reloads to problematic tabs this._tab_reloads = 0; // The maximum amount of times to try to recover a tab that won't connect this._max_number_of_tab_recovery_reloads = 3; // Type of raw text mode; HTML or plain this.raw_text_mode_type = ""; } postDOMLoadInit(terminal, dimensions) { this.terminal = terminal; this.dimensions = dimensions; this._closeUnwantedStartupTabs(); } postConnectionInit(channel, config) { this.channel = channel; this._sendTTYDimensions(); this._listenForMessages(); this.sendGlobalConfig(config); } _calculateMode() { return !this._is_raw_text_mode ? "interactive" : "raw_text_" + this.raw_text_mode_type; } isConnected() { return this.channel !== undefined; } reload() { const reloading = browser.tabs.reload(this.id); reloading.then( (tab) => this.log(`Tab ${tab.id} reloaded.`), (error) => this.log(error) ); } remove() { const removing = browser.tabs.remove(this.id); removing.then( () => this.log(`Tab ${this.id} removed.`), (error) => this.log(error) ); } updateStatus(status, message = "") { let status_message; switch (status) { case "page_init": status_message = `Loading ${this.url}`; break; case "parsing_complete": status_message = ""; break; case "window_unload": status_message = "Loading..."; break; default: if (message != "") status_message = message; } this.page_state = status; this.status_message = status_message; this.sendStateToTerminal(); } getStateObject() { return { id: this.id, active: this.active, removed: this.removed, title: this.title, uri: this.url, page_state: this.page_state, status_message: this.status_message, }; } sendStateToTerminal() { this.sendToTerminal(`/tab_state,${JSON.stringify(this.getStateObject())}`); } // For various reasons a tab's content script doesn't always load. Currently // the known reasons are; // 1. Pages without content, such as direct links to images. // 2. Native pages such as `about:config`. // 3. Unknown buggy behaviour such as on Travis :/ // So here we attempt some workarounds. ensureConnectionToBackground() { let native_status; if (!this._isItOKToRetryReload()) { return; } if (this.native_last_change) { native_status = this.native_last_change.status; } if (native_status === "complete" && !this._isConnected()) { this.log( `Automatically reloading tab ${this.id} that has loaded but not connected ` + "to the webextension" ); this.reload(); this._reload_count++; } } sendGlobalConfig(config) { config.http_server_mode_type = this._calculateMode(); config.start_time = this.start_time; if (this.channel) { this.channel.postMessage(`/config,${JSON.stringify(config)}`); } else { setTimeout(() => { this.sendGlobalConfig(config); }, 1); } } _listenForMessages() { this.channel.onMessage.addListener(this.handleTabMessage.bind(this)); } _sendTTYDimensions() { this.channel.postMessage( `/tty_size,${this.dimensions.tty.width},${this.dimensions.tty.height}` ); } _isItOKToRetryReload() { return this._reload_count <= this._max_number_of_tab_recovery_reloads; } // On the very first startup of Firefox on a new profile it loads a tab disclaiming // its data collection to a third-party. Sometimes this tab loads first, sometimes // it loads second. Especially for testing we always need to load the tab we requested // first. So let's just close that tab. // TODO: Only do this for a testing ENV? _closeUnwantedStartupTabs() { if (this.title === undefined) { return false; } if ( this.title.includes("Firefox by default shares data to:") || this.title.includes("Firefox Privacy Notice") ) { this.log("Removing Firefox startup page"); this.remove(); return true; } return false; } } ================================================ FILE: webext/src/background/tab_commands_mixin.js ================================================ import utils from "utils"; // Handle commands from tabs, like sending a frame or information about // the current character dimensions. export default (MixinBase) => class extends MixinBase { // TODO: There needs to be some consistency in this message sending protocol. // Eg; always requiring JSON. handleTabMessage(message) { let incoming; const parts = message.split(","); const command = parts[0]; switch (command) { case "/frame_text": this.sendToTerminal(`/frame_text,${message.slice(12)}`); break; case "/frame_pixels": this.sendToTerminal(`/frame_pixels,${message.slice(14)}`); break; case "/tab_info": incoming = JSON.parse(utils.rebuildArgsToSingleArg(parts)); this._updateTabInfo(incoming); break; case "/dimensions": incoming = JSON.parse(message.slice(12)); this.dimensions.setCharValues(incoming.char); break; case "/status": this.updateStatus(parts[1], parts[2]); break; case "/log": this.log(message.slice(5)); break; case "/raw_text": incoming = JSON.parse(utils.rebuildArgsToSingleArg(parts)); this._rawTextRequest(incoming); break; default: this.log("Unknown command from tab to background", message); } } _updateTabInfo(incoming) { this.title = incoming.title; this.url = incoming.url; this.sendStateToTerminal(); } _rawTextRequest(incoming) { // I think the only reason that a tab would send a raw text payload is the // automatic startup URL loading, which should now be disabled for HTTP Server // mode. if (this.request_id) { let payload = { json: JSON.stringify(incoming), request_id: this.request_id, }; this.sendToTerminal(`/raw_text,${JSON.stringify(payload)}`); } this._tabCount((count) => { if (count > 1) { this.remove(); } }); } _tabCount(callback) { this._getAllTabs((windowInfoArray) => { callback(windowInfoArray[0].tabs.length); }); } _getAllTabs(callback) { var getting = browser.windows.getAll({ populate: true, windowTypes: ["normal"], }); getting.then( (windowInfoArray) => callback(windowInfoArray), () => this.log("Error getting all tabs in Tab class") ); } }; ================================================ FILE: webext/src/background/tty_commands_mixin.js ================================================ import utils from "utils"; // Handle commands coming in from the terminal, like; STDIN keystrokes, TTY resize, etc export default (MixinBase) => class extends MixinBase { handleTerminalMessage(message) { const parts = message.split(","); const command = parts[0]; switch (command) { case "/config": this._loadConfig(message.slice(8)); break; case "/tab_command": this.sendToCurrentTab(message.slice(13)); break; case "/tty_size": this._updateTTYSize(parts[1], parts[2]); break; case "/stdin": this._handleUICommand(parts); this.sendToCurrentTab(message); break; case "/url_bar": this._handleURLBarInput(parts.slice(1).join(",")); break; case "/new_tab": this.createNewTab(parts.slice(1).join(",")); break; case "/switch_to_tab": this.switchToTab(parts.slice(1).join(",")); break; case "/remove_tab": this.removeTab(parts.slice(1).join(",")); break; case "/raw_text_request": this._rawTextRequest(parts[1], parts[2], parts.slice(3).join(",")); break; } } _loadConfig(json_string) { this.log(json_string); this.config = JSON.parse(json_string); this.config.browsh_version = browser.runtime.getManifest().version; if (this.currentTab()) { this.currentTab().sendGlobalConfig(this.config); } this.dimensions.postConfigSetup(this.config); this._setupRawTextMode(); } _setupRawTextMode() { if (!this.config["http-server-mode"]) { return; } this._is_raw_text_mode = true; this._updateTTYSize( this.dimensions.raw_text_tty_size.width, this.dimensions.raw_text_tty_size.height ); } _updateTTYSize(width, height) { this.dimensions.tty.width = parseInt(width); this.dimensions.tty.height = parseInt(height); if (this.currentTab()) { this.sendToCurrentTab( `/tty_size,${this.dimensions.tty.width},${this.dimensions.tty.height}` ); } this.log( `Requesting browser resize for new TTY dimensions: ` + `${width}x${height}` ); this.dimensions.resizeBrowserWindow(); } _handleUICommand(parts) { const input = JSON.parse(utils.rebuildArgsToSingleArg(parts)); // CTRL mappings /* if (input.mod === 2) { switch (input.char) { default: } } */ // ALT mappings if (input.mod === 4) { switch (input.char) { case "p": this.screenshotActiveTab(); break; case "u": this.toggleUserAgent(); break; } } return false; } _handleURLBarInput(input) { const final_url = this._getURLfromUserInput(input); this.gotoURL(final_url); } // TODO: move to CLI client _getURLfromUserInput(input) { let url; const search_engine = this.config.default_search_engine_base; // Basically just check to see if there is text either side of a dot const is_straddled_dot = RegExp(/^[^\s]+\.[^\s]+/); // More comprehensive URL pattern const is_url = RegExp(/\/\/\w+(\.\w+)*(:[0-9]+)?\/?(\/[.\w]*)*$/); if (is_straddled_dot.test(input) || is_url.test(input)) { url = input; if (!url.startsWith("http")) { url = "http://" + url; } } else { url = `${search_engine}${input}`; } this.urlBarUserContent = url; return url; } createNewTab(url, callback) { const final_url = this._getURLfromUserInput(url); let creating = browser.tabs.create({ url: final_url, }); creating.then( (tab) => { if (callback) { callback(tab); } this.log(`New tab created: ${tab}`); }, (error) => { this.log(`Error creating new tab: ${error}`); } ); } gotoURL(url) { let updating = browser.tabs.update(parseInt(this.currentTab().id), { url: url, }); updating.then( (tab) => { this.log(`Tab ${tab.id} loaded: ${url}`); }, (error) => { this.log(`Error loading: ${url} \nError: ${error}`); } ); } switchToTab(id) { let updating = browser.tabs.update(parseInt(id), { active: true, }); updating.then( (tab) => { this.log(`Switched to tab: ${tab.id}`); }, (error) => { this.log(`Error switching to tab: ${error}`); } ); } removeTab(id) { this.tabs[id].remove(); this.tabs[id] = null; } // We use the `browser` object here rather than going into the actual content script // because the content script may have crashed, even never loaded. screenshotActiveTab() { const capturing = browser.tabs.captureVisibleTab({ format: "jpeg", }); capturing.then(this._saveScreenshot.bind(this), (error) => this.log(error) ); } _saveScreenshot(imageUri) { const data = imageUri.replace(/^data:image\/\w+;base64,/, ""); this.sendToTerminal("/screenshot," + data); } _rawTextRequest(request_id, mode, url) { this.createNewTab(url, (native_tab) => { this._acknowledgeNewTab({ id: native_tab.id, request_id: request_id, raw_text_mode_type: mode.toLowerCase(), start_time: Date.now(), }); // Sometimes tabs fail to load for whatever reason. Make sure they get // removed to save RAM in long-lived Browsh HTTP servers setTimeout(() => { if (this.tabs[native_tab.id]) { this.removeTab(native_tab.id); } }, 60000); }); } toggleUserAgent() { let message; this._is_using_mobile_user_agent = !this._is_using_mobile_user_agent; message = this._is_using_mobile_user_agent ? "Mobile user agent active" : "Desktop user agent active"; this.currentTab().updateStatus("info", message); } _addUserAgentListener() { browser.webRequest.onBeforeSendHeaders.addListener( (e) => { if (this._is_using_mobile_user_agent) { e.requestHeaders.forEach((header) => { if (header.name.toLowerCase() == "user-agent") { header.value = this.config.mobile_user_agent; } }); return { requestHeaders: e.requestHeaders, }; } }, { urls: ["*://*/*"], }, ["blocking", "requestHeaders"] ); } }; ================================================ FILE: webext/src/dom/commands_mixin.js ================================================ import utils from "utils"; export default (MixinBase) => class extends MixinBase { _handleBackgroundMessage(message) { let input, url, config; const parts = message.split(","); const command = parts[0]; switch (command) { case "/config": config = JSON.parse(utils.rebuildArgsToSingleArg(parts)); this._loadConfig(config); break; case "/request_frame": this.sendFrame(); break; case "/rebuild_text": if (this._is_interactive_mode) { this.sendAllBigFrames(); } break; case "/scroll_status": this._handleScroll(parts[1], parts[2]); break; case "/tty_size": this._handleTTYSize(parts[1], parts[2]); break; case "/stdin": input = JSON.parse(utils.rebuildArgsToSingleArg(parts)); this._handleUserInput(input); break; case "/input_box": input = JSON.parse(utils.rebuildArgsToSingleArg(parts)); this._handleInputBoxContent(input); break; case "/url": url = utils.rebuildArgsToSingleArg(parts); document.location.href = url; break; case "/history_back": history.go(-1); break; case "/window_stop": window.stop(); break; default: this.log("Unknown command sent to tab", message); } } _launch() { const mode = this.config.http_server_mode_type; if (mode.includes("raw_text_")) { this._is_raw_text_mode = true; this._is_interactive_mode = false; this._raw_mode_type = mode; this.sendRawText(); } if (mode === "interactive") { this._is_raw_text_mode = false; this._is_interactive_mode = true; this._setupInteractiveMode(); } } _loadConfig(config) { this.config = config; this._postSetupConstructor(); this._launch(); } _handleUserInput(input) { this._handleSpecialKeys(input); this._handleCharBasedKeys(input); this._handleMouse(input); } _handleSpecialKeys(input) { let state, message; switch (input.key) { case 18: // CTRL+r window.location.reload(); break; case 284: // F6 state = this.config.browsh.use_experimental_text_visibility; state = !state; this.config.browsh.use_experimental_text_visibility = state; message = state ? "on" : "off"; this.sendMessage( `/status,info,Experimental text visibility: ${message}` ); this.sendSmallTextFrame(); break; } } _handleCharBasedKeys(input) { switch (input.char) { default: this._triggerKeyPress(input); } } _handleInputBoxContent(input) { let input_box = document.querySelectorAll( `[data-browsh-id="${input.id}"]` )[0]; if (input_box) { input_box.focus(); if (input_box.getAttribute("role") == "textbox") { input_box.innerHTML = input.text; } else { input_box.value = input.text; } } } // TODO: Dragndrop doesn't seem to work :/ _handleMouse(input) { switch (input.button) { case 1: this._mouseAction("mousemove", input.mouse_x, input.mouse_y); if (!this._mousedown) { this._mouseAction("mousedown", input.mouse_x, input.mouse_y); setTimeout(() => { this.sendSmallTextFrame(); }, 500); } this._mousedown = true; break; case 0: this._mouseAction("mousemove", input.mouse_x, input.mouse_y); if (this._mousedown) { this._mouseAction("click", input.mouse_x, input.mouse_y); this._mouseAction("mouseup", input.mouse_x, input.mouse_y); } this._mousedown = false; break; } } _handleTTYSize(x, y) { if (!this._is_first_frame_finished) { this.dimensions.tty.width = parseInt(x); this.dimensions.tty.height = parseInt(y); this.dimensions.update(); this.sendAllBigFrames(); } } _handleScroll(x, y) { this.dimensions.frame.x_scroll = parseInt(x); this.dimensions.frame.y_scroll = parseInt(y); this.dimensions.update(); window.scrollTo( this.dimensions.frame.x_scroll / this.dimensions.scale_factor.width, this.dimensions.frame.y_scroll / this.dimensions.scale_factor.height ); this._mightSendBigFrames(); } _triggerKeyPress(key) { let el = document.activeElement; if (el == null) { this.log( `Not pressing '${key.char}(${key.key})' as there is no active element` ); return; } const key_object = { key: key.char, keyCode: key.key, }; let event_press = new KeyboardEvent("keypress", key_object); let event_down = new KeyboardEvent("keydown", key_object); let event_up = new KeyboardEvent("keyup", key_object); // Generally sending down/up serves more use cases. But default input forms // don't listen for down/up to make the form submit. So this makes the assumption // that it's okay to send ENTER twice to an input box without any serious side // effects. if (key.key === 13 && el.tagName === "INPUT") { el.dispatchEvent(event_press); } else { el.dispatchEvent(event_down); el.dispatchEvent(event_up); } } _mouseAction(type, x, y) { const [dom_x, dom_y] = this._getDOMCoordsFromMouseCoords(x, y); const element = document.elementFromPoint( dom_x - window.scrollX, dom_y - window.scrollY ); element.focus(); var clickEvent = document.createEvent("MouseEvents"); clickEvent.initMouseEvent( type, true, true, window, 0, 0, 0, dom_x, dom_y, false, false, false, false, 0, null ); element.dispatchEvent(clickEvent); } // The user clicks on a TTY grid which has a significantly lower resolution than the // actual browser window. So we scale the coordinates up as if the user clicked on the // the central "pixel" of a TTY cell. // // Furthermore if the TTY click is on a readable character then the click is proxied // to the original position of the character before TextBuilder snapped the character into // position. _getDOMCoordsFromMouseCoords(x, y) { let dom_x, dom_y, char, original_position; const index = y * this.dimensions.frame.width + x; if (this.text_builder.tty_grid.cells[index] !== undefined) { char = this.text_builder.tty_grid.cells[index].rune; } else { char = false; } if (!char || char === "▄") { dom_x = x * this.dimensions.char.width; dom_y = y * this.dimensions.char.height; } else { // Recall that text can be shifted from its original position in the browser in order // to snap it consistently to the TTY grid. original_position = this.text_builder.tty_grid.cells[index].dom_coords; dom_x = original_position.x; dom_y = original_position.y; } return [ dom_x + this.dimensions.char.width / 2, dom_y + this.dimensions.char.height / 2, ]; } _sendTabInfo() { const title_object = document.getElementsByTagName("title"); let info = { url: document.location.href, title: title_object.length ? title_object[0].innerHTML : "", }; this.sendMessage(`/tab_info,${JSON.stringify(info)}`); } _mightSendBigFrames() { if (this._is_raw_text_mode) { return; } const y_diff = this.dimensions.frame.y_last_big_frame - this.dimensions.frame.y_scroll; const max_y_scroll_without_new_big_frame = (this.dimensions._big_sub_frame_factor - 1) * this.dimensions.tty.height; if (Math.abs(y_diff) > max_y_scroll_without_new_big_frame) { this.log( `Parsing big frames: ` + `previous-y: ${this.dimensions.frame.y_last_big_frame}, ` + `y-scroll: ${this.dimensions.frame.y_scroll}, ` + `diff: ${y_diff}, ` + `max-scroll: ${max_y_scroll_without_new_big_frame} ` ); this.sendAllBigFrames(); } } }; ================================================ FILE: webext/src/dom/common_mixin.js ================================================ export default (MixinBase) => class extends MixinBase { constructor() { super(); this._is_first_frame_finished = false; } sendMessage(message) { if (this.channel == undefined) { return; } this.channel.postMessage(message); } log(...messages) { if (this.channel == undefined) { return; } messages.unshift(this.channel.name); this.sendMessage(`/log,${JSON.stringify(messages)}`); } logPerformance(work, reference) { let start = performance.now(); work(); let end = performance.now(); let duration = end - start; if (duration > 10) { this.firstFrameLog(`${reference}: ${duration}ms`); } } logError(error) { this.log(`'${error.name}' ${error.message}`); this.log(`@${error.fileName}:${error.lineNumber}`); this.log(error.stack); } // If you're logging large objects and using a high-ish FPS (<1000ms) then you might // crash the browser. So use this function instead. firstFrameLog(...logs) { if (this._is_first_frame_finished) return; if (DEVELOPMENT) { this.log(logs); } } }; ================================================ FILE: webext/src/dom/dimensions.js ================================================ import utils from "utils"; import CommonMixin from "dom/common_mixin"; // All the various dimensions, sizes, scales, etc export default class extends utils.mixins(CommonMixin) { constructor() { super(); // ID for element we place in the DOM to measure the size of a single monospace // character. this._measuring_box_id = "browsh_em_measuring_box"; // This used to be dynamically calculated at _calculateCharacterDimensions() // But it proved to be bugy, I think because of a race condition on lightweight sites // where the webextension's CSS wouldn't get applied in time. this._pre_calculated_char = !TEST ? { width: 9, height: 15, } : { width: 1, height: 2, }; // TODO: WTF is this magic number? The gap between lines? this._char_height_magic_number = !TEST ? 5 : 0; // This is the region outside the visible area of the TTY that is pre-parsed and // sent to the TTY to be buffered to support faster scrolling. this._big_sub_frame_factor = 6; // The max size in pixels for either the width or height to be for Browsh to parse in // raw text mode. // TODO: Use incremental parses to overcome this limit. this._entire_dom_limit = 30000; this.dom = {}; this.tty = {}; this.frame = { x_scroll: 0, y_scroll: 0, x_last_big_frame: 0, y_last_big_frame: 0, }; } update() { this._calculateCharacterDimensions(); this._updateDOMDimensions(); this._calculateScaleFactor(); this._updateFrameDimensions(); this._notifyBackground(); } setSubFrameDimensions(size) { this._calculateSmallSubFrame(); if (size === "big" || size === "all") { this._calculateBigSubFrame(); } if (size === "raw_text") { this._calculateEntireDOMFrames(); } // Only the height needs to be even because of the UTF8 half-block trick. A single // TTY cell always contains exactly 2 pseudo pixels. this.frame.sub.height = utils.ensureEven(this.frame.sub.height); } // This is the data that is sent with the JSON payload of every frame to the TTY getFrameMeta() { return { sub_left: utils.snap(this.frame.sub.left), sub_top: utils.snap(this.frame.sub.top), sub_width: utils.snap(this.frame.sub.width), sub_height: utils.snap(this.frame.sub.height), total_width: utils.snap(this.frame.width), total_height: utils.snap(this.frame.height), }; } // This is the sub frame that is the view onto the frame that is visible by the user // in the TTY at any given time. _calculateSmallSubFrame() { this.frame.sub = { left: this.frame.x_scroll, top: this.frame.y_scroll, width: this.tty.width, height: this.tty.height * 2, }; this._scaleSubFrameToSubDOM(); } // This is the sub frame that is a few factors bigger than what the user can see // in the TTY. _calculateBigSubFrame() { this.frame.sub = { left: this.frame.x_scroll - this._big_sub_frame_factor * this.tty.width, top: this.frame.y_scroll - this._big_sub_frame_factor * this.tty.height * 2, width: this.tty.width + this._big_sub_frame_factor * 2 * this.tty.width, height: this.tty.height + this._big_sub_frame_factor * 2 * this.tty.height * 2, }; this._limitSubFrameDimensions(); this._scaleSubFrameToSubDOM(); } // The raw text frames requested through the Browsh HTTP server need to be built from the // entire DOM, not just a small window onto the DOM. _calculateEntireDOMFrames() { this.dom.sub = { left: 0, top: 0, width: this.dom.width, height: this.dom.height, }; if (this.dom.sub.width > this._entire_dom_limit) { this.dom.sub.width = this._entire_dom_limit; this.is_page_truncated = true; } if (this.dom.sub.height > this._entire_dom_limit) { this.dom.sub.height = this._entire_dom_limit; this.is_page_truncated = true; } this.frame.sub = { left: 0, top: 0, width: this.dom.sub.width * this.scale_factor.width, height: this.dom.sub.height * this.scale_factor.height, }; } _limitSubFrameDimensions() { if (this.frame.sub.left < 0) { this.frame.sub.left = 0; } if (this.frame.sub.top < 0) { this.frame.sub.top = 0; } if (this.frame.sub.width > this.frame.width) { this.frame.sub.width = this.frame.width; } if (this.frame.sub.height > this.frame.height) { this.frame.sub.height = this.frame.height; } } _scaleSubFrameToSubDOM() { this.dom.sub = { left: this.frame.sub.left / this.scale_factor.width, top: this.frame.sub.top / this.scale_factor.height, width: this.frame.sub.width / this.scale_factor.width, height: this.frame.sub.height / this.scale_factor.height, }; } // This is critical in order for the terminal to match the browser as closely as possible. // Ideally we want the browser's window size to be exactly multiples of the terminal's // dimensions. So if the terminal is 80x40 and the font-size is 12px (12x6 pixels), then // the window should be 480x480. Also knowing the precise font-size helps the text builder // map un-snapped text to the best grid cells - grid cells that represent the terminal's // character positions. // // This used to dynamically allocate the character size but it proved to be buggy, both because // of an occasional race condition and because some sites (eg; stackoverflow.com) returned values // over 100. _calculateCharacterDimensions() { if (document.body !== null) { const element = this._getOrCreateMeasuringBox(); const dom_rect = element.getBoundingClientRect(); if ( dom_rect.width != this._pre_calculated_char.width || dom_rect.height != this._pre_calculated_char.height ) { this.log( `Using char dims ${this._pre_calculated_char.width}x${this._pre_calculated_char.height}` ); this.log(`Actual char dims ${dom_rect.width}x${dom_rect.height}`); } } this.char = { width: this._pre_calculated_char.width, height: this._pre_calculated_char.height + this._char_height_magic_number, }; } // Back when printing was done by physical stamps, it was convention to measure the // font-size using the letter 'M', thus where we get the unit 'em' from. Not that it // should not make any difference to us, but it's nice to keep a tradition. _getOrCreateMeasuringBox() { let measuring_box = this.findMeasuringBox(); if (measuring_box) return measuring_box; measuring_box = document.createElement("span"); measuring_box.id = this._measuring_box_id; measuring_box.style.visibility = "hidden"; var M = document.createTextNode("M"); measuring_box.appendChild(M); document.body.appendChild(measuring_box); return measuring_box; } findMeasuringBox() { return document.getElementById(this._measuring_box_id); } _updateDOMDimensions() { const [new_width, new_height] = this._calculateDOMDimensions(); const is_new = this.dom.width != new_width || this.dom.height != new_height; this.dom = { sub: this.dom.sub, width: new_width, height: new_height, is_new: is_new, }; } // For discussion on various methods to get total scrollable DOM dimensions, see: // https://stackoverflow.com/a/44077777/575773 _calculateDOMDimensions() { let width = document.documentElement.scrollWidth; if (window.innerWidth > width) width = window.innerWidth; let height = document.documentElement.scrollHeight; if (window.innerHeight > height) height = window.innerHeight; return [width, height]; } // A frame represents the entire DOM page. Its height usually extends below the window's // bottom and occasionally extends beyond the sides too. // // Note that it treats the height of a single TTY cell as containing 2 pixels. Therefore // a TTY of 4x4 will have frame dimensions of 4x8. _updateFrameDimensions() { let width = this.dom.width * this.scale_factor.width; let height = this.dom.height * this.scale_factor.height; this.frame.width = utils.snap(width); this.frame.height = utils.snap(height); } // The scale factor is the ratio of the TTY's representation of the DOM to the browser's // representation of the DOM. The idea is that the TTY just represents a very low // resolution version of the browser - though note that the TTY has the significant // benefit of being able to display native fonts (possibly even retina-like high DPI // fonts). So Browsh's enforced CSS rules reorient the browser page to render all text // at the same monospaced sized - in this sense, theoretically, the TTY and the browser // should essentially be facsimilies of each other. However of course the TTY is limited // by its cell size in how it renders "pixels", namely pseudo pixels using the UTF8 // block trick. // // All of which is to say that the fundamental relationship between the browser's dimensions // and the TTY's dimensions is represented by a TTY cell - that which displays a single // character. So if we know how many characters fit into the DOM, then we know how many // "pixels" the TTY should have. _calculateScaleFactor() { this.scale_factor = { width: 1 / this.char.width, // Recall that 2 UTF8 half-black "pixels" can fit into a single TTY cell height: 2 / this.char.height, }; } _notifyBackground() { const dimensions = { dom: this.dom, frame: this.frame, char: this.char, }; this.sendMessage(`/dimensions,${JSON.stringify(dimensions)}`); } } ================================================ FILE: webext/src/dom/graphics_builder.js ================================================ import utils from "utils"; import CommonMixin from "dom/common_mixin"; // Converts an instance of the visible DOM into an array of pixel values. // Note that it does this both with and without the text visible in order // to aid in a clean separation of the graphics and text in the final frame // rendered in the terminal. export default class extends utils.mixins(CommonMixin) { constructor(channel, dimensions, config) { super(); this.channel = channel; this.dimensions = dimensions; this.config = config; this._html_image_compression = this.config["http-server"].jpeg_compression; this._screenshot_canvas = document.createElement("canvas"); this._converter_canvas = document.createElement("canvas"); this._screenshot_ctx = this._screenshot_canvas.getContext("2d"); this._converter_ctx = this._converter_canvas.getContext("2d"); } sendFrame() { this.__getScaledScreenshot(); this._sendFrame(); } // With full-block single-glyph font on getUnscaledFGPixelAt(x, y) { [x, y] = this._convertDOMCoordsToRelative(x, y); if (x === null || y === null) { return [null, null, null]; } const width = this.dimensions.dom.sub.width; const pixel_data_start = parseInt(y * width * 4 + x * 4); let fg_rgb = this.pixels_with_text.slice( pixel_data_start, pixel_data_start + 3 ); return [fg_rgb[0], fg_rgb[1], fg_rgb[2]]; } // Without any text showing at all getUnscaledBGPixelAt(x, y) { [x, y] = this._convertDOMCoordsToRelative(x, y); if (x === null || y === null) { return [null, null, null]; } const width = this.dimensions.dom.sub.width; const pixel_data_start = parseInt(y * width * 4 + x * 4); let bg_rgb = this.pixels_without_text.slice( pixel_data_start, pixel_data_start + 3 ); return [bg_rgb[0], bg_rgb[1], bg_rgb[2]]; } getScreenshotWithText(callback) { this.logPerformance(() => { this._getScreenshotWithText(callback); }, "get screenshot with text"); } getScreenshotWithoutText() { this.logPerformance(() => { this._getScreenshotWithoutText(); }, "get screenshot without text"); } getOnOffScreenshots(callback) { this.getScreenshotWithoutText(); this.getScreenshotWithText(callback); } _getScreenshotWithoutText() { this.pixels_without_text = this._getScreenshot().data; return this.pixels_without_text; } _getScreenshotWithText(callback) { this.showText(); if (this.config["http-server-mode"]) { // It's a little odd that `config['http-server'].render_delay` is named as such // and placed here of all places. But the fact is that a delay is needed here // *anyway* and extending the delay kills 2 birds with one stone. Firstly solving // this tricky little need-to-wait-for-the-font-to-render issue *and* solving the // the fact that some pages just don't finish loading at `windows.onload()`. setTimeout(() => { this._getScreenshotWithTextDelayable(callback); }, this.config["http-server"].render_delay); } else { this._getScreenshotWithTextDelayable(callback); } } // I'm not entirely clear on the reason, but when a Browsh tab's only purpose is // to render a single frame (such as in the HTTP service), it needs a few milliseconds // to show the text for the first time. My only theory is that at page load some time // is needed to parse and render the font. // However in normal TTY mode, no such delay is needed, indeed even placing this // function inside `setTimeout()` causes oddities. _getScreenshotWithTextDelayable(callback) { this.pixels_with_text = this._getScreenshot().data; this.hideText(); callback(); } _getScaledScreenshot() { this._scaleCanvas(); this.scaled_pixels_image_object = this._getScreenshot(); this.scaled_pixels = this.scaled_pixels_image_object.data; this._unScaleCanvas(); return this.scaled_pixels; } // It's either convert coords to relative in this class or TextBuilder. On balance it // seems better to retain TextBuilder's reference in absolute coords, thus somewhat // hiding the overhead of relative-to-the-frame coords in public methods. _convertDOMCoordsToRelative(x, y) { const top = this.dimensions.dom.sub.top; const bottom = this.dimensions.dom.sub.top + this.dimensions.dom.sub.height; const left = this.dimensions.dom.sub.left; const right = this.dimensions.dom.sub.left + this.dimensions.dom.sub.width; if (x >= left && x < right) { x -= this.dimensions.dom.sub.left; } else { x = null; } if (y >= top && y < bottom) { y -= this.dimensions.dom.sub.top; } else { y = null; } return [x, y]; } // Scaled to the size where each pixel is the same size as a TTY cell _getScaledPixelAt(x, y) { const width = this.dimensions.frame.sub.width; const pixel_data_start = y * width * 4 + x * 4; const rgb = this.scaled_pixels.slice( pixel_data_start, pixel_data_start + 3 ); return [rgb[0], rgb[1], rgb[2]]; } __getScaledScreenshot() { this.logPerformance(() => { this._getScaledScreenshot(); }, "get scaled screenshot"); } hideText() { document.body.classList.remove("browsh-show-text"); document.body.classList.add("browsh-hide-text"); } showText() { document.body.classList.remove("browsh-hide-text"); document.body.classList.add("browsh-show-text"); } _getScreenshot() { return this._getPixelData(); } // Scale the screenshot so that 1 pixel approximates half a TTY cell. _scaleCanvas() { this._is_scaled = true; this._screenshot_ctx.save(); this._screenshot_ctx.scale( this.dimensions.scale_factor.width, this.dimensions.scale_factor.height ); } _unScaleCanvas() { this._screenshot_ctx.restore(); this._is_scaled = false; } _updateCanvasSize() { if (this._is_scaled) return; this._screenshot_canvas.width = this.dimensions.dom.sub.width; this._screenshot_canvas.height = this.dimensions.dom.sub.height; } // Get an array of RGB values. // This is Firefox-only. Chrome has a nicer MediaStream for this. _getPixelData() { let width, height; const background_colour = "rgb(255,255,255)"; if (this._is_scaled) { width = this.dimensions.frame.sub.width; height = this.dimensions.frame.sub.height; } else { width = this.dimensions.dom.sub.width; height = this.dimensions.dom.sub.height; } if (width <= 0 || height <= 0) { return []; } this._updateCanvasSize(); this._screenshot_ctx.drawWindow( window, this.dimensions.dom.sub.left, this.dimensions.dom.sub.top, this.dimensions.dom.sub.width, this.dimensions.dom.sub.height, background_colour ); return this._screenshot_ctx.getImageData(0, 0, width, height); } // Return the scaled screenshot as a data URI to display in HTML _getScaledDataURI() { this.__getScaledScreenshot(); this._converter_canvas.width = this.dimensions.frame.sub.width; this._converter_canvas.height = this.dimensions.frame.sub.height; this._converter_ctx.putImageData(this.scaled_pixels_image_object, 0, 0); return this._converter_canvas.toDataURL( "image/jpeg", this._html_image_compression ); } _sendFrame() { this._serialiseFrame(); if (this.frame.colours.length > 0) { this.sendMessage(`/frame_pixels,${JSON.stringify(this.frame)}`); } else { this.log("Not sending empty pixels frame"); } } _serialiseFrame() { this._setupFrameMeta(); const width = this.dimensions.frame.sub.width; const height = this.dimensions.frame.sub.height; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { // TODO: Explore sending as binary data this._getScaledPixelAt(x, y).map((c) => this.frame.colours.push(c)); } } } _setupFrameMeta() { this.frame = { meta: this.dimensions.getFrameMeta(), colours: [], }; this.frame.meta.id = parseInt(this.channel.name); } } ================================================ FILE: webext/src/dom/manager.js ================================================ import _ from "lodash"; import utils from "utils"; import CommonMixin from "dom/common_mixin"; import CommandsMixin from "dom/commands_mixin"; import Dimensions from "dom/dimensions"; import GraphicsBuilder from "dom/graphics_builder"; import TextBuilder from "dom/text_builder"; // Entrypoint for managing a single tab export default class extends utils.mixins(CommonMixin, CommandsMixin) { constructor() { super(); this.dimensions = new Dimensions(); // Whether the DOM has loaded this.is_dom_loaded = false; // Whether the page has finished "spinning" this.is_page_finished_loading = false; // For Browsh used via the interactive CLI ap this._is_interactive_mode = false; // For Browsh used via the HTTP server this._is_raw_mode = false; this._setupInit(); } _postSetupConstructor() { this._injectCustomCSS(); this.dimensions.channel = this.channel; this.graphics_builder = new GraphicsBuilder( this.channel, this.dimensions, this.config ); this.text_builder = new TextBuilder( this.channel, this.dimensions, this.graphics_builder, this.config ); } _willHideText() { if (this.is_dom_loaded && this.graphics_builder) { this.graphics_builder.hideText(); } else { setTimeout(this._willHideText.bind(this), 1); } } sendFrame() { this.dimensions.update(); if (this.dimensions.dom.is_new) { this.sendAllBigFrames(); } this.sendSmallPixelFrame(); this._sendTabInfo(); if (!this._is_first_frame_finished) { this.sendMessage("/status,parsing_complete"); } this._is_first_frame_finished = true; } sendAllBigFrames() { if (!this._is_interactive_mode) { return; } if (!this.dimensions.tty.width) { this.log("Not sending big frames without TTY data"); return; } else { this.log("Sending big frames..."); } this.dimensions.update(); this.dimensions.setSubFrameDimensions("big"); this.text_builder.sendFrame(); this.graphics_builder.sendFrame(); this.dimensions.frame.x_last_big_frame = this.dimensions.frame.x_scroll; this.dimensions.frame.y_last_big_frame = this.dimensions.frame.y_scroll; } sendRawText() { if (this.is_page_finished_loading) { this.dimensions.update(); this.dimensions.setSubFrameDimensions("raw_text"); this.text_builder.sendRawText(this._raw_mode_type); } else { setTimeout(this.sendRawText.bind(this), 1); } } sendSmallPixelFrame() { if (!this._is_interactive_mode) { return; } if (!this.dimensions.tty.width) { this.log("Not sending small frames without TTY data"); return; } this.dimensions.update(); this.dimensions.setSubFrameDimensions("small"); this.graphics_builder.sendFrame(); } sendSmallTextFrame() { if (!this._is_interactive_mode) { return; } if (!this.dimensions.tty.width) { this.log("Not sending small frames without TTY data"); return; } this.dimensions.update(); this.dimensions.setSubFrameDimensions("small"); this.text_builder.sendFrame(); } _postCommsInit() { this.log("Webextension postCommsInit()"); this._sendTabInfo(); this.sendMessage("/status,page_init"); this._listenForBackgroundMessages(); this._startWindowEventListeners(); } // Fire up the TTY interactive mode. It doesn't need to wait for any particular // DOM stage as it's good to just get something in front of the user as soon // as possible. _setupInteractiveMode() { this._setupDebouncedFunctions(); this._startMutationObserver(); this.sendAllBigFrames(); // TODO: // Disabling CSS transitions is not easy, many pages won't even render // if they're disabled. Eg; Google's login process. // What if we could get a post-transition hook? setTimeout(() => { this.sendAllBigFrames(); }, 500); } _setupDebouncedFunctions() { this._debouncedSmallTextFrame = _.debounce(this.sendSmallTextFrame, 100, { leading: true, }); } _setupInit() { if (this._isWindowAlreadyLoaded()) { this._init(100); } else { this._init(); } } _isWindowAlreadyLoaded() { if (document.body === undefined) { return false; } return !!this.dimensions.findMeasuringBox(); } _init(delay = 0) { // When the webext devtools auto reloads this code, the background process // can sometimes still be loading, in which case we need to wait. setTimeout(() => this._registerWithBackground(), delay); } _registerWithBackground() { let sending = browser.runtime.sendMessage("/register"); sending.then( (r) => this._registrationSuccess(r), (e) => this._registrationError(e) ); } _registrationSuccess(registered) { this.channel = browser.runtime.connect({ // We need to give ourselves a unique channel name, so the background // process can identify us amongst other tabs. name: registered.id.toString(), }); this._postCommsInit(); } _registrationError(error) { this.log(error); } _startWindowEventListeners() { window.addEventListener("DOMContentLoaded", () => { this.is_dom_loaded = true; this.log("DOM LOADED"); this._fixStickyElements(); this._willHideText(); }); window.addEventListener("load", () => { this.is_page_finished_loading = true; this.config.page_load_duration = Date.now() - this.config.start_time; this.log("PAGE LOADED"); }); window.addEventListener("unload", () => { this.sendMessage("/status,window_unload"); }); window.addEventListener("error", (error) => { this.logError(error); }); } _startMutationObserver() { let target = document.querySelector("body"); let observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { this.log("!!MUTATION!!", mutation); this._debouncedSmallTextFrame(); }); }); observer.observe(target, { subtree: true, characterData: true, childList: true, }); } _listenForBackgroundMessages() { this.channel.onMessage.addListener((message) => { try { this._handleBackgroundMessage(message); } catch (error) { this.logError(error); } }); } // Sticky elements are, for example, those headers that follow you down the page as you // scroll. They are annoying even in desktop browsers, however because of the lower frame // rate of Browsh, sticky elements stutter down the page, so it's even more annoying. Not // to mention the screen real estate that sticky elements take up, which is even more // noticeable on a small TTY screen like Browsh's. // // Note that this uses `getComputedStyle()`, which can be expensive, there should only // be 1 that parses that entire tree during page load. So if there's reason to use more // CSS tricks like this, then the call should be refactored. _fixStickyElements() { let position; let i, elements = document.querySelectorAll("body *"); for (i = 0; i < elements.length; i++) { position = getComputedStyle(elements[i]).position; if (position === "fixed" || position === "sticky") { elements[i].style.setProperty("position", "absolute", "important"); } } } _injectCustomCSS() { var node = document.createElement("style"); node.innerHTML = this.config.browsh.custom_css; if (document.body) { document.body.appendChild(node); } } } ================================================ FILE: webext/src/dom/serialise_mixin.js ================================================ import utils from "utils"; export default (MixinBase) => class extends MixinBase { __serialiseFrame() { let cell, index; const top = this.dimensions.frame.sub.top / 2; const left = this.dimensions.frame.sub.left; const bottom = top + this.dimensions.frame.sub.height / 2; const right = left + this.dimensions.frame.sub.width; this._setupFrameMeta(); this._serialiseInputBoxes(); for (let y = top; y < bottom; y++) { for (let x = left; x < right; x++) { index = y * this.dimensions.frame.width + x; cell = this.tty_grid.cells[index]; if (cell === undefined) { this.frame.colours.push(0); this.frame.colours.push(0); this.frame.colours.push(0); this.frame.text.push(""); } else { cell.fg_colour.map((c) => this.frame.colours.push(c)); this.frame.text.push(cell.rune); } } } } _serialiseRawText() { let raw_text = ""; this._previous_cell_href = ""; this._is_inside_anchor = false; const top = this.dimensions.frame.sub.top / 2; const left = this.dimensions.frame.sub.left; const bottom = top + this.dimensions.frame.sub.height / 2; const right = left + this.dimensions.frame.sub.width; for (let y = top; y < bottom; y++) { for (let x = left; x < right; x++) { raw_text += this._addCell(x, y, right); } raw_text += "\n"; } return this._wrap(raw_text); } _wrap(raw_text) { let head; head = this._raw_mode_type === "raw_text_html" ? this._getHTMLHead() : this._getUserHeader(); return head + raw_text + this._getFooter(); } // Whether a use has shown support. This controls certain Browsh branding and // nags to donate. userHasShownSupport() { return ( this.config.browsh_supporter === "I have shown my support for Browsh" ); } _byBrowsh() { let by; if (this.userHasShownSupport()) { return ""; } by = this._raw_mode_type === "raw_text_html" ? 'by Browsh v' : "by Browsh v"; return by + this.config.browsh_version + " "; } _getUserFooter() { return "\n" + this.config["http-server"].footer; } _getUserHeader() { return this.config["http-server"].header + "\n"; } _getMetaData() { let metadata = ""; this._markParsingDuration(); const date_time = this._getCurrentDataTime(); const elapsed = `${this._parsing_duration}ms`; metadata += "\n\n" + `Built ` + this._byBrowsh() + `on ${date_time} in ${elapsed}.`; if (this.dimensions.is_page_truncated) { metadata += "\nBrowsh parser: the page was too large, some text may have been truncated."; } return metadata; } _getDonateCall() { let donating; if (this.userHasShownSupport()) { return ""; } donating = this._raw_mode_type === "raw_text_html" ? 'donating' : "https://brow.sh/donate"; return ( "\nPlease consider " + donating + " to help all those with slow and/or expensive internet." ); } _getFooter() { let start, end; if (this._raw_mode_type === "raw_text_html") { start = ''; end = ""; } else { start = ""; end = ""; } return ( start + this._getMetaData() + this._getDonateCall() + this._getUserFooter() + end ); } _getHTMLHead() { const img_src = this.graphics_builder._getScaledDataURI(); const width = this.dimensions.dom.sub.width; const height = this.dimensions.dom.sub.height; return ` ${this._getFavicon()} ${document.title} ${this._getUserHeader()}
`;
    }

    _getFavicon() {
      let el = document.querySelector("link[rel*='icon']");
      if (el) {
        return ``;
      } else {
        return "";
      }
    }

    _markParsingDuration() {
      this._parsing_duration = performance.now() - this._parse_start_time;
    }

    _getCurrentDataTime() {
      let current_date = new Date();
      const offset = -(new Date().getTimezoneOffset() / 60);
      const sign = offset > 0 ? "+" : "-";
      let date_time =
        current_date.getDate() +
        "/" +
        (current_date.getMonth() + 1) +
        "/" +
        current_date.getFullYear() +
        "@" +
        current_date.getHours() +
        ":" +
        current_date.getMinutes() +
        ":" +
        current_date.getSeconds() +
        " " +
        "UTC" +
        sign +
        offset +
        " (" +
        Intl.DateTimeFormat().resolvedOptions().timeZone +
        ")";
      return date_time;
    }

    // TODO: Ultimately we're going to need to know exactly which parts of the input
    //       box are obscured. This is partly possible using the element's computed
    //       styles, however this isn't comprehensive - think partially obscuring.
    //       So the best solution is to use the same trick as we do for normal text,
    //       except that we can't fill the input box with text, however we can
    //       temporarily change the background to a contrasting colour.
    _getAllInputBoxes() {
      let dom_rect, styles, font_rgb;
      let parsed_input_boxes = {};
      let raw_input_boxes = document.querySelectorAll(
        "input, " + "textarea, " + '[role="textbox"]'
      );
      raw_input_boxes.forEach((i) => {
        let type;
        this._ensureBrowshID(i);
        dom_rect = this._convertDOMRectToAbsoluteCoords(
          i.getBoundingClientRect()
        );
        const width = utils.snap(
          dom_rect.width * this.dimensions.scale_factor.width
        );
        const height = utils.snap(
          dom_rect.height * this.dimensions.scale_factor.height
        );
        if (width == 0 || height == 0) {
          return;
        }
        type =
          i.getAttribute("role") == "textbox"
            ? "textbox"
            : i.getAttribute("type");
        styles = window.getComputedStyle(i);
        font_rgb = styles["color"]
          .replace(/[^\d,]/g, "")
          .split(",")
          .map((i) => parseInt(i));
        const padding_top = parseInt(styles["padding-top"].replace("px", ""));
        const padding_left = parseInt(styles["padding-left"].replace("px", ""));
        if (this._isUnwantedInboxBox(i, styles)) {
          return;
        }
        parsed_input_boxes[i.getAttribute("data-browsh-id")] = {
          id: i.getAttribute("data-browsh-id"),
          x: utils.snap(
            (dom_rect.left + padding_left) * this.dimensions.scale_factor.width
          ),
          y: utils.snap(
            (dom_rect.top + padding_top) * this.dimensions.scale_factor.height
          ),
          width: width,
          height: height,
          tag_name: i.nodeName,
          type: type,
          colour: [font_rgb[0], font_rgb[1], font_rgb[2]],
        };
      });
      return parsed_input_boxes;
    }

    _ensureBrowshID(element) {
      if (element.getAttribute("data-browsh-id") === null) {
        element.setAttribute("data-browsh-id", utils.uuidv4());
      }
    }

    _isUnwantedInboxBox(input_box, styles) {
      return (
        styles.display === "none" ||
        styles.visibility === "hidden" ||
        input_box.getAttribute("aria-hidden") == "true"
      );
    }

    _sendRawText() {
      let body;
      if (this._raw_mode_type == "raw_text_dom") {
        body =
          "" +
          document.getElementsByTagName("html")[0].innerHTML +
          "";
      } else {
        body = this._serialiseRawText();
      }
      let payload = {
        body: body,
        page_load_duration: this.config.page_load_duration,
        parsing_duration: this._parsing_duration,
      };
      this.sendMessage(`/raw_text,${JSON.stringify(payload)}`);
    }

    _sendFrame() {
      this._serialiseFrame();
      if (this.frame.text.length > 0) {
        this.sendMessage(`/frame_text,${JSON.stringify(this.frame)}`);
      } else {
        this.log("Not sending empty text frame");
      }
    }

    _addCell(x, y, right) {
      let text = "";
      const index = y * this.dimensions.frame.width + x;
      this._cell_for_raw_text = this.tty_grid.cells[index];
      if (this._raw_mode_type === "raw_text_html") {
        this._is_line_end = x === right - 1;
        text += this._addCellAsHTML();
      } else {
        text += this._addCellAsPlainText();
      }
      return text;
    }

    _addCellAsHTML() {
      this._HTML = "";
      if (!this._cell_for_raw_text) {
        this._addHTMLForNonExistentCell();
      } else {
        this._current_cell_href = this._cell_for_raw_text.parent_element.href;
        this._is_HREF_changed =
          this._current_cell_href !== this._previous_cell_href;
        this._handleCellOutsideAnchor();
        this._handleCellInsideAnchor();
        this._HTML += this._cell_for_raw_text.rune;
        this._previous_cell_href = this._current_cell_href;
      }
      if (this._will_be_inside_anchor !== undefined) {
        this._is_inside_anchor = this._will_be_inside_anchor;
      }
      return this._HTML;
    }

    _addHTMLForNonExistentCell() {
      if (this._is_inside_anchor) {
        this._previous_cell_href = undefined;
        this._closeAnchorTag();
      }
      this._HTML += " ";
    }

    _handleCellOutsideAnchor() {
      if (this._is_inside_anchor) {
        return;
      }
      if (this._current_cell_href || this._is_HREF_changed) {
        this._openAnchorTag();
      }
    }

    _handleCellInsideAnchor() {
      if (!this._is_inside_anchor) {
        return;
      }
      if (
        this._is_HREF_changed ||
        !this._current_cell_href ||
        this._is_line_end
      ) {
        this._closeAnchorTag();
        if (this._current_cell_href) {
          this._openAnchorTag();
        }
      }
    }

    _openAnchorTag() {
      this._will_be_inside_anchor = true;
      this._HTML += ``;
    }

    _closeAnchorTag() {
      this._will_be_inside_anchor = false;
      this._HTML += ``;
    }

    _addCellAsPlainText() {
      if (this._cell_for_raw_text === undefined) {
        return " ";
      }
      return this._cell_for_raw_text.rune;
    }

    _setupFrameMeta() {
      this.frame = {
        meta: this.dimensions.getFrameMeta(),
        text: [],
        colours: [],
      };
      this.frame.meta.id = parseInt(this.channel.name);
    }

    _serialiseInputBoxes() {
      this.frame.input_boxes = this._getAllInputBoxes();
    }
  };


================================================
FILE: webext/src/dom/text_builder.js
================================================
import _ from "lodash";

import utils from "utils";
import CommonMixin from "dom/common_mixin";
import SerialiseMixin from "dom/serialise_mixin";
import TTYCell from "dom/tty_cell";
import TTYGrid from "dom/tty_grid";

// Convert the text on the page into a snapped 2-dimensional grid to be displayed directly
// in the terminal.
export default class extends utils.mixins(CommonMixin, SerialiseMixin) {
  constructor(channel, dimensions, graphics_builder, config) {
    super();
    this.channel = channel;
    this.dimensions = dimensions;
    this.graphics_builder = graphics_builder;
    this.config = config;
    this.tty_grid = new TTYGrid(dimensions, graphics_builder, config);
    this._parse_started_elements = [];
    // A `range` is the DOM's representation of elements and nodes as they are rendered in
    // the DOM. Think of the 'range' that is created when you select/highlight text for
    // copy-pasting, those usually blue-ish rectangles around the selected text are ranges.
    this._range = document.createRange();
  }

  sendFrame() {
    this.buildFormattedText(this._sendFrame.bind(this));
  }

  sendRawText(type) {
    this._raw_mode_type = type;
    this._parse_start_time = performance.now();
    if (type == "raw_text_dom") {
      setTimeout(() => {
        this._sendRawText();
      }, this.config["http-server"].render_delay);
    } else {
      this.buildFormattedText(this._sendRawText.bind(this));
    }
  }

  buildFormattedText(callback) {
    this._updateState();
    this.graphics_builder.getOnOffScreenshots(() => {
      this.dimensions.update();
      this._getTextNodes();
      this._positionTextNodes();
      callback();
    });
  }

  _updateState() {
    this.tty_grid.cells = [];
    this._parse_started_elements = [];
    this._previous_dom_box = {};
    this._convertSubFrameToViewportCoords();
  }

  // This is relatively cheap: around 50ms for a 13,000 word Wikipedia page
  _getTextNodes() {
    this.logPerformance(() => {
      this.__getTextNodes();
    }, "tree walker");
  }

  // This should be around ?? for a largish Wikipedia page of 13,000 words
  _positionTextNodes() {
    this.logPerformance(() => {
      this.__positionTextNodes();
    }, "position text nodes");
  }

  _serialiseFrame() {
    this.logPerformance(() => {
      this.__serialiseFrame();
    }, "serialise text frame");
  }

  // Search through every node in the DOM looking for displayable text.
  __getTextNodes() {
    this._text_nodes = [];
    const walker = document.createTreeWalker(
      document.body,
      NodeFilter.SHOW_TEXT,
      null,
      false
    );
    while (walker.nextNode()) {
      if (this._isRelevantTextNode(walker.currentNode)) {
        this._text_nodes.push(walker.currentNode);
      }
    }
  }

  // Does the node contain text that we want to parse?
  _isRelevantTextNode(node) {
    // Ignore text outside of the sub-frame, therefore outside either the TTY view or
    // outside the larger buffered TTY view.
    // Or ignore nodes with only whitespace
    const dom_rect = node.parentElement.getBoundingClientRect();

    return !(
      !this._isDOMRectInSubFrame(dom_rect) ||
      node.textContent.trim().length === 0
    );
  }

  // In order to decide if a particular DOM rect is inside the current sub frame then we need
  // to compare the sub frame's dimensions to those of the DOM rect. However DOM rects are in
  // viewport-relative coords. In order to save on some CPU cycles, we can just apply the
  // transform to the sub frame.
  _convertSubFrameToViewportCoords() {
    this._viewport_relative_sub_frame = {
      top: this.dimensions.dom.sub.top - window.scrollY,
      bottom:
        this.dimensions.dom.sub.top +
        this.dimensions.dom.sub.height -
        window.scrollY,
      left: this.dimensions.dom.sub.left - window.scrollX,
      right:
        this.dimensions.dom.sub.left +
        this.dimensions.dom.sub.width -
        window.scrollX,
    };
  }

  _isDOMRectInSubFrame(dom_rect) {
    const isBottomIn =
      dom_rect.bottom >= this._viewport_relative_sub_frame.top &&
      dom_rect.bottom <= this._viewport_relative_sub_frame.bottom;
    const isTopIn =
      dom_rect.top >= this._viewport_relative_sub_frame.top &&
      dom_rect.top <= this._viewport_relative_sub_frame.bottom;
    const isLeftIn =
      dom_rect.left >= this._viewport_relative_sub_frame.left &&
      dom_rect.left <= this._viewport_relative_sub_frame.right;
    const isRightIn =
      dom_rect.right >= this._viewport_relative_sub_frame.left &&
      dom_rect.right <= this._viewport_relative_sub_frame.right;
    return (isBottomIn || isTopIn) && (isLeftIn || isRightIn);
  }

  __positionTextNodes() {
    for (const node of this._text_nodes) {
      this._node = node;
      this._text = node.textContent;
      this._formatText();
      this._character_index = 0;
      this._positionSingleTextNode();
    }
  }

  _formatText() {
    this._normaliseWhitespace();
    this._fixJustifiedText();
  }

  // Justified text uses the space between words to stretch a line to perfectly fit from
  // end to end. That'd be ok if it only stretched by exact units of monospace width, but
  // it doesn't, which messes with our fragile grid system.
  // TODO:
  //   * It'd be nice to detect right-justified text so we can keep it. Just need to be
  //     careful with things like traversing parents up the DOM, or using `computedStyle()`
  //     because they can be expensive.
  //   * Another approach could be to explore how a global use of `pre` styling renders
  //     pages.
  //   * Also, is it possible and/or faster to do this once in the main style sheet? Or
  //     even by a find-replace on all occurrences of 'justify'?
  //   * Yet another thing, the style change doesn't actually get picked up until the
  //     next frame. Thus why the loop is independent of the `positionTextNodes()` loop.
  _fixJustifiedText() {
    if (this._node.parentElement) {
      this._node.parentElement.style.textAlign = "left";
    }
  }

  // The need for this wasn't immediately obvious to me. The fact is that the DOM stores
  // text nodes _as they are written in the HTML doc_. Therefore, if you've written some
  // nicely indented HTML, then the text node will actually contain those as something like
  //   `\n      text starts here`
  // It's just that the way CSS works most of the time means that whitespace is collapsed
  // so viewers never notice.
  //
  // TODO:
  //   The normalisation here of course destroys the formatting of `white-space: pre`
  //   styling, like code snippets for example. So hopefully we can detect the node's
  //   `white-space` setting and skip this function if necessary?
  _normaliseWhitespace() {
    // Unify all whitespace to a single space character
    this._text = this._text.replace(/[\t\n\r ]+/g, " ");
    if (this._isFirstParseInElement()) {
      // Remove whitespace at the beginning
      if (this._text.charAt(0) === " ") {
        this._text = this._text.substring(1, this._text.length);
      }
      // Remove whitespace at the end
      if (this._text.charAt(this._text.length - 1) === " ") {
        this._text = this._text.substring(0, this._text.length - 1);
      }
    }
  }

  // Knowing if a text node is the first within its parent element helps to decide
  // whether to remove its leading whitespace or not.
  //
  // An element may contain many text nodes. For example a `

` element may contain a // starting text node followed by a `` tag, finishing with another plain text node. We // only want to remove leading whitespace from the text at the _beginning_ of a line. // Usually we can do this just by checking if a DOM rectangle's position is further down // the page than the previous one - but of course there is nothing to compare the first // DOM rectangle to. What's more, DOM rects are grouped per _text node_, NOT per element // and we are not guaranteed to iterate through elements in the order that text flows. // Therefore we need to make the assumption that plain text nodes flow within their shared // parent element. There is a possible caveat here for elements starting with another // element (like a link), where that sub-element contains leading whitespace. _isFirstParseInElement() { let element = this._node.parentElement; const is_parse_started = _.includes(this._parse_started_elements, element); if (is_parse_started) { return false; } else { this._parse_started_elements.push(element); return true; } } // Here is where we actually make use of the rather strict monospaced and fixed font size // CSS rules enforced by the webextension. Of course the CSS is never going to be able to // perfectly snap characters onto a grid, so we force it here instead. At least we can be // fairly certain that every character at least takes up the same space as a TTY cell, it // just might not be perfectly aligned. So here we just round down all coordinates to force // the snapping. // // Use `this.addClientRectsOverlay(dom_rects, text);` to see DOM rectangle outlines in a // real browser. _positionSingleTextNode() { this._dom_box = {}; for (const dom_box of this._getNodeDOMBoxes()) { if (!this._isDOMRectInSubFrame(dom_box)) { continue; } this._dom_box.top = dom_box.top; this._dom_box.left = dom_box.left; this._dom_box.width = dom_box.width; this._handleSingleDOMBox(); this._previous_dom_box = _.clone(this._dom_box); } } // This is the key to being able to display formatted text within the strict confines // of a TTY. DOM Rectangles are closely related to selection ranges (like when you click // and drag the mouse cursor over text). Think of an individual DOM rectangle as a single // bar of highlighted selection. So that, for example, a 3 line paragraph will have 3 // DOM rectangles. Fortunately DOMRect coordinates and dimensions are precisely defined. // Although do note that, unlike selection ranges, sub-selections can appear seemingly // inside other selections for things like italics or anchor tags. _getNodeDOMBoxes() { let rects = []; // TODO: selectNode() hangs if it can't find a node in the DOM // Node.isConnected() might be faster // It's possible that the node has dissapeared since nodes were collected. if (document.body.contains(this._node)) { this._range.selectNode(this._node); rects = this._range.getClientRects(); } return rects; } // A single box is always a valid rectangle. Therefore a single box will, for example, // never straddle 2 lines as there is no guarantee that a valid rectangle can be formed. // We can use this to our advantage by stepping through coordinates of a box to get the // exact position of every single individual character. We just have to understand and // follow exactly how the DOM flows text - easier said than done. _handleSingleDOMBox() { this._prepareToParseDOMBox(); for (let step = 0; step < this._tty_box.width; step++) { this._handleSingleCharacter(); this._stepToNextCharacter(); } } _prepareToParseDOMBox() { this._dom_box = this._convertDOMRectToAbsoluteCoords(this._dom_box); this._createSyncedTTYBox(); this._createTrackers(); this._setCurrentCharacter(); this._ignoreUnrenderedWhitespace(); } // Note that it's possible for this._text to straddle many DOM boxes _setCurrentCharacter() { this._current_character = this._text.charAt(this._character_index); } // Everything hinges on these 2 trackers being in sync. The DOM tracker is defined by // actual pixel coordinates and we move horizontally, from left to right, each step // being the width of a single character. The TTY tracker moves in the same way except // each step is a new single cell within the TTY. _createTrackers() { this._dom_tracker = { x: this._dom_box.left, y: this._dom_box.top, }; this._tty_tracker = { x: this._tty_box.col_start, y: this._tty_box.row, }; } _handleSingleCharacter() { let cell = new TTYCell(); cell.rune = this._current_character; cell.tty_coords = _.clone(this._tty_tracker); cell.dom_coords = _.clone(this._dom_tracker); cell.parent_element = this._node.parentElement; this.tty_grid.addCell(cell); } _stepToNextCharacter(tracked = true) { this._character_index++; this._setCurrentCharacter(); if (tracked) { this._dom_tracker.x += this.dimensions.char.width; this._tty_tracker.x++; } } // There is a careful tracking between the currently parsed character of `this._text` // and the position of the current 'cell' space within `this._dom_box`. So we must be precise // in how we synchronise them. This requires following the DOM's method for wrapping text. // Recall how the DOM will split a line at a space character boundry. That space character // is then in fact never rendered - its existence is never registered within the dimensions // of a DOM rectangle's box (`this._dom_box`). _ignoreUnrenderedWhitespace() { if (this._isNewLine() && this._current_character.trim().length == 0) { this._stepToNextCharacter(false); } } // Is the current DOM rectangle further down the page than the previous? _isNewLine() { if (Object.keys(this._previous_dom_box).length === 0) return false; return this._dom_box.top > this._previous_dom_box.top; } // The DOM returns box coordinates relative to the viewport. As we are rendering the // entire DOM as a single frame, then we need the coords to be relative to the top-left // of the DOM itself. _convertDOMRectToAbsoluteCoords(dom_rect) { return { top: dom_rect.top + window.scrollY, bottom: dom_rect.bottom + window.scrollY, left: dom_rect.left + window.scrollX, right: dom_rect.right + window.scrollX, height: dom_rect.height, width: dom_rect.width, }; } // Round and snap a DOM rectangle as if it were placed in the TTY frame _createSyncedTTYBox() { this._tty_box = { col_start: utils.snap( this._dom_box.left * this.dimensions.scale_factor.width ), row: utils.snap( (this._dom_box.top * this.dimensions.scale_factor.height) / 2 ), width: utils.snap( this._dom_box.width * this.dimensions.scale_factor.width ), }; } // Purely for debugging. // // Draws a red border around all the DOMClientRect nodes. // Based on code from the MDN docs site. _addClientRectsOverlay(dom_rects, normalised_text) { // Don't draw on every frame if (this.is_first_frame_finished) return; // Absolutely position a div over each client rect so that its border width // is the same as the rectangle's width. // Note: the overlays will be out of place if the user resizes or zooms. for (const rect of dom_rects) { let tableRectDiv = document.createElement("div"); // A DOMClientRect object only contains dimensions, so there's no way to identify it // to a node, so let's put its text as an attribute so we can cross-check if needs be. tableRectDiv.setAttribute("browsh-text", normalised_text); let tty_row = parseInt( Math.round(rect.top / this.dimemnsions.char.height) ); tableRectDiv.setAttribute("tty_row", tty_row); tableRectDiv.style.position = "absolute"; tableRectDiv.style.border = "1px solid red"; tableRectDiv.style.margin = tableRectDiv.style.padding = "0"; tableRectDiv.style.top = rect.top + "px"; tableRectDiv.style.left = rect.left + "px"; // We want rect.width to be the border width, so content width is 2px less. tableRectDiv.style.width = rect.width - 2 + "px"; tableRectDiv.style.height = rect.height - 2 + "px"; document.body.appendChild(tableRectDiv); } } } ================================================ FILE: webext/src/dom/tty_cell.js ================================================ // A single cell on the TTY grid export default class { // When a character clobbers another character in the grid, we can't use our // text show/hide trick to know if the character is visible in the final DOM. So we have // to use standard CSS inspection instead. Hopefully this doesn't happen often because // it's expensive. // TODO: Make comprehensive isHighestLayer() { const found_element = document.elementFromPoint( this.dom_coords.x, this.dom_coords.y ); return this.parent_element == found_element; } } ================================================ FILE: webext/src/dom/tty_grid.js ================================================ import utils from "utils"; // The TTY grid export default class { constructor(dimensions, graphics_builder, config) { this.dimensions = dimensions; this.graphics_builder = graphics_builder; this.config = config; this._setMiddleOfEm(); } getCell(index) { return this.cells[index]; } getCellAt(x, y) { return this.cells[y * this.dimensions.frame.width + x]; } addCell(new_cell) { new_cell.index = this._calculateIndex(new_cell); const is_cell_possibly_obscured = !this._handleCellVisibility(new_cell); const is_cell_at_highest_layer = this._isNewCellAtHighestLayer(new_cell); if (is_cell_at_highest_layer && !is_cell_possibly_obscured) { this.cells[new_cell.index] = new_cell; } } _isNewCellAtHighestLayer(new_cell) { let existing_cell = this.cells[new_cell.index]; return !( existing_cell !== undefined && !new_cell.isHighestLayer(existing_cell) ); } _handleCellVisibility(new_cell) { const colours = this._getColours(new_cell); if (!colours) return false; if (this._isCharObscured(colours)) return false; new_cell.fg_colour = colours[0]; new_cell.bg_colour = colours[1]; return true; } _calculateIndex(cell) { return cell.tty_coords.y * this.dimensions.frame.width + cell.tty_coords.x; } // Get the colours right in the middle of the character's font. Returns both the colour // when the text is displayed and when it's hidden. _getColours(cell) { const offset_x = utils.snap( cell.dom_coords.x + this.dimensions.char.width * this._middle_of_em ); const offset_y = utils.snap( cell.dom_coords.y + this.dimensions.char.height * this._middle_of_em ); const fg_colour = this.graphics_builder.getUnscaledFGPixelAt( offset_x, offset_y ); const bg_colour = this.graphics_builder.getUnscaledBGPixelAt( offset_x, offset_y ); return [fg_colour, bg_colour]; } // This is the value to reach the middle of a uni-glyph font character in order to // sample its colour. Obviosuly it is better to reach for the middle in case there are // vagaries of rendering, it increases our chances of actually getting the characters // own colour and not some other colour nearby. // // However during testing, we use very small self-generated pixel arrays which makes // the snapped values rather unintuitive. So we just encourage the snaped values to // snap lower which just lends itself to more readable test values. _setMiddleOfEm() { this._middle_of_em = TEST ? 0.49 : 0.5; } // This is somewhat of a, hopefully elegant, hack. So, imagine that situation where you're // browsing a web page and a popup appears; perhaps just a select box, or menu, or worst // of all a dreaded full-page overlay. Now, DOM rectangles don't take into account whether // they are the uppermost visible element, so we're left in a bit of a pickle. The only JS // way to know if an element is visible is to use `Document.elementFromPoint(x, y)`, where // you compare the returned element with the element whose visibility you're checking. // This is has a number of problems. Firstly, it only checks one coordinate in the element // for visibility, which of course isn't going to 100% reliably speak for all the // characters in the element. Secondly, even ignoring the first caveat, running // `elementFromPoint()` for every character is very expensive, around 25ms for an average // DOM. So it's basically a no-go. So instead we take advantage of the fact that we're // working with a snapshot of the the webpage's pixels. It's pretty good assumption that if // you make the text transparent and a pixel's colour doesn't change then that character // must be obscured by something. // // There are of course some potential edge cases with this. What if we get a false // positive, where a character is obscured _by another character_? Hopefully in such a // case we can work with `z-index` so that characters justifiably overwrite each other in // the TTY grid. _isCharObscured(colours) { if (!this.config.browsh.use_experimental_text_visibility) { return false; } return ( colours[0][0] === colours[1][0] && colours[0][1] === colours[1][1] && colours[0][2] === colours[1][2] ); } } ================================================ FILE: webext/src/utils.js ================================================ export default { mixins: function (...mixins) { return mixins.reduce((base, mixin) => { return mixin(base); }, class {}); }, ttyCell: function ( fg_colour = [255, 255, 255], bg_colour = [0, 0, 0], character ) { let cell = fg_colour.concat(bg_colour); cell.push(character); return cell; }, ttyPlainCell: function (character) { return this.ttyCell(null, null, character); }, snap: function (number) { return parseInt(Math.round(number)); }, ensureEven: function (number) { number = this.snap(number); if (number % 2) { number++; } return number; }, rebuildArgsToSingleArg: function (args) { return args.slice(1).join(","); }, uuidv4: function () { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace( /[xy]/g, function (c) { var r = (Math.random() * 16) | 0, v = c == "x" ? r : (r & 0x3) | 0x8; return v.toString(16); } ); }, }; ================================================ FILE: webext/test/fixtures/canvas_pixels.js ================================================ // Generate fake pixel data, as if sreenshotting a canvas element. // // The RGB channels are repurposed to indicate the function they were created by // and their position in their 1 dimensionsal array structure. // // If a [R, G, B] contains a non-zero value the following applies; // R: The pixel's colour is due to text and was generated by withText() // G: The pixel's colour is due to background and was generated by withText() // B: The pixel's colour is due to background and was generated by withoutText() // // * The values express the pixel's position in the array. // * G and B should always have the same values, but should never appear in the same // [R, G, B] array. export default class CanvasPixels { constructor(dimensions) { this.width = dimensions.dom.sub.width; this.height = dimensions.dom.sub.height; this.pixel_count = this.width * this.height * 4; } // Putting the index in the red channel indicates text. An index value in the green // channel indicates that this is the withText() function yet the colour is being // picked up from the background (as if it were the withoutText() function). with_text() { let x, y, index; let pixels = []; for (let i = 0; i < this.pixel_count; i += 4) { x = (i / 4) % this.width; y = Math.floor(i / 2 / 4 / this.width); index = this._getIndexValue(i); if (this._checkForCharacter(x, y)) { pixels.push(index); pixels.push(0); } else { pixels.push(0); pixels.push(index); } pixels.push(0); pixels.push(0); } return { data: pixels }; } _getIndexValue(i) { // Add 1 to distinguish the zero value. Because zero values mean that nothing was // found. return i / 4 + 1; } _checkForCharacter(x, y) { const char = global.mock_DOM_text[y].charAt(x); const mask_char = global.mock_DOM_template[y].charAt(x); const isCharThere = !this._isNullOrWhiteSpace(char); const isMaskThere = mask_char === "!"; return isCharThere && !isMaskThere; } _isNullOrWhiteSpace(str) { return !str || str.length === 0 || /^\s*$/.test(str); } // Using the blue channel indicates that this sample was taken from the withoutText() // function. without_text() { let pixels = []; for (let i = 0; i < this.pixel_count; i += 4) { pixels.push(0); pixels.push(0); pixels.push(this._getIndexValue(i)); pixels.push(0); } return { data: pixels }; } scaled() { return this.without_text(); } } ================================================ FILE: webext/test/fixtures/text_nodes.js ================================================ // Create DOM-compatible DOM Rectangles from a simple array of strings export default class TextNodes { constructor() { this.offset = 0.0; this.char_width = global.dimensions.char.width; this.char_height = global.dimensions.char.height; this.total_width = global.mock_DOM_template[0].length * this.char_width; this.total_height = global.mock_DOM_template.length * this.char_height; this.dom_rects = []; } build() { for (let line of global.mock_DOM_text) { this.addDomRect(line); } return [ { textContent: global.mock_DOM_text.join(""), parentElement: { style: {}, }, bounding_box: this.boundingBox(), dom_rects: this.dom_rects, }, ]; } boundingBox() { return { top: this.offset, bottom: this.total_height + this.offset, left: this.offset, right: this.total_width + this.offset, width: this.total_width, height: this.total_height, }; } addDomRect(line) { const width = line.length * this.char_width; const height = this.char_height; const top = this.dom_rects.length * this.char_height + this.offset; this.dom_rects.push({ top: top, bottom: top + height, left: this.offset, right: width + this.offset, width: width, height: height, }); } } ================================================ FILE: webext/test/graphics_builder_spec.js ================================================ import helper from "helper"; import { expect } from "chai"; describe("Graphics Builder", () => { let graphics_builder; describe("Non-offsetted frames", () => { beforeEach(() => { global.mock_DOM_template = [" ", " "]; global.frame_type = "small"; global.tty = { width: 4, height: 2, x_scroll: 0, y_scroll: 0, }; graphics_builder = helper.runGraphicsBuilder(); }); it("should serialise a scaled frame", () => { const colours = graphics_builder.frame.colours; expect(colours.length).to.equal(48); expect(colours[0]).to.equal(0); expect(colours[2]).to.equal(1); expect(colours[46]).to.equal(0); expect(colours[47]).to.equal(16); }); it("should populate the frame's meta", () => { const meta = graphics_builder.frame.meta; expect(meta).to.deep.equal({ sub_left: 0, sub_top: 0, sub_width: 4, sub_height: 4, total_width: 4, total_height: 4, id: 1, }); }); }); describe("Offset frames", () => { beforeEach(() => { global.tty = { width: 2, height: 2, x_scroll: 2, y_scroll: 1, }; global.frame_type = "small"; global.mock_DOM_template = [" ", " ", " ", " "]; graphics_builder = helper.runGraphicsBuilder(); }); it("should serialise a scaled frame", () => { const colours = graphics_builder.frame.colours; expect(colours.length).to.equal(24); expect(colours[0]).to.equal(0); expect(colours[2]).to.equal(1); expect(colours[22]).to.equal(0); expect(colours[23]).to.equal(8); }); it("should populate the frame's meta", () => { const meta = graphics_builder.frame.meta; expect(meta).to.deep.equal({ sub_left: 2, sub_top: 1, sub_width: 2, sub_height: 4, total_width: 4, total_height: 8, id: 1, }); }); }); }); ================================================ FILE: webext/test/helper.js ================================================ import sinon from "sinon"; import Dimensions from "dom/dimensions"; import GraphicsBuilder from "dom/graphics_builder"; import TextBuilder from "dom/text_builder"; import TTYCell from "dom/tty_cell"; import MockRange from "mocks/range"; import TextNodes from "fixtures/text_nodes"; import CanvasPixels from "fixtures/canvas_pixels"; var sandbox = sinon.createSandbox(); let getPixelsStub; let channel = { name: 1 }; beforeEach(() => { sandbox .stub(Dimensions.prototype, "_getOrCreateMeasuringBox") .returns(element); sandbox.stub(Dimensions.prototype, "sendMessage").returns(true); sandbox.stub(GraphicsBuilder.prototype, "hideText").returns(true); sandbox.stub(GraphicsBuilder.prototype, "showText").returns(true); sandbox.stub(GraphicsBuilder.prototype, "_scaleCanvas").returns(true); sandbox.stub(GraphicsBuilder.prototype, "_unScaleCanvas").returns(true); sandbox.stub(TextBuilder.prototype, "_getAllInputBoxes").returns([]); sandbox.stub(TTYCell.prototype, "isHighestLayer").returns(true); getPixelsStub = sandbox.stub(GraphicsBuilder.prototype, "_getPixelData"); }); afterEach(() => { sandbox.restore(); }); global.dimensions = { char: { width: 1, height: 2, }, }; global.document = { addEventListener: () => {}, body: { contains: () => { return true; }, }, getElementById: () => {}, getElementsByTagName: () => { return [ { innerHTML: "Google", }, ]; }, createRange: () => { return new MockRange(); }, createElement: () => { return { getContext: () => {}, }; }, documentElement: { scrollWidth: null, scrollHeight: null, }, location: { href: "https://www.google.com", }, scrollX: 0, scrollY: 0, innerWidth: null, innerHeight: null, }; global.DEVELOPMENT = false; global.PRODUCTION = false; global.TEST = true; global.window = global.document; global.performance = { now: () => {}, }; let element = { getBoundingClientRect: () => { return { width: global.dimensions.char.width, height: global.dimensions.char.height, }; }, }; function _setupMockDOMSize() { const width = global.mock_DOM_template[0].length; const height = global.mock_DOM_template.length * 2; global.document.documentElement.scrollWidth = width; global.document.documentElement.scrollHeight = height; global.document.innerWidth = width; global.document.innerHeight = height; } function _setupDimensions() { let dimensions = new Dimensions(); _setupMockDOMSize(); dimensions.tty.width = global.tty.width; dimensions.tty.height = global.tty.height; dimensions.frame.x_scroll = global.tty.x_scroll; dimensions.frame.y_scroll = global.tty.y_scroll; dimensions.update(); dimensions.setSubFrameDimensions(global.frame_type); return dimensions; } function _setupGraphicsBuilder(type) { let dimensions = _setupDimensions(); let canvas_pixels = new CanvasPixels(dimensions); if (type === "with_text") { getPixelsStub.onCall(0).returns(canvas_pixels.with_text()); getPixelsStub.onCall(1).returns(canvas_pixels.without_text()); getPixelsStub.onCall(2).returns(canvas_pixels.scaled()); } else { getPixelsStub.onCall(0).returns(canvas_pixels.scaled()); } let config = { "http-server": { "jpeg-compression": 0.9, render_delay: 0, }, }; let graphics_builder = new GraphicsBuilder(channel, dimensions, config); return graphics_builder; } let functions = { runTextBuilder: (callback) => { let text_nodes = new TextNodes(); let graphics_builder = _setupGraphicsBuilder("with_text"); let text_builder = new TextBuilder( channel, graphics_builder.dimensions, graphics_builder, { browsh: { use_experimental_text_visibility: true, }, } ); graphics_builder._getScreenshotWithText(() => { graphics_builder._getScreenshotWithoutText(); graphics_builder.__getScaledScreenshot(); text_builder._text_nodes = text_nodes.build(); text_builder._updateState(); text_builder._positionTextNodes(); callback(text_builder); }); }, runGraphicsBuilder: () => { let graphics_builder = _setupGraphicsBuilder(); graphics_builder.__getScaledScreenshot(); graphics_builder._serialiseFrame(); return graphics_builder; }, }; export default functions; ================================================ FILE: webext/test/mocks/range.js ================================================ export default class MockRange { selectNode(node) { this.node = node; } getBoundingClientRect() { return this.node.bounding_box; } getClientRects() { return this.node.dom_rects; } } ================================================ FILE: webext/test/text_builder_spec.js ================================================ import { expect } from "chai"; import helper from "helper"; let text_builder, grid; describe("Text Builder", () => { beforeEach((done) => { global.mock_DOM_template = [ " ", " ", " ", " ", " ", " !!! ", " !!! ", ]; // We can't simulate anything that uses groups of spaces, as TextBuilder collapses all spaces // to a single space in order to sync with how the DOM renders monospaced text. // // TODO: That being said, I can surely imagine that multiple spaces within a single DOM rect // would not be collapsed by the DOM, so maybe that's something to take into account for // the TextBuilder code? global.mock_DOM_text = [ "Testing nodes. ", "Max 15 chars ", "wide. ", "Diff kinds of ", "Whitespace. ", "Also we need to ", "test subframes.", ]; global.tty = { width: 5, height: 3, x_scroll: 0, y_scroll: 0, }; global.frame_type = "small"; helper.runTextBuilder((returned_text_builder) => { text_builder = returned_text_builder; grid = text_builder.tty_grid.cells; done(); }); }); it("should convert text nodes to a grid of cell objects", () => { expect(grid.length).to.equal(37); expect(grid[0]).to.deep.equal({ index: 0, rune: "T", fg_colour: [6, 0, 0], bg_colour: [0, 0, 6], parent_element: { style: { textAlign: "left", }, }, tty_coords: { x: 0, y: 0, }, dom_coords: { x: 0, y: 0, }, }); expect(grid[5]).to.equal(undefined); expect(grid[16]).to.deep.equal({ index: 16, rune: "M", fg_colour: [16, 0, 0], bg_colour: [0, 0, 16], parent_element: { style: { textAlign: "left", }, }, tty_coords: { x: 0, y: 1, }, dom_coords: { x: 0, y: 2, }, }); expect(grid[36]).to.deep.equal({ index: 36, rune: ".", fg_colour: [30, 0, 0], bg_colour: [0, 0, 30], parent_element: { style: { textAlign: "left", }, }, tty_coords: { x: 4, y: 2, }, dom_coords: { x: 4, y: 4, }, }); expect(grid[37]).to.equal(undefined); }); it("should not detect the colour of whitespace characters", () => { expect(grid[19].rune).to.equal(" "); expect(grid[19].fg_colour).to.deep.equal([0, 19, 0]); }); it("should serialise a frame", () => { text_builder._serialiseFrame(); expect(text_builder.frame.meta).to.deep.equal({ sub_left: 0, sub_top: 0, sub_width: 5, sub_height: 6, total_width: 16, total_height: 14, id: 1, }); expect(text_builder.frame.text).to.deep.equal([ "T", "e", "s", "t", "i", "M", "a", "x", " ", "1", "w", "i", "d", "e", ".", ]); expect(text_builder.frame.colours).to.deep.equal([ 6, 0, 0, 7, 0, 0, 8, 0, 0, 9, 0, 0, 10, 0, 0, 16, 0, 0, 17, 0, 0, 18, 0, 0, 0, 19, 0, 20, 0, 0, 26, 0, 0, 27, 0, 0, 28, 0, 0, 29, 0, 0, 30, 0, 0, ]); }); }); ================================================ FILE: webext/webpack.config.js ================================================ import webpack from 'webpack'; import path from 'path'; import CopyWebpackPlugin from 'copy-webpack-plugin'; import fs from 'fs'; const dirname = process.cwd(); export default { mode: process.env['BROWSH_ENV'] === 'RELEASE' ? 'production' : 'development', target: 'node', entry: { content: './content.js', background: './background.js' }, output: { path: dirname, filename: 'dist/[name].js', }, resolve: { modules: [ path.resolve(dirname, './src'), 'node_modules' ], }, module: { rules: [ { test: /\.m?js/, resolve: { fullySpecified: false, }, }, ] }, devtool: 'source-map', plugins: [ new webpack.DefinePlugin({ DEVELOPMENT: JSON.stringify(true), TEST: JSON.stringify(false), // TODO: For production use a different webpack.config.js PRODUCTION: JSON.stringify(false) }), new CopyWebpackPlugin({ patterns: [ { from: 'assets', to: 'dist/assets' }, { from: '.web-extension-id', to: 'dist/' }, { from: 'manifest.json', to: 'dist/', // Inject the current Browsh version into the manifest JSON transform(manifest, _) { const version_path = '../interfacer/src/browsh/version.go'; let buffer = fs.readFileSync(version_path); let version_contents = buffer.toString(); const matches = version_contents.match(/"(.*?)"/); return manifest.toString().replace('BROWSH_VERSION', matches[1]); } }, ] }) ] }