Full Code of browsh-org/browsh for AI

master 499ef386d45c cached
88 files
297.9 KB
87.2k tokens
507 symbols
1 requests
Download .txt
Showing preview only (320K chars total). Download the full file or copy to clipboard to get everything.
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 <tom@tombh.co.uk>
    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("<a href"))
	})

	It("should return HTML text", func() {
		response := getPath("/smorgasbord", "html")
		Expect(response).To(ContainSubstring(
			"<a href=\"/http://localhost:4444/smorgasbord/another.html\">"))
	})

	It("should return the DOM", func() {
		response := getPath("/smorgasbord", "dom")
		Expect(response).To(ContainSubstring(
			"<div class=\"big_middle\">"))
	})

	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
================================================
<html>
  <head>
    <meta charset="utf-8">
    <title>Another</title>
  </head>
  <body>
    Another webpage
  </body>
</html>



================================================
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
================================================
<html>
  <head>
    <meta charset="utf-8">
    <title>Smörgåsbord</title>
    <link href="css/main.css" rel="stylesheet" type="text/css" />
    <link href="css/spinner.css" rel="stylesheet" type="text/css" />
    <script type="text/javascript">
      window.onload = function(e){
        document.getElementById("test_form").addEventListener("submit", function(e){
          e.preventDefault();
          let text = document.getElementById("test_form_input").value;
          text = text.split("").reverse().join("");
          document.getElementById("form_result").textContent = text;
        });
      }
    </script>
  </head>
  <body>
    <div id="content">
      <div id="spinner"></div>
      <h1>Smörgåsbord</h1>
      <div class="left_col">
        Smörgåsbord (Swedish: [ˈsmœrɡɔsˌbuːɖ]) is a type of Scandinavian meal,
        originating in Sweden, served buffet-style with multiple hot and cold
        dishes of various foods on a table.
        <p>
          <a href="another.html">Another page</a>
        </p>
        <p>
          <form id="test_form">
            <input type="text" id="test_form_input" size="15">
            <input type="submit">
          </form>
        </p>
        <p id="form_result">Unsubmitted</p>
      </div>
      <div class="right_col">
        The <a href="/">Swedish</a> 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.
      </div>

      <div class="big_middle">
        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.
      </div>
    </div>
  </body>
</html>


================================================
FILE: interfacer/test/sites/smorgasbord/textarea.html
================================================
<html>
  <head>
    <meta charset="utf-8">
    <title>Another</title>
  </head>
  <body>
    <textarea rows="3"></textarea>
  </body>
</html>



================================================
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": [
    "<all_urls>",
    "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",
    
Download .txt
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
Download .txt
SYMBOL INDEX (507 symbols across 47 files)

FILE: interfacer/cmd/browsh/main.go
  function main (line 5) | func main() {

FILE: interfacer/src/browsh/browsh.go
  function setupLogging (line 47) | func setupLogging() {
  function Initialise (line 63) | func Initialise() {
  function Shutdown (line 72) | func Shutdown(err error) {
  function Log (line 90) | func Log(message string) {
  function saveScreenshot (line 93) | func saveScreenshot(base64String string) {
  function Shell (line 118) | func Shell(command string) string {
  function TTYStart (line 136) | func TTYStart(injectedScreen tcell.Screen) {
  function toInt (line 152) | func toInt(char string) int {
  function toInt32 (line 160) | func toInt32(char string) int32 {
  function ttyEntry (line 168) | func ttyEntry() {
  function MainEntry (line 185) | func MainEntry() {

FILE: interfacer/src/browsh/cells.go
  type cell (line 12) | type cell struct
  type threadSafeCellsMap (line 20) | type threadSafeCellsMap struct
    method load (line 31) | func (m *threadSafeCellsMap) load(key int) (value cell, ok bool) {
    method store (line 38) | func (m *threadSafeCellsMap) store(key int, value cell) {
  function newCellsMap (line 25) | func newCellsMap() *threadSafeCellsMap {

FILE: interfacer/src/browsh/comms.go
  type incomingRawText (line 25) | type incomingRawText struct
  function startWebSocketServer (line 30) | func startWebSocketServer() {
  function sendMessageToWebExtension (line 40) | func sendMessageToWebExtension(message string) {
  function webSocketReader (line 50) | func webSocketReader(ws *websocket.Conn) {
  function handleWebextensionCommand (line 71) | func handleWebextensionCommand(message []byte) {
  function handleRawFrameTextCommands (line 97) | func handleRawFrameTextCommands(parts []string) {
  function triggerSocketWriterClose (line 123) | func triggerSocketWriterClose() {
  function webSocketWriter (line 128) | func webSocketWriter(ws *websocket.Conn) {
  function webSocketServer (line 145) | func webSocketServer(w http.ResponseWriter, r *http.Request) {
  function sendConfigToWebExtension (line 173) | func sendConfigToWebExtension() {

FILE: interfacer/src/browsh/config.go
  function getConfigNamespace (line 31) | func getConfigNamespace() string {
  function getConfigDir (line 39) | func getConfigDir() string {
  function ensureConfigFile (line 52) | func ensureConfigFile(path string) {
  function getFirefoxProfilePath (line 68) | func getFirefoxProfilePath() string {
  function setDefaults (line 75) | func setDefaults() {
  function loadConfig (line 80) | func loadConfig() {

FILE: interfacer/src/browsh/firefox.go
  function startHeadlessFirefox (line 62) | func startHeadlessFirefox() {
  function checkIfFirefoxIsAlreadyRunning (line 95) | func checkIfFirefoxIsAlreadyRunning() {
  function ensureFirefoxBinary (line 106) | func ensureFirefoxBinary() string {
  function versionOrdinal (line 129) | func versionOrdinal(version string) string {
  function startWERFirefox (line 161) | func startWERFirefox() {
  function firefoxMarionette (line 207) | func firefoxMarionette() {
  function installWebextension (line 237) | func installWebextension() {
  function setFFPreference (line 252) | func setFFPreference(key string, value string) {
  function readMarionette (line 267) | func readMarionette() {
  function sendFirefoxCommand (line 277) | func sendFirefoxCommand(command string, args map[string]interface{}) {
  function setDefaultFirefoxPreferences (line 287) | func setDefaultFirefoxPreferences() {
  function beginTimeLimit (line 297) | func beginTimeLimit() {
  function setupFirefox (line 308) | func setupFirefox() {
  function StartFirefox (line 324) | func StartFirefox() {
  function quitFirefox (line 340) | func quitFirefox() {

FILE: interfacer/src/browsh/firefox_unix.go
  function getFirefoxPath (line 11) | func getFirefoxPath() string {
  function ensureFirefoxVersion (line 15) | func ensureFirefoxVersion(path string) {

FILE: interfacer/src/browsh/firefox_windows.go
  function getFirefoxPath (line 14) | func getFirefoxPath() string {
  function getWindowsFirefoxVersionString (line 35) | func getWindowsFirefoxVersionString() string {
  function getFirefoxFlavor (line 57) | func getFirefoxFlavor() string {
  function ensureFirefoxVersion (line 99) | func ensureFirefoxVersion(path string) {

FILE: interfacer/src/browsh/frame_builder.go
  type frame (line 14) | type frame struct
    method domRowCount (line 67) | func (f *frame) domRowCount() int {
    method subRowCount (line 71) | func (f *frame) subRowCount() int {
    method buildFrameText (line 90) | func (f *frame) buildFrameText(incoming incomingFrameText) {
    method buildFramePixels (line 115) | func (f *frame) buildFramePixels(incoming incomingFramePixels) {
    method setup (line 123) | func (f *frame) setup(meta jsonFrameBase) {
    method resetCells (line 139) | func (f *frame) resetCells() {
    method isIncomingFrameTextValid (line 143) | func (f *frame) isIncomingFrameTextValid(incoming incomingFrameText) b...
    method updateInputBoxes (line 152) | func (f *frame) updateInputBoxes(incoming incomingFrameText) {
    method populateFrameText (line 175) | func (f *frame) populateFrameText(incoming incomingFrameText) {
    method populateFramePixels (line 197) | func (f *frame) populateFramePixels(incoming incomingFramePixels) {
    method isIncomingFramePixelsValid (line 228) | func (f *frame) isIncomingFramePixelsValid(incoming incomingFramePixel...
    method buildCell (line 241) | func (f *frame) buildCell(x int, y int) {
    method getCharacterAt (line 252) | func (f *frame) getCharacterAt(index int) ([]rune, tcell.Color) {
    method getPixelColoursAt (line 265) | func (f *frame) getPixelColoursAt(index int) (tcell.Color, tcell.Color) {
    method addCell (line 281) | func (f *frame) addCell(index int, fgColour, bgColour tcell.Color, cha...
    method getCellIndexFromSubCoords (line 294) | func (f *frame) getCellIndexFromSubCoords(x, y int) int {
    method limitScroll (line 299) | func (f *frame) limitScroll(height int) {
    method maybeFocusInputBox (line 309) | func (f *frame) maybeFocusInputBox(x, y int) {
    method overlayInputBoxContent (line 325) | func (f *frame) overlayInputBoxContent() {
  type jsonFrameBase (line 44) | type jsonFrameBase struct
  type incomingFrameText (line 54) | type incomingFrameText struct
  type incomingFramePixels (line 62) | type incomingFramePixels struct
  function parseJSONFrameText (line 75) | func parseJSONFrameText(jsonString string) {
  function parseJSONFramePixels (line 99) | func parseJSONFramePixels(jsonString string) {
  function isCharacterTransparent (line 277) | func isCharacterTransparent(character []rune) bool {

FILE: interfacer/src/browsh/frame_builder_test.go
  function TestFrameBuilder (line 11) | func TestFrameBuilder(t *testing.T) {
  function testGetCell (line 17) | func testGetCell(index int) cell {
  function testGetCellChar (line 22) | func testGetCellChar(index int) string {
  function debugCells (line 27) | func debugCells() {

FILE: interfacer/src/browsh/input_box.go
  type inputBox (line 20) | type inputBox struct
    method renderURLBox (line 52) | func (i *inputBox) renderURLBox() {
    method setCells (line 67) | func (i *inputBox) setCells() {
    method resetCells (line 99) | func (i *inputBox) resetCells() {
    method addCharacterToFrame (line 107) | func (i *inputBox) addCharacterToFrame(x int, y int, c rune) {
    method isMultiLine (line 131) | func (i *inputBox) isMultiLine() bool {
    method textToDisplay (line 138) | func (i *inputBox) textToDisplay() []rune {
    method textToDisplayForSingleLine (line 145) | func (i *inputBox) textToDisplayForSingleLine() []rune {
    method lineCount (line 160) | func (i *inputBox) lineCount() int {
    method sendInputBoxToBrowser (line 168) | func (i *inputBox) sendInputBoxToBrowser() {
    method handleEnterKey (line 177) | func (i *inputBox) handleEnterKey(modifier tcell.ModMask) {
    method selectionOff (line 198) | func (i *inputBox) selectionOff() {
    method selectAll (line 203) | func (i *inputBox) selectAll() {
    method removeSelectedText (line 208) | func (i *inputBox) removeSelectedText() {
  function newInputBox (line 42) | func newInputBox(id string) *inputBox {
  function isLineBreak (line 164) | func isLineBreak(character string) bool {
  function handleInputBoxInput (line 220) | func handleInputBoxInput(ev *tcell.EventKey) {

FILE: interfacer/src/browsh/input_cursor.go
  method renderCursor (line 3) | func (i *inputBox) renderCursor() {
  method isSelection (line 14) | func (i *inputBox) isSelection() bool {
  method renderSingleCursor (line 18) | func (i *inputBox) renderSingleCursor() {
  method renderSelectionCursor (line 23) | func (i *inputBox) renderSelectionCursor() {
  method getCoordsOfCursor (line 34) | func (i *inputBox) getCoordsOfCursor() (int, int) {
  method getCoordsOfIndex (line 44) | func (i *inputBox) getCoordsOfIndex(index int) (int, int) {
  method cursorLeft (line 56) | func (i *inputBox) cursorLeft() {
  method cursorRight (line 62) | func (i *inputBox) cursorRight() {
  method cursorUp (line 68) | func (i *inputBox) cursorUp() {
  method cursorDown (line 73) | func (i *inputBox) cursorDown() {
  method cursorBackspace (line 78) | func (i *inputBox) cursorBackspace() {
  method cursorInsertRune (line 92) | func (i *inputBox) cursorInsertRune(theRune rune) {
  method isCursorOverRightEdge (line 101) | func (i *inputBox) isCursorOverRightEdge() bool {
  method isCursorOverLeftEdge (line 105) | func (i *inputBox) isCursorOverLeftEdge() bool {
  method isCursorOverTopEdge (line 109) | func (i *inputBox) isCursorOverTopEdge() bool {
  method isCursorOverBottomEdge (line 113) | func (i *inputBox) isCursorOverBottomEdge() bool {
  method putCursorAtEnd (line 117) | func (i *inputBox) putCursorAtEnd() {
  method updateAllCursors (line 122) | func (i *inputBox) updateAllCursors() {
  method limitTextCursor (line 140) | func (i *inputBox) limitTextCursor() {
  method updateXYCursors (line 149) | func (i *inputBox) updateXYCursors() {

FILE: interfacer/src/browsh/input_multiline.go
  type multiLine (line 9) | type multiLine struct
    method convert (line 20) | func (m *multiLine) convert() []rune {
    method reset (line 43) | func (m *multiLine) reset() {
    method isInsideWord (line 52) | func (m *multiLine) isInsideWord() bool {
    method isPreviousCharacterWhitespace (line 56) | func (m *multiLine) isPreviousCharacterWhitespace() bool {
    method isCurrentCharacterWhitespace (line 68) | func (m *multiLine) isCurrentCharacterWhitespace() bool {
    method isWordishReady (line 75) | func (m *multiLine) isWordishReady() bool {
    method isNaturalWordEnding (line 79) | func (m *multiLine) isNaturalWordEnding() bool {
    method isForcedWordEnding (line 83) | func (m *multiLine) isForcedWordEnding() bool {
    method isCurrentWordishFillingLine (line 87) | func (m *multiLine) isCurrentWordishFillingLine() bool {
    method currentWordishLength (line 91) | func (m *multiLine) currentWordishLength() int {
    method currentLineLength (line 95) | func (m *multiLine) currentLineLength() int {
    method isProjectedLineFull (line 99) | func (m *multiLine) isProjectedLineFull() bool {
    method addWordish (line 103) | func (m *multiLine) addWordish() {
    method addLineWithTruncatedWordish (line 115) | func (m *multiLine) addLineWithTruncatedWordish() {
    method addLineButWrapWord (line 121) | func (m *multiLine) addLineButWrapWord() {
    method noteUserAddedLineIndex (line 128) | func (m *multiLine) noteUserAddedLineIndex() {
    method appendWordToLine (line 132) | func (m *multiLine) appendWordToLine() {
    method addLine (line 137) | func (m *multiLine) addLine() {
    method addWhitespace (line 142) | func (m *multiLine) addWhitespace() {
    method isNaturalLineBreak (line 151) | func (m *multiLine) isNaturalLineBreak() bool {
    method isFinalCharacter (line 155) | func (m *multiLine) isFinalCharacter() bool {
    method lineCount (line 159) | func (m *multiLine) lineCount() int {
    method finish (line 163) | func (m *multiLine) finish() {
    method updateCursor (line 167) | func (m *multiLine) updateCursor() {
    method moveYCursorBy (line 189) | func (m *multiLine) moveYCursorBy(magnitude int) {
    method convertXYCursorToTextCursor (line 216) | func (m *multiLine) convertXYCursorToTextCursor() {
    method isUserAddedLine (line 229) | func (m *multiLine) isUserAddedLine(index int) bool {

FILE: interfacer/src/browsh/input_multiline_test.go
  function TestMultiLineTextBuilder (line 11) | func TestMultiLineTextBuilder(t *testing.T) {
  function toMulti (line 17) | func toMulti(text string, width int) string {
  function visualiseWhitespace (line 28) | func visualiseWhitespace(text string) string {
  function showWhitespace (line 34) | func showWhitespace(textArray []string) string {

FILE: interfacer/src/browsh/input_scroll.go
  method xScrollBy (line 3) | func (i *inputBox) xScrollBy(magnitude int) {
  method yScrollBy (line 10) | func (i *inputBox) yScrollBy(magnitude int) {
  method handleSingleLineScroll (line 17) | func (i *inputBox) handleSingleLineScroll(magnitude int) {
  method isCursorAtEdgeOfBox (line 32) | func (i *inputBox) isCursorAtEdgeOfBox(detectionBoxWidth int) bool {
  method isBestFit (line 38) | func (i *inputBox) isBestFit() bool {
  method limitScroll (line 45) | func (i *inputBox) limitScroll() {

FILE: interfacer/src/browsh/raw_text_server.go
  type threadSafeRequestsMap (line 27) | type threadSafeRequestsMap struct
    method load (line 38) | func (m *threadSafeRequestsMap) load(key string) (value string, ok boo...
    method store (line 45) | func (m *threadSafeRequestsMap) store(key string, value string) {
    method remove (line 51) | func (m *threadSafeRequestsMap) remove(key string) {
  function newRequestsMap (line 32) | func newRequestsMap() *threadSafeRequestsMap {
  type rawTextResponse (line 57) | type rawTextResponse struct
  function HTTPServerStart (line 69) | func HTTPServerStart() {
  function setupRateLimiter (line 85) | func setupRateLimiter() *stdlib.Middleware {
  function pseudoUUID (line 96) | func pseudoUUID() (uuid string) {
  type slashFix (line 107) | type slashFix struct
    method ServeHTTP (line 116) | func (h *slashFix) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  function handleHTTPServerRequest (line 121) | func handleHTTPServerRequest(w http.ResponseWriter, r *http.Request) {
  function deRecurseURL (line 182) | func deRecurseURL(urlForBrowsh string) (string, bool) {
  function isDisallowedDomain (line 193) | func isDisallowedDomain(urlForBrowsh string) bool {
  function isDisallowedUserAgent (line 203) | func isDisallowedUserAgent(userAgent string) bool {
  function isKubeReadinessProbe (line 213) | func isKubeReadinessProbe(userAgent string) bool {
  function isProductionHTTP (line 221) | func isProductionHTTP(r *http.Request) bool {
  function getRawTextMode (line 231) | func getRawTextMode(r *http.Request) string {
  function waitForResponse (line 245) | func waitForResponse(rawTextRequestID string, w http.ResponseWriter) {
  function sendResponse (line 267) | func sendResponse(response, rawTextRequestID string, w http.ResponseWrit...
  function unpackResponse (line 279) | func unpackResponse(jsonString string) rawTextResponse {
  function getTotalTiming (line 287) | func getTotalTiming(startString string) string {

FILE: interfacer/src/browsh/raw_text_server_test.go
  function TestRawTextServer (line 10) | func TestRawTextServer(t *testing.T) {

FILE: interfacer/src/browsh/tab.go
  type tab (line 22) | type tab struct
    method handleStateChange (line 153) | func (t *tab) handleStateChange(incoming *tab) {
  function ResetTabs (line 32) | func ResetTabs() {
  function ensureTabExists (line 39) | func ensureTabExists(id int) {
  function isTabPresent (line 48) | func isTabPresent(id int) bool {
  function newTab (line 53) | func newTab(id int) {
  function removeTab (line 64) | func removeTab(id int) {
  function removeTabIDfromTabsOrder (line 79) | func removeTabIDfromTabsOrder(id int) {
  function createNewEmptyTab (line 91) | func createNewEmptyTab() {
  function isNewEmptyTabActive (line 107) | func isNewEmptyTabActive() bool {
  function nextTab (line 111) | func nextTab() {
  function isTabPreviouslyDeleted (line 128) | func isTabPreviouslyDeleted(id int) bool {
  function parseJSONTabState (line 137) | func parseJSONTabState(jsonString string) {

FILE: interfacer/src/browsh/tty.go
  function setupTcell (line 23) | func setupTcell() {
  function sendTtySize (line 34) | func sendTtySize() {
  function readStdin (line 42) | func readStdin() {
  function handleUserKeyPress (line 56) | func handleUserKeyPress(ev *tcell.EventKey) {
  function isKey (line 101) | func isKey(userKey string, ev *tcell.EventKey) bool {
  function quitBrowsh (line 111) | func quitBrowsh() {
  function toggleMonochromeMode (line 118) | func toggleMonochromeMode() {
  function openHelpTab (line 122) | func openHelpTab() {
  function forwardKeyPress (line 126) | func forwardKeyPress(ev *tcell.EventKey) {
  function isMultiLineEnter (line 141) | func isMultiLineEnter(ev *tcell.EventKey) bool {
  function handleScrolling (line 148) | func handleScrolling(ev *tcell.EventKey) {
  function handleMouseEvent (line 175) | func handleMouseEvent(ev *tcell.EventMouse) {
  function handleMouseScroll (line 199) | func handleMouseScroll(scrollType tcell.ButtonMask) {
  function handleTTYResize (line 219) | func handleTTYResize() {
  function renderCurrentTabWindow (line 230) | func renderCurrentTabWindow() {
  function getCell (line 268) | func getCell(x, y int) cell {
  function getHatchedCellColours (line 284) | func getHatchedCellColours(x int) (tcell.Color, tcell.Color) {

FILE: interfacer/src/browsh/ui.go
  function renderUI (line 20) | func renderUI() {
  function writeString (line 28) | func writeString(x, y int, str string, style tcell.Style) {
  function fillLineToEnd (line 45) | func fillLineToEnd(x, y int) {
  function renderTabs (line 52) | func renderTabs() {
  function renderURLBar (line 75) | func renderURLBar() {
  function urlBarFocusToggle (line 88) | func urlBarFocusToggle() {
  function urlBarFocus (line 96) | func urlBarFocus(on bool) {
  function overlayPageStatusMessage (line 111) | func overlayPageStatusMessage() {
  function overlayCallToSupport (line 116) | func overlayCallToSupport() {
  function reverseCellColour (line 131) | func reverseCellColour(x, y int) {

FILE: interfacer/src/browsh/unit_test.go
  function TestBrowshUnits (line 10) | func TestBrowshUnits(t *testing.T) {

FILE: interfacer/test/http-server/server_test.go
  function TestHTTPServer (line 13) | func TestHTTPServer(t *testing.T) {

FILE: interfacer/test/http-server/setup.go
  function startStaticFileServer (line 20) | func startStaticFileServer() {
  function initBrowsh (line 26) | func initBrowsh() {
  function waitUntilConnectedToWebExtension (line 32) | func waitUntilConnectedToWebExtension(maxTime time.Duration) {
  function getBrowshServiceBase (line 43) | func getBrowshServiceBase() string {
  function getPath (line 47) | func getPath(path string, mode string) string {
  function stopFirefox (line 73) | func stopFirefox() {

FILE: interfacer/test/tty/matchers.go
  function BeInFrameAt (line 12) | func BeInFrameAt(x, y int) gomegaTypes.GomegaMatcher {
  type textInFrameMatcher (line 20) | type textInFrameMatcher struct
    method Match (line 26) | func (matcher *textInFrameMatcher) Match(actual interface{}) (success ...
    method FailureMessage (line 39) | func (matcher *textInFrameMatcher) FailureMessage(text interface{}) (m...
    method NegatedFailureMessage (line 43) | func (matcher *textInFrameMatcher) NegatedFailureMessage(text interfac...

FILE: interfacer/test/tty/setup.go
  function init (line 33) | func init() {
  function initTerm (line 49) | func initTerm() {
  function GetFrame (line 58) | func GetFrame() string {
  function triggerUserKeyFor (line 81) | func triggerUserKeyFor(name string) {
  function SpecialKey (line 90) | func SpecialKey(key tcell.Key) {
  function Keyboard (line 96) | func Keyboard(keys string) {
  function SpecialMouse (line 105) | func SpecialMouse(mouse tcell.ButtonMask) {
  function waitForNextFrame (line 110) | func waitForNextFrame() {
  function WaitForText (line 117) | func WaitForText(text string, x, y int) {
  function WaitForPageLoad (line 131) | func WaitForPageLoad() {
  function sleepUntilPageLoad (line 135) | func sleepUntilPageLoad(maxTime time.Duration) {
  function GotoURL (line 151) | func GotoURL(url string) {
  function mouseClick (line 167) | func mouseClick(x, y int) {
  function elementColourForTTY (line 172) | func elementColourForTTY(element tcell.SimCell) string {
  function GetText (line 183) | func GetText(x, y, length int) string {
  function GetFgColour (line 199) | func GetFgColour(x, y int) [3]int32 {
  function GetBgColour (line 210) | func GetBgColour(x, y int) [3]int32 {
  function ensureOnlyOneTab (line 220) | func ensureOnlyOneTab() {
  function startStaticFileServer (line 226) | func startStaticFileServer() {
  function initBrowsh (line 232) | func initBrowsh() {
  function stopFirefox (line 238) | func stopFirefox() {
  function runeCount (line 245) | func runeCount(text string) int {

FILE: interfacer/test/tty/tty_test.go
  function TestIntegration (line 11) | func TestIntegration(t *testing.T) {

FILE: webext/contrib/font_maker.py
  function generate (line 7) | def generate(name, block):

FILE: webext/src/background/common_mixin.js
  method sendToCurrentTab (line 7) | sendToCurrentTab(message) {
  method sendToTerminal (line 15) | sendToTerminal(message) {
  method log (line 24) | log(...messages) {
  method currentTab (line 36) | currentTab() {

FILE: webext/src/background/dimensions.js
  method constructor (line 7) | constructor() {
  method postConfigSetup (line 21) | postConfigSetup(config) {
  method setCharValues (line 26) | setCharValues(incoming) {
  method _setRawTextTTYSize (line 46) | _setRawTextTTYSize() {
  method resizeBrowserWindow (line 53) | resizeBrowserWindow() {
  method _sendWindowResizeRequest (line 89) | _sendWindowResizeRequest(active_window, width, height) {

FILE: webext/src/background/manager.js
  method constructor (line 12) | constructor() {
  method _connectToTerminal (line 39) | _connectToTerminal() {
  method _reconnectToTerminal (line 55) | _reconnectToTerminal() {
  method _listenForTerminalMessages (line 66) | _listenForTerminalMessages() {
  method _connectToBrowserDOM (line 74) | _connectToBrowserDOM() {
  method _initialDOMConnection (line 82) | _initialDOMConnection() {
  method _reconnectToDOM (line 89) | _reconnectToDOM() {
  method _listenForNewTab (line 104) | _listenForNewTab() {
  method _listenForTabUpdates (line 112) | _listenForTabUpdates() {
  method _maybeNewTab (line 121) | _maybeNewTab(tabish_object) {
  method _handleTabUpdate (line 130) | _handleTabUpdate(_tab_id, changes, native_tab_object) {
  method _newTabHandler (line 143) | _newTabHandler(_request, sender, sendResponse) {
  method _acknowledgeNewTab (line 152) | _acknowledgeNewTab(native_tab_object) {
  method _applyUpdates (line 158) | _applyUpdates(tabish_object) {
  method _listenForTabChannelOpen (line 182) | _listenForTabChannelOpen() {
  method _tabChannelOpenHandler (line 188) | _tabChannelOpenHandler(channel) {
  method _listenForFocussedTab (line 200) | _listenForFocussedTab() {
  method _focussedTabHandler (line 204) | _focussedTabHandler(tab) {
  method _getTabsOnSuccess (line 209) | _getTabsOnSuccess(windowInfoArray, callback) {
  method _getTabsOnError (line 217) | _getTabsOnError(error) {
  method _pollAllTabs (line 221) | _pollAllTabs(callback) {
  method _initialWindowResize (line 236) | _initialWindowResize() {
  method _startFrameRequestLoop (line 249) | _startFrameRequestLoop() {
  method _isAbleToRequestFrame (line 263) | _isAbleToRequestFrame() {
  method _addWebRequestListener (line 285) | _addWebRequestListener() {

FILE: webext/src/background/tab.js
  method constructor (line 7) | constructor() {
  method postDOMLoadInit (line 17) | postDOMLoadInit(terminal, dimensions) {
  method postConnectionInit (line 23) | postConnectionInit(channel, config) {
  method _calculateMode (line 30) | _calculateMode() {
  method isConnected (line 36) | isConnected() {
  method reload (line 40) | reload() {
  method remove (line 48) | remove() {
  method updateStatus (line 56) | updateStatus(status, message = "") {
  method getStateObject (line 76) | getStateObject() {
  method sendStateToTerminal (line 88) | sendStateToTerminal() {
  method ensureConnectionToBackground (line 98) | ensureConnectionToBackground() {
  method sendGlobalConfig (line 116) | sendGlobalConfig(config) {
  method _listenForMessages (line 128) | _listenForMessages() {
  method _sendTTYDimensions (line 132) | _sendTTYDimensions() {
  method _isItOKToRetryReload (line 138) | _isItOKToRetryReload() {
  method _closeUnwantedStartupTabs (line 147) | _closeUnwantedStartupTabs() {

FILE: webext/src/background/tab_commands_mixin.js
  method handleTabMessage (line 9) | handleTabMessage(message) {
  method _updateTabInfo (line 43) | _updateTabInfo(incoming) {
  method _rawTextRequest (line 49) | _rawTextRequest(incoming) {
  method _tabCount (line 67) | _tabCount(callback) {
  method _getAllTabs (line 73) | _getAllTabs(callback) {

FILE: webext/src/background/tty_commands_mixin.js
  method handleTerminalMessage (line 6) | handleTerminalMessage(message) {
  method _loadConfig (line 41) | _loadConfig(json_string) {
  method _setupRawTextMode (line 52) | _setupRawTextMode() {
  method _updateTTYSize (line 63) | _updateTTYSize(width, height) {
  method _handleUICommand (line 78) | _handleUICommand(parts) {
  method _handleURLBarInput (line 102) | _handleURLBarInput(input) {
  method _getURLfromUserInput (line 108) | _getURLfromUserInput(input) {
  method createNewTab (line 127) | createNewTab(url, callback) {
  method gotoURL (line 145) | gotoURL(url) {
  method switchToTab (line 159) | switchToTab(id) {
  method removeTab (line 173) | removeTab(id) {
  method screenshotActiveTab (line 180) | screenshotActiveTab() {
  method _saveScreenshot (line 189) | _saveScreenshot(imageUri) {
  method _rawTextRequest (line 194) | _rawTextRequest(request_id, mode, url) {
  method toggleUserAgent (line 212) | toggleUserAgent() {
  method _addUserAgentListener (line 221) | _addUserAgentListener() {

FILE: webext/src/dom/commands_mixin.js
  method _handleBackgroundMessage (line 5) | _handleBackgroundMessage(message) {
  method _launch (line 51) | _launch() {
  method _loadConfig (line 66) | _loadConfig(config) {
  method _handleUserInput (line 72) | _handleUserInput(input) {
  method _handleSpecialKeys (line 78) | _handleSpecialKeys(input) {
  method _handleCharBasedKeys (line 97) | _handleCharBasedKeys(input) {
  method _handleInputBoxContent (line 104) | _handleInputBoxContent(input) {
  method _handleMouse (line 119) | _handleMouse(input) {
  method _handleTTYSize (line 142) | _handleTTYSize(x, y) {
  method _handleScroll (line 151) | _handleScroll(x, y) {
  method _triggerKeyPress (line 162) | _triggerKeyPress(key) {
  method _mouseAction (line 189) | _mouseAction(type, x, y) {
  method _getDOMCoordsFromMouseCoords (line 224) | _getDOMCoordsFromMouseCoords(x, y) {
  method _sendTabInfo (line 248) | _sendTabInfo() {
  method _mightSendBigFrames (line 257) | _mightSendBigFrames() {

FILE: webext/src/dom/common_mixin.js
  method constructor (line 3) | constructor() {
  method sendMessage (line 8) | sendMessage(message) {
  method log (line 15) | log(...messages) {
  method logPerformance (line 23) | logPerformance(work, reference) {
  method logError (line 33) | logError(error) {
  method firstFrameLog (line 41) | firstFrameLog(...logs) {

FILE: webext/src/dom/dimensions.js
  method constructor (line 7) | constructor() {
  method update (line 49) | update() {
  method setSubFrameDimensions (line 57) | setSubFrameDimensions(size) {
  method getFrameMeta (line 71) | getFrameMeta() {
  method _calculateSmallSubFrame (line 84) | _calculateSmallSubFrame() {
  method _calculateBigSubFrame (line 97) | _calculateBigSubFrame() {
  method _calculateEntireDOMFrames (line 112) | _calculateEntireDOMFrames() {
  method _limitSubFrameDimensions (line 135) | _limitSubFrameDimensions() {
  method _scaleSubFrameToSubDOM (line 150) | _scaleSubFrameToSubDOM() {
  method _calculateCharacterDimensions (line 169) | _calculateCharacterDimensions() {
  method _getOrCreateMeasuringBox (line 192) | _getOrCreateMeasuringBox() {
  method findMeasuringBox (line 204) | findMeasuringBox() {
  method _updateDOMDimensions (line 208) | _updateDOMDimensions() {
  method _calculateDOMDimensions (line 221) | _calculateDOMDimensions() {
  method _updateFrameDimensions (line 234) | _updateFrameDimensions() {
  method _calculateScaleFactor (line 255) | _calculateScaleFactor() {
  method _notifyBackground (line 263) | _notifyBackground() {

FILE: webext/src/dom/graphics_builder.js
  method constructor (line 10) | constructor(channel, dimensions, config) {
  method sendFrame (line 22) | sendFrame() {
  method getUnscaledFGPixelAt (line 28) | getUnscaledFGPixelAt(x, y) {
  method getUnscaledBGPixelAt (line 43) | getUnscaledBGPixelAt(x, y) {
  method getScreenshotWithText (line 57) | getScreenshotWithText(callback) {
  method getScreenshotWithoutText (line 63) | getScreenshotWithoutText() {
  method getOnOffScreenshots (line 69) | getOnOffScreenshots(callback) {
  method _getScreenshotWithoutText (line 74) | _getScreenshotWithoutText() {
  method _getScreenshotWithText (line 79) | _getScreenshotWithText(callback) {
  method _getScreenshotWithTextDelayable (line 101) | _getScreenshotWithTextDelayable(callback) {
  method _getScaledScreenshot (line 107) | _getScaledScreenshot() {
  method _convertDOMCoordsToRelative (line 118) | _convertDOMCoordsToRelative(x, y) {
  method _getScaledPixelAt (line 137) | _getScaledPixelAt(x, y) {
  method __getScaledScreenshot (line 147) | __getScaledScreenshot() {
  method hideText (line 153) | hideText() {
  method showText (line 158) | showText() {
  method _getScreenshot (line 163) | _getScreenshot() {
  method _scaleCanvas (line 168) | _scaleCanvas() {
  method _unScaleCanvas (line 177) | _unScaleCanvas() {
  method _updateCanvasSize (line 182) | _updateCanvasSize() {
  method _getPixelData (line 190) | _getPixelData() {
  method _getScaledDataURI (line 216) | _getScaledDataURI() {
  method _sendFrame (line 227) | _sendFrame() {
  method _serialiseFrame (line 236) | _serialiseFrame() {
  method _setupFrameMeta (line 248) | _setupFrameMeta() {

FILE: webext/src/dom/manager.js
  method constructor (line 13) | constructor() {
  method _postSetupConstructor (line 27) | _postSetupConstructor() {
  method _willHideText (line 43) | _willHideText() {
  method sendFrame (line 51) | sendFrame() {
  method sendAllBigFrames (line 64) | sendAllBigFrames() {
  method sendRawText (line 82) | sendRawText() {
  method sendSmallPixelFrame (line 92) | sendSmallPixelFrame() {
  method sendSmallTextFrame (line 105) | sendSmallTextFrame() {
  method _postCommsInit (line 118) | _postCommsInit() {
  method _setupInteractiveMode (line 129) | _setupInteractiveMode() {
  method _setupDebouncedFunctions (line 142) | _setupDebouncedFunctions() {
  method _setupInit (line 148) | _setupInit() {
  method _isWindowAlreadyLoaded (line 156) | _isWindowAlreadyLoaded() {
  method _init (line 163) | _init(delay = 0) {
  method _registerWithBackground (line 169) | _registerWithBackground() {
  method _registrationSuccess (line 177) | _registrationSuccess(registered) {
  method _registrationError (line 186) | _registrationError(error) {
  method _startWindowEventListeners (line 190) | _startWindowEventListeners() {
  method _startMutationObserver (line 210) | _startMutationObserver() {
  method _listenForBackgroundMessages (line 225) | _listenForBackgroundMessages() {
  method _fixStickyElements (line 244) | _fixStickyElements() {
  method _injectCustomCSS (line 256) | _injectCustomCSS() {

FILE: webext/src/dom/serialise_mixin.js
  method __serialiseFrame (line 5) | __serialiseFrame() {
  method _serialiseRawText (line 30) | _serialiseRawText() {
  method _wrap (line 47) | _wrap(raw_text) {
  method userHasShownSupport (line 58) | userHasShownSupport() {
  method _byBrowsh (line 64) | _byBrowsh() {
  method _getUserFooter (line 76) | _getUserFooter() {
  method _getUserHeader (line 80) | _getUserHeader() {
  method _getMetaData (line 84) | _getMetaData() {
  method _getDonateCall (line 98) | _getDonateCall() {
  method _getFooter (line 114) | _getFooter() {
  method _getHTMLHead (line 132) | _getHTMLHead() {
  method _getFavicon (line 176) | _getFavicon() {
  method _markParsingDuration (line 185) | _markParsingDuration() {
  method _getCurrentDataTime (line 189) | _getCurrentDataTime() {
  method _getAllInputBoxes (line 221) | _getAllInputBoxes() {
  method _ensureBrowshID (line 274) | _ensureBrowshID(element) {
  method _isUnwantedInboxBox (line 280) | _isUnwantedInboxBox(input_box, styles) {
  method _sendRawText (line 288) | _sendRawText() {
  method _sendFrame (line 306) | _sendFrame() {
  method _addCell (line 315) | _addCell(x, y, right) {
  method _addCellAsHTML (line 328) | _addCellAsHTML() {
  method _addHTMLForNonExistentCell (line 347) | _addHTMLForNonExistentCell() {
  method _handleCellOutsideAnchor (line 355) | _handleCellOutsideAnchor() {
  method _handleCellInsideAnchor (line 364) | _handleCellInsideAnchor() {
  method _openAnchorTag (line 380) | _openAnchorTag() {
  method _closeAnchorTag (line 385) | _closeAnchorTag() {
  method _addCellAsPlainText (line 390) | _addCellAsPlainText() {
  method _setupFrameMeta (line 397) | _setupFrameMeta() {
  method _serialiseInputBoxes (line 406) | _serialiseInputBoxes() {

FILE: webext/src/dom/text_builder.js
  method constructor (line 12) | constructor(channel, dimensions, graphics_builder, config) {
  method sendFrame (line 26) | sendFrame() {
  method sendRawText (line 30) | sendRawText(type) {
  method buildFormattedText (line 42) | buildFormattedText(callback) {
  method _updateState (line 52) | _updateState() {
  method _getTextNodes (line 60) | _getTextNodes() {
  method _positionTextNodes (line 67) | _positionTextNodes() {
  method _serialiseFrame (line 73) | _serialiseFrame() {
  method __getTextNodes (line 80) | __getTextNodes() {
  method _isRelevantTextNode (line 96) | _isRelevantTextNode(node) {
  method _convertSubFrameToViewportCoords (line 112) | _convertSubFrameToViewportCoords() {
  method _isDOMRectInSubFrame (line 127) | _isDOMRectInSubFrame(dom_rect) {
  method __positionTextNodes (line 143) | __positionTextNodes() {
  method _formatText (line 153) | _formatText() {
  method _fixJustifiedText (line 171) | _fixJustifiedText() {
  method _normaliseWhitespace (line 188) | _normaliseWhitespace() {
  method _isFirstParseInElement (line 216) | _isFirstParseInElement() {
  method _positionSingleTextNode (line 236) | _positionSingleTextNode() {
  method _getNodeDOMBoxes (line 257) | _getNodeDOMBoxes() {
  method _handleSingleDOMBox (line 274) | _handleSingleDOMBox() {
  method _prepareToParseDOMBox (line 282) | _prepareToParseDOMBox() {
  method _setCurrentCharacter (line 291) | _setCurrentCharacter() {
  method _createTrackers (line 299) | _createTrackers() {
  method _handleSingleCharacter (line 310) | _handleSingleCharacter() {
  method _stepToNextCharacter (line 319) | _stepToNextCharacter(tracked = true) {
  method _ignoreUnrenderedWhitespace (line 334) | _ignoreUnrenderedWhitespace() {
  method _isNewLine (line 341) | _isNewLine() {
  method _convertDOMRectToAbsoluteCoords (line 349) | _convertDOMRectToAbsoluteCoords(dom_rect) {
  method _createSyncedTTYBox (line 361) | _createSyncedTTYBox() {
  method _addClientRectsOverlay (line 379) | _addClientRectsOverlay(dom_rects, normalised_text) {

FILE: webext/src/dom/tty_cell.js
  method isHighestLayer (line 8) | isHighestLayer() {

FILE: webext/src/dom/tty_grid.js
  method constructor (line 5) | constructor(dimensions, graphics_builder, config) {
  method getCell (line 12) | getCell(index) {
  method getCellAt (line 16) | getCellAt(x, y) {
  method addCell (line 20) | addCell(new_cell) {
  method _isNewCellAtHighestLayer (line 29) | _isNewCellAtHighestLayer(new_cell) {
  method _handleCellVisibility (line 37) | _handleCellVisibility(new_cell) {
  method _calculateIndex (line 46) | _calculateIndex(cell) {
  method _getColours (line 52) | _getColours(cell) {
  method _setMiddleOfEm (line 78) | _setMiddleOfEm() {
  method _isCharObscured (line 101) | _isCharObscured(colours) {

FILE: webext/test/fixtures/canvas_pixels.js
  class CanvasPixels (line 14) | class CanvasPixels {
    method constructor (line 15) | constructor(dimensions) {
    method with_text (line 24) | with_text() {
    method _getIndexValue (line 44) | _getIndexValue(i) {
    method _checkForCharacter (line 50) | _checkForCharacter(x, y) {
    method _isNullOrWhiteSpace (line 58) | _isNullOrWhiteSpace(str) {
    method without_text (line 64) | without_text() {
    method scaled (line 75) | scaled() {

FILE: webext/test/fixtures/text_nodes.js
  class TextNodes (line 2) | class TextNodes {
    method constructor (line 3) | constructor() {
    method build (line 12) | build() {
    method boundingBox (line 28) | boundingBox() {
    method addDomRect (line 39) | addDomRect(line) {

FILE: webext/test/helper.js
  function _setupMockDOMSize (line 95) | function _setupMockDOMSize() {
  function _setupDimensions (line 104) | function _setupDimensions() {
  function _setupGraphicsBuilder (line 116) | function _setupGraphicsBuilder(type) {

FILE: webext/test/mocks/range.js
  class MockRange (line 1) | class MockRange {
    method selectNode (line 2) | selectNode(node) {
    method getBoundingClientRect (line 5) | getBoundingClientRect() {
    method getClientRects (line 8) | getClientRects() {

FILE: webext/webpack.config.js
  method transform (line 50) | transform(manifest, _) {
Condensed preview — 88 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (329K chars).
[
  {
    "path": ".dockerignore",
    "chars": 11,
    "preview": "Dockerfile\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "chars": 63,
    "preview": "# These are supported funding model platforms\n\npatreon: browsh\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "chars": 375,
    "preview": "---\nname: Bug report\nabout: Create a report to help us improve\n\n---\n\nJust a few points to consider before submitting a b"
  },
  {
    "path": ".github/workflows/lint.yml",
    "chars": 722,
    "preview": "name: Lint\non: [push, pull_request]\n\njobs:\n  lint:\n    runs-on: ubuntu-latest\n    env:\n      GOPATH: ${{ github.workspac"
  },
  {
    "path": ".github/workflows/main.yml",
    "chars": 3669,
    "preview": "name: Test-Release\non: [push, pull_request]\n\njobs:\n  tests:\n    name: \"Tests (webextension, interfacer: unit, tty, http-"
  },
  {
    "path": ".gitignore",
    "chars": 680,
    "preview": "build/\n*.log\n*.out\nnode_modules\ninterfacer/target\ninterfacer/vendor\ninterfacer/dist\ninterfacer/interfacer\ninterfacer/bro"
  },
  {
    "path": "Dockerfile",
    "chars": 1823,
    "preview": "FROM debian:trixie-slim as build\n\nRUN apt update\nRUN apt install --yes \\\n      curl \\\n      ca-certificates \\\n      git "
  },
  {
    "path": "LICENSE",
    "chars": 24479,
    "preview": "                  GNU LESSER GENERAL PUBLIC LICENSE\n                       Version 2.1, February 1999\n\n Copyright (C) 19"
  },
  {
    "path": "README.md",
    "chars": 3705,
    "preview": "[![Follow @brow_sh](https://img.shields.io/twitter/follow/brow_sh.svg?style=social&label=Follow)](https://twitter.com/in"
  },
  {
    "path": "ctl.sh",
    "chars": 576,
    "preview": "#!/usr/bin/env bash\nset -e\n\nfunction_to_run=$1\n\nexport PROJECT_ROOT\nexport GORELEASER_VERSION=1.10.2\n\nPROJECT_ROOT=$(cd "
  },
  {
    "path": "goreleaser.yml",
    "chars": 1409,
    "preview": "# Run with `ctl.sh release` to get ENV vars\n\nproject_name: browsh\nbuilds:\n  - binary: browsh\n    env:\n      - CGO_ENABLE"
  },
  {
    "path": "interfacer/cmd/browsh/main.go",
    "chars": 111,
    "preview": "package main\n\nimport \"github.com/browsh-org/browsh/interfacer/src/browsh\"\n\nfunc main() {\n\tbrowsh.MainEntry()\n}\n"
  },
  {
    "path": "interfacer/contrib/upx_compress_binary.sh",
    "chars": 112,
    "preview": "#!/usr/bin/env bash\nset -ex\nshopt -s extglob\n\npushd dist\nupx !(@(freebsd*|openbsd*|darwin*|linux_arm64))/*\npopd\n"
  },
  {
    "path": "interfacer/go.mod",
    "chars": 1688,
    "preview": "module github.com/browsh-org/browsh/interfacer\n\ngo 1.24.4\n\nrequire (\n\tgithub.com/NYTimes/gziphandler v1.1.1\n\tgithub.com/"
  },
  {
    "path": "interfacer/go.sum",
    "chars": 16826,
    "preview": "github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I=\ngithub.com/NYTimes/gziphandler v1."
  },
  {
    "path": "interfacer/src/browsh/browsh.go",
    "chars": 4672,
    "preview": "package browsh\n\nimport (\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runt"
  },
  {
    "path": "interfacer/src/browsh/cells.go",
    "chars": 899,
    "preview": "package browsh\n\nimport (\n\t\"sync\"\n\n\t\"github.com/gdamore/tcell\"\n)\n\n// A cell represents an individual TTY cell. An entire "
  },
  {
    "path": "interfacer/src/browsh/comms.go",
    "chars": 5012,
    "preview": "package browsh\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/go-errors/errors\"\n\t\"gi"
  },
  {
    "path": "interfacer/src/browsh/config.go",
    "chars": 3047,
    "preview": "package browsh\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/shibukawa/configdir"
  },
  {
    "path": "interfacer/src/browsh/config_sample.go",
    "chars": 3279,
    "preview": "package browsh\n\nvar configSample = `\n# See; https://www.brow.sh/donate/\n# By showing your support you can disable the ap"
  },
  {
    "path": "interfacer/src/browsh/firefox.go",
    "chars": 9900,
    "preview": "package browsh\n\nimport (\n\t\"bufio\"\n\t\"embed\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"log/slog\"\n\t\"net\"\n\t\"os\"\n\t\"os/exec\"\n\t\"os/sig"
  },
  {
    "path": "interfacer/src/browsh/firefox_unix.go",
    "chars": 577,
    "preview": "//go:build darwin || dragonfly || freebsd || linux || nacl || netbsd || openbsd || solaris\n\npackage browsh\n\nimport (\n\t\"s"
  },
  {
    "path": "interfacer/src/browsh/firefox_windows.go",
    "chars": 2324,
    "preview": "//go:build windows\n\npackage browsh\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"strings\"\n\n\t\"github.com/go-errors/errors\"\n\t\"golang.org/"
  },
  {
    "path": "interfacer/src/browsh/frame_builder.go",
    "chars": 9712,
    "preview": "package browsh\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"unicode\"\n\n\t\"github.com/gdamore/tcell\"\n)\n\n// A frame is a "
  },
  {
    "path": "interfacer/src/browsh/frame_builder_test.go",
    "chars": 6552,
    "preview": "package browsh\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo\"\n\t. \"github.com/onsi/gomega\"\n)\n\nfunc TestFrameBui"
  },
  {
    "path": "interfacer/src/browsh/input_box.go",
    "chars": 6094,
    "preview": "package browsh\n\nimport (\n\t\"encoding/json\"\n\t\"unicode/utf8\"\n\n\t\"github.com/gdamore/tcell\"\n)\n\nvar activeInputBox *inputBox\n\n"
  },
  {
    "path": "interfacer/src/browsh/input_cursor.go",
    "chars": 3075,
    "preview": "package browsh\n\nfunc (i *inputBox) renderCursor() {\n\tif !i.isActive {\n\t\treturn\n\t}\n\tif i.isSelection() {\n\t\ti.renderSelect"
  },
  {
    "path": "interfacer/src/browsh/input_multiline.go",
    "chars": 5158,
    "preview": "package browsh\n\nimport (\n\t\"strings\"\n\t\"unicode\"\n\t\"unicode/utf8\"\n)\n\ntype multiLine struct {\n\tinputBox          *inputBox\n\t"
  },
  {
    "path": "interfacer/src/browsh/input_multiline_test.go",
    "chars": 2796,
    "preview": "package browsh\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo\"\n\t. \"github.com/onsi/gomega\"\n)\n\nfunc TestMult"
  },
  {
    "path": "interfacer/src/browsh/input_scroll.go",
    "chars": 1429,
    "preview": "package browsh\n\nfunc (i *inputBox) xScrollBy(magnitude int) {\n\tif !i.isMultiLine() {\n\t\ti.handleSingleLineScroll(magnitud"
  },
  {
    "path": "interfacer/src/browsh/raw_text_server.go",
    "chars": 8579,
    "preview": "package browsh\n\nimport (\n\t\"crypto/rand\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"str"
  },
  {
    "path": "interfacer/src/browsh/raw_text_server_test.go",
    "chars": 1063,
    "preview": "package browsh\n\nimport (\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo\"\n\t. \"github.com/onsi/gomega\"\n)\n\nfunc TestRawTextServer(t"
  },
  {
    "path": "interfacer/src/browsh/tab.go",
    "chars": 3638,
    "preview": "package browsh\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n)\n\n// Tabs is a map of all tab data\nvar Tabs = make(map[int]*tab)\n\n// C"
  },
  {
    "path": "interfacer/src/browsh/tty.go",
    "chars": 7329,
    "preview": "package browsh\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"strconv\"\n\n\t\"github.com/gdamore/tcell\"\n\t\"github.com/go-errors/er"
  },
  {
    "path": "interfacer/src/browsh/ui.go",
    "chars": 3079,
    "preview": "package browsh\n\nimport (\n\t\"log/slog\"\n\n\t\"github.com/gdamore/tcell\"\n\t\"github.com/spf13/viper\"\n)\n\nvar urlInputBox = inputBo"
  },
  {
    "path": "interfacer/src/browsh/unit_test.go",
    "chars": 188,
    "preview": "package browsh\n\nimport (\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo\"\n\t. \"github.com/onsi/gomega\"\n)\n\nfunc TestBrowshUnits(t *"
  },
  {
    "path": "interfacer/src/browsh/version.go",
    "chars": 44,
    "preview": "package browsh\n\nvar browshVersion = \"1.8.2\"\n"
  },
  {
    "path": "interfacer/test/http-server/server_test.go",
    "chars": 2300,
    "preview": "package test\n\nimport (\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo\"\n\t. \"github.com/onsi/gomega\"\n\t\"gi"
  },
  {
    "path": "interfacer/test/http-server/setup.go",
    "chars": 2352,
    "preview": "package test\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/browsh-org/browsh/interfacer/src/brows"
  },
  {
    "path": "interfacer/test/sites/smorgasbord/another.html",
    "chars": 128,
    "preview": "<html>\n  <head>\n    <meta charset=\"utf-8\">\n    <title>Another</title>\n  </head>\n  <body>\n    Another webpage\n  </body>\n<"
  },
  {
    "path": "interfacer/test/sites/smorgasbord/css/main.css",
    "chars": 197,
    "preview": "#content {\n  width: 500px;\n  margin: auto;\n}\n\nh1 {\n  text-align: center;\n}\n\n.left_col {\n  width: 45%;\n  float: left;\n}\n\n"
  },
  {
    "path": "interfacer/test/sites/smorgasbord/css/spinner.css",
    "chars": 943,
    "preview": "/* Animation */\n@-webkit-keyframes spinner {\n  to { -webkit-transform: rotate(360deg); }\n}\n@-moz-keyframes spinner {\n  t"
  },
  {
    "path": "interfacer/test/sites/smorgasbord/index.html",
    "chars": 2628,
    "preview": "<html>\n  <head>\n    <meta charset=\"utf-8\">\n    <title>Smörgåsbord</title>\n    <link href=\"css/main.css\" rel=\"stylesheet\""
  },
  {
    "path": "interfacer/test/sites/smorgasbord/textarea.html",
    "chars": 143,
    "preview": "<html>\n  <head>\n    <meta charset=\"utf-8\">\n    <title>Another</title>\n  </head>\n  <body>\n    <textarea rows=\"3\"></textar"
  },
  {
    "path": "interfacer/test/tty/matchers.go",
    "chars": 1190,
    "preview": "package test\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\tgomegaTypes \"github.com/onsi/gomega/types\"\n)\n\n// BeInFrameAt is a custom matcher"
  },
  {
    "path": "interfacer/test/tty/setup.go",
    "chars": 7556,
    "preview": "package test\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"githu"
  },
  {
    "path": "interfacer/test/tty/tty_test.go",
    "chars": 6205,
    "preview": "package test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/gdamore/tcell\"\n\t. \"github.com/onsi/ginkgo\"\n\t. \"github.com/onsi/gomega\"\n)"
  },
  {
    "path": "scripts/bundling.bash",
    "chars": 2898,
    "preview": "#!/usr/bin/env bash\n\nexport XPI_PATH=\"$PROJECT_ROOT\"/interfacer/src/browsh/browsh.xpi\nexport XPI_SOURCE_DIR=$PROJECT_ROO"
  },
  {
    "path": "scripts/common.bash",
    "chars": 362,
    "preview": "#!/usr/bin/env bash\n\n# shellcheck disable=2120\nfunction _panic() {\n\tlocal message=$1\n\techo >&2 \"$message\"\n\texit 1\n}\n\nfun"
  },
  {
    "path": "scripts/docker.bash",
    "chars": 974,
    "preview": "#!/usr/bin/env bash\n\nfunction docker_image_name() {\n\t_export_versions\n\techo browsh/browsh:v\"$BROWSH_VERSION\"\n}\n\nfunction"
  },
  {
    "path": "scripts/misc.bash",
    "chars": 2049,
    "preview": "#!/usr/bin/env bash\n\nfunction golang_lint_check() {\n\tpushd \"$PROJECT_ROOT\"/interfacer || _panic\n\tdiff -u <(echo -n) <(go"
  },
  {
    "path": "scripts/releasing.bash",
    "chars": 3697,
    "preview": "#!/usr/bin/env bash\n\nexport BROWSH_VERSION\nexport LATEST_TAGGED_VERSION\n\nfunction _goreleaser_production() {\n\tif ! comma"
  },
  {
    "path": "scripts/tests.bash",
    "chars": 1009,
    "preview": "# For the webextension: in `webext/` folder, `npm test`\n# For CLI unit tests: in `/interfacer` run `go test src/browsh/*"
  },
  {
    "path": "webext/.eslintrc",
    "chars": 435,
    "preview": "{\n  \"env\" : {\n    \"es6\": true,\n    \"node\" : true,\n    \"browser\" : true,\n    \"webextensions\": true,\n    \"mocha\": true\n  }"
  },
  {
    "path": "webext/.mocharc.cjs",
    "chars": 102,
    "preview": "'use strict';\n\nmodule.exports = {\n\trequire: 'babel-register',\n\trecursive: true,\n\ttimeout: '60000'\n};\n\n"
  },
  {
    "path": "webext/.web-extension-id",
    "chars": 162,
    "preview": "# This file was created by https://github.com/mozilla/web-ext\n# Your auto-generated extension ID for addons.mozilla.org "
  },
  {
    "path": "webext/assets/browsh-schema.json",
    "chars": 6006,
    "preview": "{\n  \"$id\": \"https://json.schemastore.org/browsh-schema.json\",\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  "
  },
  {
    "path": "webext/assets/styles.css",
    "chars": 1221,
    "preview": "@font-face {\n  /* A special font that only has unicode full blocks in it, so we can detect */\n  /* font colors and text "
  },
  {
    "path": "webext/background.js",
    "chars": 78,
    "preview": "import BackgroundManager from 'background/manager'\n\nnew BackgroundManager();\n\n"
  },
  {
    "path": "webext/content.js",
    "chars": 58,
    "preview": "import DOMManager from 'dom/manager';\n\nnew DOMManager();\n\n"
  },
  {
    "path": "webext/contrib/download_xpi.js",
    "chars": 604,
    "preview": "// `npm install -g jsonwebtoken`\nvar jwt = require('jsonwebtoken');\n\nvar key = 'user:13243312:78';\nvar secret = process."
  },
  {
    "path": "webext/contrib/firefoxheadless.sh",
    "chars": 251,
    "preview": "#!/usr/bin/env bash\n\nif [[ \"$1\" = \"kill\" ]]; then\n\tpkill --full 'firefox.*headless.*profile'\n\tsleep 1\n\tif [[ \"$CI\" == \"t"
  },
  {
    "path": "webext/contrib/font_maker.py",
    "chars": 1757,
    "preview": "# TODO:\n#   Look into using: https://github.com/adobe-fonts/adobe-blank\n#   It should both reduce the size of the font a"
  },
  {
    "path": "webext/manifest.json",
    "chars": 684,
    "preview": "{\n  \"manifest_version\": 2,\n  \"name\": \"Browsh\",\n  \"version\": \"BROWSH_VERSION\",\n\n  \"description\": \"Renders the browser as "
  },
  {
    "path": "webext/package.json",
    "chars": 771,
    "preview": "{\n  \"type\": \"module\",\n  \"scripts\": {\n    \"build:dev\": \"webpack\",\n    \"build:watch\": \"webpack --watch\",\n    \"lint\": \"pret"
  },
  {
    "path": "webext/src/background/common_mixin.js",
    "chars": 1058,
    "preview": "import stripAnsi from \"strip-ansi\";\n\n// Here we keep the public functions used to mediate communications between\n// the "
  },
  {
    "path": "webext/src/background/dimensions.js",
    "chars": 3335,
    "preview": "import _ from \"lodash\";\n\nimport utils from \"utils\";\nimport CommonMixin from \"background/common_mixin\";\n\nexport default c"
  },
  {
    "path": "webext/src/background/manager.js",
    "chars": 10183,
    "preview": "import _ from \"lodash\";\nimport utils from \"utils\";\nimport CommonMixin from \"background/common_mixin\";\nimport TTYCommands"
  },
  {
    "path": "webext/src/background/tab.js",
    "chars": 4450,
    "preview": "import utils from \"utils\";\n\nimport CommonMixin from \"background/common_mixin\";\nimport TabCommandsMixin from \"background/"
  },
  {
    "path": "webext/src/background/tab_commands_mixin.js",
    "chars": 2546,
    "preview": "import utils from \"utils\";\n\n// Handle commands from tabs, like sending a frame or information about\n// the current chara"
  },
  {
    "path": "webext/src/background/tty_commands_mixin.js",
    "chars": 6881,
    "preview": "import utils from \"utils\";\n\n// Handle commands coming in from the terminal, like; STDIN keystrokes, TTY resize, etc\nexpo"
  },
  {
    "path": "webext/src/dom/commands_mixin.js",
    "chars": 8640,
    "preview": "import utils from \"utils\";\n\nexport default (MixinBase) =>\n  class extends MixinBase {\n    _handleBackgroundMessage(messa"
  },
  {
    "path": "webext/src/dom/common_mixin.js",
    "chars": 1197,
    "preview": "export default (MixinBase) =>\n  class extends MixinBase {\n    constructor() {\n      super();\n      this._is_first_frame_"
  },
  {
    "path": "webext/src/dom/dimensions.js",
    "chars": 9803,
    "preview": "import utils from \"utils\";\n\nimport CommonMixin from \"dom/common_mixin\";\n\n// All the various dimensions, sizes, scales, e"
  },
  {
    "path": "webext/src/dom/graphics_builder.js",
    "chars": 8200,
    "preview": "import utils from \"utils\";\n\nimport CommonMixin from \"dom/common_mixin\";\n\n// Converts an instance of the visible DOM into"
  },
  {
    "path": "webext/src/dom/manager.js",
    "chars": 7651,
    "preview": "import _ from \"lodash\";\n\nimport utils from \"utils\";\n\nimport CommonMixin from \"dom/common_mixin\";\nimport CommandsMixin fr"
  },
  {
    "path": "webext/src/dom/serialise_mixin.js",
    "chars": 12292,
    "preview": "import utils from \"utils\";\n\nexport default (MixinBase) =>\n  class extends MixinBase {\n    __serialiseFrame() {\n      let"
  },
  {
    "path": "webext/src/dom/text_builder.js",
    "chars": 15972,
    "preview": "import _ from \"lodash\";\n\nimport utils from \"utils\";\nimport CommonMixin from \"dom/common_mixin\";\nimport SerialiseMixin fr"
  },
  {
    "path": "webext/src/dom/tty_cell.js",
    "chars": 551,
    "preview": "// A single cell on the TTY grid\nexport default class {\n  // When a character clobbers another character in the grid, we"
  },
  {
    "path": "webext/src/dom/tty_grid.js",
    "chars": 4362,
    "preview": "import utils from \"utils\";\n\n// The TTY grid\nexport default class {\n  constructor(dimensions, graphics_builder, config) {"
  },
  {
    "path": "webext/src/utils.js",
    "chars": 989,
    "preview": "export default {\n  mixins: function (...mixins) {\n    return mixins.reduce((base, mixin) => {\n      return mixin(base);\n"
  },
  {
    "path": "webext/test/fixtures/canvas_pixels.js",
    "chars": 2558,
    "preview": "// Generate fake pixel data, as if sreenshotting a canvas element.\n//\n// The RGB channels are repurposed to indicate the"
  },
  {
    "path": "webext/test/fixtures/text_nodes.js",
    "chars": 1366,
    "preview": "// Create DOM-compatible DOM Rectangles from a simple array of strings\nexport default class TextNodes {\n  constructor() "
  },
  {
    "path": "webext/test/graphics_builder_spec.js",
    "chars": 2022,
    "preview": "import helper from \"helper\";\nimport { expect } from \"chai\";\n\ndescribe(\"Graphics Builder\", () => {\n  let graphics_builder"
  },
  {
    "path": "webext/test/helper.js",
    "chars": 4392,
    "preview": "import sinon from \"sinon\";\n\nimport Dimensions from \"dom/dimensions\";\nimport GraphicsBuilder from \"dom/graphics_builder\";"
  },
  {
    "path": "webext/test/mocks/range.js",
    "chars": 206,
    "preview": "export default class MockRange {\n  selectNode(node) {\n    this.node = node;\n  }\n  getBoundingClientRect() {\n    return t"
  },
  {
    "path": "webext/test/text_builder_spec.js",
    "chars": 3395,
    "preview": "import { expect } from \"chai\";\nimport helper from \"helper\";\n\nlet text_builder, grid;\n\ndescribe(\"Text Builder\", () => {\n "
  },
  {
    "path": "webext/webpack.config.js",
    "chars": 1594,
    "preview": "import webpack from 'webpack';\nimport path from 'path';\nimport CopyWebpackPlugin from 'copy-webpack-plugin';\nimport fs f"
  }
]

About this extraction

This page contains the full source code of the browsh-org/browsh GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 88 files (297.9 KB), approximately 87.2k tokens, and a symbol index with 507 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!